From 494eb1282d67547323811d72d7dbc79e099f5985 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:39:08 -0400 Subject: [PATCH 01/20] WIP --- packages/tailwindcss/src/plugin-api.test.ts | 102 ++++++++++++++++++++ packages/tailwindcss/src/plugin-api.ts | 82 +++++++++++++++- 2 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 packages/tailwindcss/src/plugin-api.test.ts diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts new file mode 100644 index 000000000..3cd0c0da8 --- /dev/null +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -0,0 +1,102 @@ +import { test } from 'vitest' +import { compile } from '.' +import plugin from './plugin' + +const css = String.raw + +test('plugin', async ({ expect }) => { + let input = css` + @theme reference { + --color-red-100: #fee2e2; + --color-red-300: #fca5a5; + --color-red-500: #ef4444; + --color-red-700: #b91c1c; + --color-red-900: #7f1d1d; + --color-orange-100: #ffedd5; + --color-orange-300: #fdba74; + --color-orange-500: #f97316; + --color-orange-700: #c2410c; + --color-orange-900: #7c2d12; + } + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ addUtilities, matchUtilities, theme }) { + addUtilities({ + '@keyframes enter': theme('keyframes.enter'), + '@keyframes exit': theme('keyframes.exit'), + }) + matchUtilities( + { + scrollbar: (value) => ({ 'scrollbar-color': value }), + }, + { + values: theme('backgroundColor'), + }, + ) + }, + { + theme: { + extend: { + colors: { + 'russet-50': '#faf5ec', + 'russet-100': '#f2e9cf', + 'russet-200': '#e6d1a2', + 'russet-300': '#d8b36c', + 'russet-400': '#cb9744', + 'russet-500': '#bc8236', + 'russet-600': '#a2662c', + 'russet-700': '#7a4724', + 'russet-800': '#6d3f26', + 'russet-900': '#5e3625', + 'russet-950': '#361b12', + }, + 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(['scrollbar-red-500', 'scrollbar-russet-700'])).toMatchInlineSnapshot(` + ".scrollbar-red-500 { + scrollbar-color: var(--color-red-500, #ef4444); + } + .scrollbar-russet-700 { + scrollbar-color: #7a4724; + } + @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)); + } + } + " + `) +}) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 3599c7a20..6dd3a6026 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,8 +1,10 @@ import { substituteAtApply } from './apply' import { objectToAst, rule, type AstNode, type CssInJs } from './ast' import type { DesignSystem } from './design-system' +import type { ThemeKey } from './theme' import { withAlpha, withNegative } from './utilities' import { inferDataType } from './utils/infer-data-type' +import { segment } from './utils/segment' export type Config = Record @@ -28,11 +30,16 @@ export type PluginAPI = { 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 { return { addBase(css) { ast.push(rule('@layer base', objectToAst(css))) @@ -61,6 +68,11 @@ export function buildPluginApi(designSystem: DesignSystem, ast: AstNode[]): Plug 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\`.`, @@ -186,27 +198,87 @@ export function buildPluginApi(designSystem: DesignSystem, ast: AstNode[]): Plug }) } }, + theme(path) { + let parts = segment(path, '.') + + let result = resolvedConfig.theme ?? {} + + for (let part of parts) { + result = result?.[part] + } + + if ( + [ + 'accentColor', + 'backgroundColor', + 'borderColor', + 'boxShadowColor', + 'caretColor', + 'divideColor', + 'fill', + 'gradientColorStops', + 'outlineColor', + 'placeholderColor', + 'ringColor', + 'ringOffsetColor', + 'stroke', + 'textColor', + 'textDecorationColor', + ].includes(path) + ) { + // Convert camelCase to kebab-case: + // https://github.com/postcss/postcss-js/blob/b3db658b932b42f6ac14ca0b1d50f50c4569805b/parser.js#L30-L35 + let newPath = `--${path.replace(/([A-Z])/g, '-$1').toLowerCase()}` as ThemeKey + + for (let [key] of designSystem.theme.namespace('--color')) { + result[key ?? 'DEFAULT'] = designSystem.theme.resolve(key, ['--color']) + } + + for (let [key] of designSystem.theme.namespace(newPath)) { + result[key ?? 'DEFAULT'] = designSystem.theme.resolve(key, [newPath]) + } + } + + 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 Config }) } } + + // Now merge all the configs and make all that crap work + let resolvedConfig: Config = { theme: {} } + + for (let { config } of pluginObjects) { + for (let key in config?.theme?.extend) { + resolvedConfig.theme[key] ??= {} + Object.assign(resolvedConfig.theme[key], config?.theme.extend[key]) + } + } + + 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) + } } From 58cbbc5759f25363ea016da87958f536a817aaed Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:09:05 -0400 Subject: [PATCH 02/20] Reorganize tests --- packages/tailwindcss/src/plugin-api.test.ts | 178 ++++++++++---------- 1 file changed, 93 insertions(+), 85 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 3cd0c0da8..a9159c86b 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -1,102 +1,110 @@ -import { test } from 'vitest' +import { describe, test } from 'vitest' import { compile } from '.' import plugin from './plugin' const css = String.raw -test('plugin', async ({ expect }) => { - let input = css` - @theme reference { - --color-red-100: #fee2e2; - --color-red-300: #fca5a5; - --color-red-500: #ef4444; - --color-red-700: #b91c1c; - --color-red-900: #7f1d1d; - --color-orange-100: #ffedd5; - --color-orange-300: #fdba74; - --color-orange-500: #f97316; - --color-orange-700: #c2410c; - --color-orange-900: #7c2d12; - } - @tailwind utilities; - @plugin "my-plugin"; - ` +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 ({ addUtilities, matchUtilities, theme }) { - addUtilities({ - '@keyframes enter': theme('keyframes.enter'), - '@keyframes exit': theme('keyframes.exit'), - }) - matchUtilities( - { - scrollbar: (value) => ({ 'scrollbar-color': value }), - }, - { - values: theme('backgroundColor'), - }, - ) - }, - { - theme: { - extend: { - colors: { - 'russet-50': '#faf5ec', - 'russet-100': '#f2e9cf', - 'russet-200': '#e6d1a2', - 'russet-300': '#d8b36c', - 'russet-400': '#cb9744', - 'russet-500': '#bc8236', - 'russet-600': '#a2662c', - 'russet-700': '#7a4724', - 'russet-800': '#6d3f26', - 'russet-900': '#5e3625', - 'russet-950': '#361b12', - }, - 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))', + 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))', + 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)); + } + } + } + " + `) }) - 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; - } - @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)); + test('plugin theme can extend colors', async ({ expect }) => { + let input = css` + @theme reference { + --color-red-500: #ef4444; } - } - @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)); + @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-russet-700 { + scrollbar-color: #7a4724; } - } - " - `) + " + `) + }) }) From 22f72362bf77cce0d97dee67fbf7f212f2e53950 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Wed, 14 Aug 2024 10:04:09 -0400 Subject: [PATCH 03/20] WIP --- packages/tailwindcss/src/plugin-api.test.ts | 47 ++++++++++++- packages/tailwindcss/src/plugin-api.ts | 76 ++++++++++----------- 2 files changed, 83 insertions(+), 40 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index a9159c86b..3c8b68aa6 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -101,10 +101,55 @@ describe('theme', async () => { }) expect(compiler.build(['scrollbar-red-500', 'scrollbar-russet-700'])).toMatchInlineSnapshot(` - ".scrollbar-russet-700 { + ".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 }) => ({ + ...theme('transitionDuration'), + }), + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animate-duration-316'])).toMatchInlineSnapshot(` + ".animate-duration-316 { + animation-duration: 316ms; + } + " + `) + }) }) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 6dd3a6026..2890329b0 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,7 +1,6 @@ import { substituteAtApply } from './apply' import { objectToAst, rule, type AstNode, type CssInJs } from './ast' import type { DesignSystem } from './design-system' -import type { ThemeKey } from './theme' import { withAlpha, withNegative } from './utilities' import { inferDataType } from './utils/infer-data-type' import { segment } from './utils/segment' @@ -26,7 +25,9 @@ export type PluginAPI = { options?: Partial<{ type: string | string[] supportsNegativeValues: boolean - values: Record + values: Record & { + [BARE_VALUE]?: (value: string) => string | null | undefined + } modifiers: 'any' | Record }>, ): void @@ -132,7 +133,7 @@ export function buildPluginApi( let isColor = types.includes('color') // Resolve the candidate value - let value: string | null + let value: string | null = null { let values = options?.values ?? {} @@ -152,8 +153,10 @@ export function buildPluginApi( value = values.DEFAULT ?? null } else if (candidate.value.kind === 'arbitrary') { value = candidate.value.value - } else { - value = values[candidate.value.value] ?? null + } else if (values[candidate.value.value]) { + value = values[candidate.value.value] + } else if (values[BARE_VALUE]) { + value = values[BARE_VALUE](candidate.value.value) ?? null } } @@ -199,51 +202,30 @@ export function buildPluginApi( } }, theme(path) { - let parts = segment(path, '.') + if (path === 'colors') { + let result: Record = resolvedConfig.theme?.colors ?? {} + for (let [key] of designSystem.theme.namespace('--color')) { + result[key ?? 'DEFAULT'] = designSystem.theme.resolve(key, ['--color'])! + } + + return result + } + + let parts = segment(path, '.') let result = resolvedConfig.theme ?? {} for (let part of parts) { result = result?.[part] } - if ( - [ - 'accentColor', - 'backgroundColor', - 'borderColor', - 'boxShadowColor', - 'caretColor', - 'divideColor', - 'fill', - 'gradientColorStops', - 'outlineColor', - 'placeholderColor', - 'ringColor', - 'ringOffsetColor', - 'stroke', - 'textColor', - 'textDecorationColor', - ].includes(path) - ) { - // Convert camelCase to kebab-case: - // https://github.com/postcss/postcss-js/blob/b3db658b932b42f6ac14ca0b1d50f50c4569805b/parser.js#L30-L35 - let newPath = `--${path.replace(/([A-Z])/g, '-$1').toLowerCase()}` as ThemeKey - - for (let [key] of designSystem.theme.namespace('--color')) { - result[key ?? 'DEFAULT'] = designSystem.theme.resolve(key, ['--color']) - } - - for (let [key] of designSystem.theme.namespace(newPath)) { - result[key ?? 'DEFAULT'] = designSystem.theme.resolve(key, [newPath]) - } - } - return result }, } } +const BARE_VALUE = Symbol('BARE_VALUE') + export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) { let pluginObjects = [] @@ -271,7 +253,23 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a for (let { config } of pluginObjects) { for (let key in config?.theme?.extend) { resolvedConfig.theme[key] ??= {} - Object.assign(resolvedConfig.theme[key], config?.theme.extend[key]) + let value = config.theme.extend[key] + if (value instanceof Function) { + value = value({ + theme(path: string) { + if (path === 'transitionDuration') { + return { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}ms` + } + }, + } + } + }, + }) + } + Object.assign(resolvedConfig.theme[key], value) } } From 5111f44efa7e86ba92297901cc25802e97bf159e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 11:54:04 -0400 Subject: [PATCH 04/20] wip --- .../tailwindcss/src/compat/config/colors.ts | 293 ++++ .../src/compat/config/deep-freeze.ts | 14 + .../src/compat/config/deep-merge.ts | 46 + .../tailwindcss/src/compat/config/default.ts | 1064 ++++++++++++ .../src/compat/config/resolve-config.test.ts | 141 ++ .../src/compat/config/resolve-config.ts | 228 +++ .../src/compat/config/resolve.test.ts | 1520 +++++++++++++++++ .../tailwindcss/src/compat/config/types.ts | 78 + packages/tailwindcss/src/plugin-api.test.ts | 109 +- packages/tailwindcss/src/plugin-api.ts | 48 +- 10 files changed, 3510 insertions(+), 31 deletions(-) create mode 100644 packages/tailwindcss/src/compat/config/colors.ts create mode 100644 packages/tailwindcss/src/compat/config/deep-freeze.ts create mode 100644 packages/tailwindcss/src/compat/config/deep-merge.ts create mode 100644 packages/tailwindcss/src/compat/config/default.ts create mode 100644 packages/tailwindcss/src/compat/config/resolve-config.test.ts create mode 100644 packages/tailwindcss/src/compat/config/resolve-config.ts create mode 100644 packages/tailwindcss/src/compat/config/resolve.test.ts create mode 100644 packages/tailwindcss/src/compat/config/types.ts diff --git a/packages/tailwindcss/src/compat/config/colors.ts b/packages/tailwindcss/src/compat/config/colors.ts new file mode 100644 index 000000000..eed63663f --- /dev/null +++ b/packages/tailwindcss/src/compat/config/colors.ts @@ -0,0 +1,293 @@ +export const colors = { + inherit: 'inherit', + current: 'currentColor', + transparent: 'transparent', + black: '#000', + white: '#fff', + slate: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', + }, + gray: { + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', + 950: '#030712', + }, + zinc: { + 50: '#fafafa', + 100: '#f4f4f5', + 200: '#e4e4e7', + 300: '#d4d4d8', + 400: '#a1a1aa', + 500: '#71717a', + 600: '#52525b', + 700: '#3f3f46', + 800: '#27272a', + 900: '#18181b', + 950: '#09090b', + }, + neutral: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#e5e5e5', + 300: '#d4d4d4', + 400: '#a3a3a3', + 500: '#737373', + 600: '#525252', + 700: '#404040', + 800: '#262626', + 900: '#171717', + 950: '#0a0a0a', + }, + stone: { + 50: '#fafaf9', + 100: '#f5f5f4', + 200: '#e7e5e4', + 300: '#d6d3d1', + 400: '#a8a29e', + 500: '#78716c', + 600: '#57534e', + 700: '#44403c', + 800: '#292524', + 900: '#1c1917', + 950: '#0c0a09', + }, + red: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + 950: '#450a0a', + }, + orange: { + 50: '#fff7ed', + 100: '#ffedd5', + 200: '#fed7aa', + 300: '#fdba74', + 400: '#fb923c', + 500: '#f97316', + 600: '#ea580c', + 700: '#c2410c', + 800: '#9a3412', + 900: '#7c2d12', + 950: '#431407', + }, + amber: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + 950: '#451a03', + }, + yellow: { + 50: '#fefce8', + 100: '#fef9c3', + 200: '#fef08a', + 300: '#fde047', + 400: '#facc15', + 500: '#eab308', + 600: '#ca8a04', + 700: '#a16207', + 800: '#854d0e', + 900: '#713f12', + 950: '#422006', + }, + lime: { + 50: '#f7fee7', + 100: '#ecfccb', + 200: '#d9f99d', + 300: '#bef264', + 400: '#a3e635', + 500: '#84cc16', + 600: '#65a30d', + 700: '#4d7c0f', + 800: '#3f6212', + 900: '#365314', + 950: '#1a2e05', + }, + green: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + 950: '#052e16', + }, + emerald: { + 50: '#ecfdf5', + 100: '#d1fae5', + 200: '#a7f3d0', + 300: '#6ee7b7', + 400: '#34d399', + 500: '#10b981', + 600: '#059669', + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + 950: '#022c22', + }, + teal: { + 50: '#f0fdfa', + 100: '#ccfbf1', + 200: '#99f6e4', + 300: '#5eead4', + 400: '#2dd4bf', + 500: '#14b8a6', + 600: '#0d9488', + 700: '#0f766e', + 800: '#115e59', + 900: '#134e4a', + 950: '#042f2e', + }, + cyan: { + 50: '#ecfeff', + 100: '#cffafe', + 200: '#a5f3fc', + 300: '#67e8f9', + 400: '#22d3ee', + 500: '#06b6d4', + 600: '#0891b2', + 700: '#0e7490', + 800: '#155e75', + 900: '#164e63', + 950: '#083344', + }, + sky: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + 950: '#082f49', + }, + blue: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + 950: '#172554', + }, + indigo: { + 50: '#eef2ff', + 100: '#e0e7ff', + 200: '#c7d2fe', + 300: '#a5b4fc', + 400: '#818cf8', + 500: '#6366f1', + 600: '#4f46e5', + 700: '#4338ca', + 800: '#3730a3', + 900: '#312e81', + 950: '#1e1b4b', + }, + violet: { + 50: '#f5f3ff', + 100: '#ede9fe', + 200: '#ddd6fe', + 300: '#c4b5fd', + 400: '#a78bfa', + 500: '#8b5cf6', + 600: '#7c3aed', + 700: '#6d28d9', + 800: '#5b21b6', + 900: '#4c1d95', + 950: '#2e1065', + }, + purple: { + 50: '#faf5ff', + 100: '#f3e8ff', + 200: '#e9d5ff', + 300: '#d8b4fe', + 400: '#c084fc', + 500: '#a855f7', + 600: '#9333ea', + 700: '#7e22ce', + 800: '#6b21a8', + 900: '#581c87', + 950: '#3b0764', + }, + fuchsia: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + 950: '#4a044e', + }, + pink: { + 50: '#fdf2f8', + 100: '#fce7f3', + 200: '#fbcfe8', + 300: '#f9a8d4', + 400: '#f472b6', + 500: '#ec4899', + 600: '#db2777', + 700: '#be185d', + 800: '#9d174d', + 900: '#831843', + 950: '#500724', + }, + rose: { + 50: '#fff1f2', + 100: '#ffe4e6', + 200: '#fecdd3', + 300: '#fda4af', + 400: '#fb7185', + 500: '#f43f5e', + 600: '#e11d48', + 700: '#be123c', + 800: '#9f1239', + 900: '#881337', + 950: '#4c0519', + }, +} 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..3a32f19dc --- /dev/null +++ b/packages/tailwindcss/src/compat/config/deep-merge.ts @@ -0,0 +1,46 @@ +type Customizer = (a: any, b: any) => any + +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: Customizer, +) { + type Key = keyof T + type Value = T[Key] + type KnownSource = Record + + for (let source of sources) { + if (source === null || source === undefined) { + continue + } + + let keys = [ + // + ...Object.getOwnPropertyNames(source), + ...Object.getOwnPropertySymbols(source), + ] + + for (let k of keys 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 any + } else { + target[k] = deepMerge({}, [target[k], source[k]] as any, customizer) as any + } + } + } + + return target +} diff --git a/packages/tailwindcss/src/compat/config/default.ts b/packages/tailwindcss/src/compat/config/default.ts new file mode 100644 index 000000000..7a9602915 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/default.ts @@ -0,0 +1,1064 @@ +import type { UserConfig } from './types' + +export default { + content: ['auto'], + presets: [], + darkMode: 'media', // or 'class' + theme: { + accentColor: ({ theme }) => ({ + ...theme('colors'), + auto: 'auto', + }), + animation: { + none: 'none', + spin: 'spin 1s linear infinite', + ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', + pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + bounce: 'bounce 1s infinite', + }, + aria: { + busy: 'busy="true"', + checked: 'checked="true"', + disabled: 'disabled="true"', + expanded: 'expanded="true"', + hidden: 'hidden="true"', + pressed: 'pressed="true"', + readonly: 'readonly="true"', + required: 'required="true"', + selected: 'selected="true"', + }, + aspectRatio: { + auto: 'auto', + square: '1 / 1', + video: '16 / 9', + }, + backdropBlur: ({ theme }) => theme('blur'), + backdropBrightness: ({ theme }) => theme('brightness'), + backdropContrast: ({ theme }) => theme('contrast'), + backdropGrayscale: ({ theme }) => theme('grayscale'), + backdropHueRotate: ({ theme }) => theme('hueRotate'), + backdropInvert: ({ theme }) => theme('invert'), + backdropOpacity: ({ theme }) => theme('opacity'), + backdropSaturate: ({ theme }) => theme('saturate'), + backdropSepia: ({ theme }) => theme('sepia'), + backgroundColor: ({ theme }) => theme('colors'), + backgroundImage: { + none: 'none', + 'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))', + 'gradient-to-tr': 'linear-gradient(to top right, var(--tw-gradient-stops))', + 'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))', + 'gradient-to-br': 'linear-gradient(to bottom right, var(--tw-gradient-stops))', + 'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))', + 'gradient-to-bl': 'linear-gradient(to bottom left, var(--tw-gradient-stops))', + 'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))', + 'gradient-to-tl': 'linear-gradient(to top left, var(--tw-gradient-stops))', + }, + backgroundOpacity: ({ theme }) => theme('opacity'), + backgroundPosition: { + bottom: 'bottom', + center: 'center', + left: 'left', + 'left-bottom': 'left bottom', + 'left-top': 'left top', + right: 'right', + 'right-bottom': 'right bottom', + 'right-top': 'right top', + top: 'top', + }, + backgroundSize: { + auto: 'auto', + cover: 'cover', + contain: 'contain', + }, + blur: { + 0: '0', + none: '0', + sm: '4px', + DEFAULT: '8px', + md: '12px', + lg: '16px', + xl: '24px', + '2xl': '40px', + '3xl': '64px', + }, + borderColor: ({ theme }) => ({ + ...theme('colors'), + DEFAULT: theme('colors.gray.200', 'currentColor'), + }), + borderOpacity: ({ theme }) => theme('opacity'), + borderRadius: { + none: '0px', + sm: '0.125rem', + DEFAULT: '0.25rem', + md: '0.375rem', + lg: '0.5rem', + xl: '0.75rem', + '2xl': '1rem', + '3xl': '1.5rem', + full: '9999px', + }, + borderSpacing: ({ theme }) => ({ + ...theme('spacing'), + }), + borderWidth: { + DEFAULT: '1px', + 0: '0px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + boxShadow: { + sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', + DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', + '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', + inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)', + none: 'none', + }, + boxShadowColor: ({ theme }) => theme('colors'), + brightness: { + 0: '0', + 50: '.5', + 75: '.75', + 90: '.9', + 95: '.95', + 100: '1', + 105: '1.05', + 110: '1.1', + 125: '1.25', + 150: '1.5', + 200: '2', + }, + caretColor: ({ theme }) => theme('colors'), + colors: ({ colors }) => ({ + inherit: colors.inherit, + current: colors.current, + transparent: colors.transparent, + black: colors.black, + white: colors.white, + slate: colors.slate, + gray: colors.gray, + zinc: colors.zinc, + neutral: colors.neutral, + stone: colors.stone, + red: colors.red, + orange: colors.orange, + amber: colors.amber, + yellow: colors.yellow, + lime: colors.lime, + green: colors.green, + emerald: colors.emerald, + teal: colors.teal, + cyan: colors.cyan, + sky: colors.sky, + blue: colors.blue, + indigo: colors.indigo, + violet: colors.violet, + purple: colors.purple, + fuchsia: colors.fuchsia, + pink: colors.pink, + rose: colors.rose, + }), + columns: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + '3xs': '16rem', + '2xs': '18rem', + xs: '20rem', + sm: '24rem', + md: '28rem', + lg: '32rem', + xl: '36rem', + '2xl': '42rem', + '3xl': '48rem', + '4xl': '56rem', + '5xl': '64rem', + '6xl': '72rem', + '7xl': '80rem', + }, + container: {}, + content: { + none: 'none', + }, + contrast: { + 0: '0', + 50: '.5', + 75: '.75', + 100: '1', + 125: '1.25', + 150: '1.5', + 200: '2', + }, + cursor: { + auto: 'auto', + default: 'default', + pointer: 'pointer', + wait: 'wait', + text: 'text', + move: 'move', + help: 'help', + 'not-allowed': 'not-allowed', + none: 'none', + 'context-menu': 'context-menu', + progress: 'progress', + cell: 'cell', + crosshair: 'crosshair', + 'vertical-text': 'vertical-text', + alias: 'alias', + copy: 'copy', + 'no-drop': 'no-drop', + grab: 'grab', + grabbing: 'grabbing', + 'all-scroll': 'all-scroll', + 'col-resize': 'col-resize', + 'row-resize': 'row-resize', + 'n-resize': 'n-resize', + 'e-resize': 'e-resize', + 's-resize': 's-resize', + 'w-resize': 'w-resize', + 'ne-resize': 'ne-resize', + 'nw-resize': 'nw-resize', + 'se-resize': 'se-resize', + 'sw-resize': 'sw-resize', + 'ew-resize': 'ew-resize', + 'ns-resize': 'ns-resize', + 'nesw-resize': 'nesw-resize', + 'nwse-resize': 'nwse-resize', + 'zoom-in': 'zoom-in', + 'zoom-out': 'zoom-out', + }, + divideColor: ({ theme }) => theme('borderColor'), + divideOpacity: ({ theme }) => theme('borderOpacity'), + divideWidth: ({ theme }) => theme('borderWidth'), + dropShadow: { + sm: '0 1px 1px rgb(0 0 0 / 0.05)', + DEFAULT: ['0 1px 2px rgb(0 0 0 / 0.1)', '0 1px 1px rgb(0 0 0 / 0.06)'], + md: ['0 4px 3px rgb(0 0 0 / 0.07)', '0 2px 2px rgb(0 0 0 / 0.06)'], + lg: ['0 10px 8px rgb(0 0 0 / 0.04)', '0 4px 3px rgb(0 0 0 / 0.1)'], + xl: ['0 20px 13px rgb(0 0 0 / 0.03)', '0 8px 5px rgb(0 0 0 / 0.08)'], + '2xl': '0 25px 25px rgb(0 0 0 / 0.15)', + none: '0 0 #0000', + }, + fill: ({ theme }) => ({ + none: 'none', + ...theme('colors'), + }), + flex: { + 1: '1 1 0%', + auto: '1 1 auto', + initial: '0 1 auto', + none: 'none', + }, + flexBasis: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + '1/12': '8.333333%', + '2/12': '16.666667%', + '3/12': '25%', + '4/12': '33.333333%', + '5/12': '41.666667%', + '6/12': '50%', + '7/12': '58.333333%', + '8/12': '66.666667%', + '9/12': '75%', + '10/12': '83.333333%', + '11/12': '91.666667%', + full: '100%', + }), + flexGrow: { + 0: '0', + DEFAULT: '1', + }, + flexShrink: { + 0: '0', + DEFAULT: '1', + }, + fontFamily: { + sans: [ + 'ui-sans-serif', + 'system-ui', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + '"Noto Color Emoji"', + ], + serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], + mono: [ + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + '"Liberation Mono"', + '"Courier New"', + 'monospace', + ], + }, + fontSize: { + xs: ['0.75rem', { lineHeight: '1rem' }], + sm: ['0.875rem', { lineHeight: '1.25rem' }], + base: ['1rem', { lineHeight: '1.5rem' }], + lg: ['1.125rem', { lineHeight: '1.75rem' }], + xl: ['1.25rem', { lineHeight: '1.75rem' }], + '2xl': ['1.5rem', { lineHeight: '2rem' }], + '3xl': ['1.875rem', { lineHeight: '2.25rem' }], + '4xl': ['2.25rem', { lineHeight: '2.5rem' }], + '5xl': ['3rem', { lineHeight: '1' }], + '6xl': ['3.75rem', { lineHeight: '1' }], + '7xl': ['4.5rem', { lineHeight: '1' }], + '8xl': ['6rem', { lineHeight: '1' }], + '9xl': ['8rem', { lineHeight: '1' }], + }, + fontWeight: { + thin: '100', + extralight: '200', + light: '300', + normal: '400', + medium: '500', + semibold: '600', + bold: '700', + extrabold: '800', + black: '900', + }, + gap: ({ theme }) => theme('spacing'), + gradientColorStops: ({ theme }) => theme('colors'), + gradientColorStopPositions: { + '0%': '0%', + '5%': '5%', + '10%': '10%', + '15%': '15%', + '20%': '20%', + '25%': '25%', + '30%': '30%', + '35%': '35%', + '40%': '40%', + '45%': '45%', + '50%': '50%', + '55%': '55%', + '60%': '60%', + '65%': '65%', + '70%': '70%', + '75%': '75%', + '80%': '80%', + '85%': '85%', + '90%': '90%', + '95%': '95%', + '100%': '100%', + }, + grayscale: { + 0: '0', + DEFAULT: '100%', + }, + gridAutoColumns: { + auto: 'auto', + min: 'min-content', + max: 'max-content', + fr: 'minmax(0, 1fr)', + }, + gridAutoRows: { + auto: 'auto', + min: 'min-content', + max: 'max-content', + fr: 'minmax(0, 1fr)', + }, + gridColumn: { + auto: 'auto', + 'span-1': 'span 1 / span 1', + 'span-2': 'span 2 / span 2', + 'span-3': 'span 3 / span 3', + 'span-4': 'span 4 / span 4', + 'span-5': 'span 5 / span 5', + 'span-6': 'span 6 / span 6', + 'span-7': 'span 7 / span 7', + 'span-8': 'span 8 / span 8', + 'span-9': 'span 9 / span 9', + 'span-10': 'span 10 / span 10', + 'span-11': 'span 11 / span 11', + 'span-12': 'span 12 / span 12', + 'span-full': '1 / -1', + }, + gridColumnEnd: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridColumnStart: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridRow: { + auto: 'auto', + 'span-1': 'span 1 / span 1', + 'span-2': 'span 2 / span 2', + 'span-3': 'span 3 / span 3', + 'span-4': 'span 4 / span 4', + 'span-5': 'span 5 / span 5', + 'span-6': 'span 6 / span 6', + 'span-7': 'span 7 / span 7', + 'span-8': 'span 8 / span 8', + 'span-9': 'span 9 / span 9', + 'span-10': 'span 10 / span 10', + 'span-11': 'span 11 / span 11', + 'span-12': 'span 12 / span 12', + 'span-full': '1 / -1', + }, + gridRowEnd: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridRowStart: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridTemplateColumns: { + none: 'none', + subgrid: 'subgrid', + 1: 'repeat(1, minmax(0, 1fr))', + 2: 'repeat(2, minmax(0, 1fr))', + 3: 'repeat(3, minmax(0, 1fr))', + 4: 'repeat(4, minmax(0, 1fr))', + 5: 'repeat(5, minmax(0, 1fr))', + 6: 'repeat(6, minmax(0, 1fr))', + 7: 'repeat(7, minmax(0, 1fr))', + 8: 'repeat(8, minmax(0, 1fr))', + 9: 'repeat(9, minmax(0, 1fr))', + 10: 'repeat(10, minmax(0, 1fr))', + 11: 'repeat(11, minmax(0, 1fr))', + 12: 'repeat(12, minmax(0, 1fr))', + }, + gridTemplateRows: { + none: 'none', + subgrid: 'subgrid', + 1: 'repeat(1, minmax(0, 1fr))', + 2: 'repeat(2, minmax(0, 1fr))', + 3: 'repeat(3, minmax(0, 1fr))', + 4: 'repeat(4, minmax(0, 1fr))', + 5: 'repeat(5, minmax(0, 1fr))', + 6: 'repeat(6, minmax(0, 1fr))', + 7: 'repeat(7, minmax(0, 1fr))', + 8: 'repeat(8, minmax(0, 1fr))', + 9: 'repeat(9, minmax(0, 1fr))', + 10: 'repeat(10, minmax(0, 1fr))', + 11: 'repeat(11, minmax(0, 1fr))', + 12: 'repeat(12, minmax(0, 1fr))', + }, + height: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + full: '100%', + screen: '100vh', + svh: '100svh', + lvh: '100lvh', + dvh: '100dvh', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + hueRotate: { + 0: '0deg', + 15: '15deg', + 30: '30deg', + 60: '60deg', + 90: '90deg', + 180: '180deg', + }, + inset: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + full: '100%', + }), + invert: { + 0: '0', + DEFAULT: '100%', + }, + keyframes: { + spin: { + to: { + transform: 'rotate(360deg)', + }, + }, + ping: { + '75%, 100%': { + transform: 'scale(2)', + opacity: '0', + }, + }, + pulse: { + '50%': { + opacity: '.5', + }, + }, + bounce: { + '0%, 100%': { + transform: 'translateY(-25%)', + animationTimingFunction: 'cubic-bezier(0.8,0,1,1)', + }, + '50%': { + transform: 'none', + animationTimingFunction: 'cubic-bezier(0,0,0.2,1)', + }, + }, + }, + letterSpacing: { + tighter: '-0.05em', + tight: '-0.025em', + normal: '0em', + wide: '0.025em', + wider: '0.05em', + widest: '0.1em', + }, + lineHeight: { + none: '1', + tight: '1.25', + snug: '1.375', + normal: '1.5', + relaxed: '1.625', + loose: '2', + 3: '.75rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + }, + listStyleType: { + none: 'none', + disc: 'disc', + decimal: 'decimal', + }, + listStyleImage: { + none: 'none', + }, + margin: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + }), + lineClamp: { + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + }, + maxHeight: ({ theme }) => ({ + ...theme('spacing'), + none: 'none', + full: '100%', + screen: '100vh', + svh: '100svh', + lvh: '100lvh', + dvh: '100dvh', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + maxWidth: ({ theme, breakpoints }) => ({ + ...theme('spacing'), + none: 'none', + xs: '20rem', + sm: '24rem', + md: '28rem', + lg: '32rem', + xl: '36rem', + '2xl': '42rem', + '3xl': '48rem', + '4xl': '56rem', + '5xl': '64rem', + '6xl': '72rem', + '7xl': '80rem', + full: '100%', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + prose: '65ch', + ...breakpoints(theme('screens')), + }), + minHeight: ({ theme }) => ({ + ...theme('spacing'), + full: '100%', + screen: '100vh', + svh: '100svh', + lvh: '100lvh', + dvh: '100dvh', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + minWidth: ({ theme }) => ({ + ...theme('spacing'), + full: '100%', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + objectPosition: { + bottom: 'bottom', + center: 'center', + left: 'left', + 'left-bottom': 'left bottom', + 'left-top': 'left top', + right: 'right', + 'right-bottom': 'right bottom', + 'right-top': 'right top', + top: 'top', + }, + opacity: { + 0: '0', + 5: '0.05', + 10: '0.1', + 15: '0.15', + 20: '0.2', + 25: '0.25', + 30: '0.3', + 35: '0.35', + 40: '0.4', + 45: '0.45', + 50: '0.5', + 55: '0.55', + 60: '0.6', + 65: '0.65', + 70: '0.7', + 75: '0.75', + 80: '0.8', + 85: '0.85', + 90: '0.9', + 95: '0.95', + 100: '1', + }, + order: { + first: '-9999', + last: '9999', + none: '0', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + }, + outlineColor: ({ theme }) => theme('colors'), + outlineOffset: { + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + outlineWidth: { + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + padding: ({ theme }) => theme('spacing'), + placeholderColor: ({ theme }) => theme('colors'), + placeholderOpacity: ({ theme }) => theme('opacity'), + ringColor: ({ theme }) => ({ + DEFAULT: theme('colors.blue.500', '#3b82f6'), + ...theme('colors'), + }), + ringOffsetColor: ({ theme }) => theme('colors'), + ringOffsetWidth: { + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + ringOpacity: ({ theme }) => ({ + DEFAULT: '0.5', + ...theme('opacity'), + }), + ringWidth: { + DEFAULT: '3px', + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + rotate: { + 0: '0deg', + 1: '1deg', + 2: '2deg', + 3: '3deg', + 6: '6deg', + 12: '12deg', + 45: '45deg', + 90: '90deg', + 180: '180deg', + }, + saturate: { + 0: '0', + 50: '.5', + 100: '1', + 150: '1.5', + 200: '2', + }, + scale: { + 0: '0', + 50: '.5', + 75: '.75', + 90: '.9', + 95: '.95', + 100: '1', + 105: '1.05', + 110: '1.1', + 125: '1.25', + 150: '1.5', + }, + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + }, + scrollMargin: ({ theme }) => ({ + ...theme('spacing'), + }), + scrollPadding: ({ theme }) => theme('spacing'), + sepia: { + 0: '0', + DEFAULT: '100%', + }, + skew: { + 0: '0deg', + 1: '1deg', + 2: '2deg', + 3: '3deg', + 6: '6deg', + 12: '12deg', + }, + space: ({ theme }) => ({ + ...theme('spacing'), + }), + spacing: { + px: '1px', + 0: '0px', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 96: '24rem', + }, + stroke: ({ theme }) => ({ + none: 'none', + ...theme('colors'), + }), + strokeWidth: { + 0: '0', + 1: '1', + 2: '2', + }, + supports: {}, + data: {}, + textColor: ({ theme }) => theme('colors'), + textDecorationColor: ({ theme }) => theme('colors'), + textDecorationThickness: { + auto: 'auto', + 'from-font': 'from-font', + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + textIndent: ({ theme }) => ({ + ...theme('spacing'), + }), + textOpacity: ({ theme }) => theme('opacity'), + textUnderlineOffset: { + auto: 'auto', + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + transformOrigin: { + center: 'center', + top: 'top', + 'top-right': 'top right', + right: 'right', + 'bottom-right': 'bottom right', + bottom: 'bottom', + 'bottom-left': 'bottom left', + left: 'left', + 'top-left': 'top left', + }, + transitionDelay: { + 0: '0s', + 75: '75ms', + 100: '100ms', + 150: '150ms', + 200: '200ms', + 300: '300ms', + 500: '500ms', + 700: '700ms', + 1000: '1000ms', + }, + transitionDuration: { + DEFAULT: '150ms', + 0: '0s', + 75: '75ms', + 100: '100ms', + 150: '150ms', + 200: '200ms', + 300: '300ms', + 500: '500ms', + 700: '700ms', + 1000: '1000ms', + }, + transitionProperty: { + none: 'none', + all: 'all', + DEFAULT: + 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter', + colors: 'color, background-color, border-color, text-decoration-color, fill, stroke', + opacity: 'opacity', + shadow: 'box-shadow', + transform: 'transform', + }, + transitionTimingFunction: { + DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)', + linear: 'linear', + in: 'cubic-bezier(0.4, 0, 1, 1)', + out: 'cubic-bezier(0, 0, 0.2, 1)', + 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)', + }, + translate: ({ theme }) => ({ + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + full: '100%', + }), + size: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + '1/12': '8.333333%', + '2/12': '16.666667%', + '3/12': '25%', + '4/12': '33.333333%', + '5/12': '41.666667%', + '6/12': '50%', + '7/12': '58.333333%', + '8/12': '66.666667%', + '9/12': '75%', + '10/12': '83.333333%', + '11/12': '91.666667%', + full: '100%', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + width: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + '1/12': '8.333333%', + '2/12': '16.666667%', + '3/12': '25%', + '4/12': '33.333333%', + '5/12': '41.666667%', + '6/12': '50%', + '7/12': '58.333333%', + '8/12': '66.666667%', + '9/12': '75%', + '10/12': '83.333333%', + '11/12': '91.666667%', + full: '100%', + screen: '100vw', + svw: '100svw', + lvw: '100lvw', + dvw: '100dvw', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + willChange: { + auto: 'auto', + scroll: 'scroll-position', + contents: 'contents', + transform: 'transform', + }, + zIndex: { + auto: 'auto', + 0: '0', + 10: '10', + 20: '20', + 30: '30', + 40: '40', + 50: '50', + }, + }, + plugins: [], +} 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/resolve.test.ts b/packages/tailwindcss/src/compat/config/resolve.test.ts new file mode 100644 index 000000000..45fa67c05 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/resolve.test.ts @@ -0,0 +1,1520 @@ +import { describe, expect, it, test } from 'vitest' +import { corePlugins } from '../generated/core-utils' +import { Resolver } from './resolve' +import type { ConfigurablePlugin, RawPlugin, UserConfig } from './types' + +let corePluginsDisabled = Object.fromEntries( + Object.entries(corePlugins).map(([name]) => [name, false]), +) + +let resolver = new Resolver() + +function stubs() { + let list: Record = {} + + function stub(id: number, config: UserConfig = {}): ConfigurablePlugin { + return (list[id] ??= { + config: () => config, + handler: Object.assign(() => {}, { + get [Symbol.toStringTag]() { + // The ID is for debugging + return `stub ${id}` + }, + }), + }) + } + + Object.defineProperty(stub, 'all', { + get() { + return Object.values(list).map((list) => list.handler) + }, + }) + + return stub as typeof stub & { + readonly all: RawPlugin[] + } +} + +describe('general', () => { + it('resolves to a minimal config when passed nothing', async () => { + let config = await resolver.resolve([]) + + expect(config).toEqual({ + blocklist: [], + content: { + files: [], + transform: {}, + }, + prefix: '', + separator: ':', + important: false, + darkMode: 'media', + plugins: [], + theme: {}, + corePlugins, + }) + }) + + it('replaces top level properties', async () => { + let config = await resolver.resolve([ + { prefix: '', important: false, separator: ':' }, + { prefix: 'tw-', important: true, separator: '_' }, + ]) + + expect(config).toMatchObject({ + prefix: 'tw-', + important: true, + separator: '_', + }) + }) + + it('can resolve 3+ configs into one', async () => { + let config = await resolver.resolve([ + { + theme: { + fontFamily: { + body: ['Arial', 'sans-serif'], + display: ['Georgia', 'serif'], + }, + colors: { + blue: 'blue', + }, + backgroundColor: ({ theme }) => theme('colors'), + }, + }, + { + theme: { + extend: { + fontFamily: { + hero: ['Futura', 'sans-serif'], + }, + colors: { + pink: 'pink', + }, + backgroundColor: () => ({ + customBackgroundThree: '#c0ffee', + }), + textDecorationColor: { + lime: 'lime', + }, + }, + }, + }, + { + theme: { + extend: { + fontFamily: { + quote: ['Helvetica', 'serif'], + }, + colors: { + green: 'green', + }, + backgroundColor: { + customBackgroundTwo: '#facade', + }, + textDecorationColor: ({ theme }) => theme('colors'), + }, + }, + }, + { + theme: { + extend: { + fontFamily: () => ({ + code: ['Menlo', 'monospace'], + }), + colors: { + red: 'red', + }, + backgroundColor: { + customBackgroundOne: '#bada55', + }, + textDecorationColor: { + orange: 'orange', + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + fontFamily: { + body: ['Arial', 'sans-serif'], + display: ['Georgia', 'serif'], + code: ['Menlo', 'monospace'], + quote: ['Helvetica', 'serif'], + hero: ['Futura', 'sans-serif'], + }, + colors: { + red: 'red', + green: 'green', + blue: 'blue', + pink: 'pink', + }, + backgroundColor: { + red: 'red', + green: 'green', + blue: 'blue', + pink: 'pink', + customBackgroundOne: '#bada55', + customBackgroundTwo: '#facade', + customBackgroundThree: '#c0ffee', + }, + textDecorationColor: { + red: 'red', + green: 'green', + blue: 'blue', + pink: 'pink', + orange: 'orange', + lime: 'lime', + }, + }) + }) + + it('does not duplicate extended configs every time resolve is called', async () => { + let shared = { + foo: { bar: { baz: [{ color: 'red' }] } }, + } + + const createConfig = (color: string) => + resolver.resolve([ + { + theme: { + foo: shared.foo, + extend: { + foo: { bar: { baz: { color } } }, + }, + }, + }, + ]) + + await createConfig('orange') + await createConfig('yellow') + await createConfig('green') + + let config = await createConfig('blue') + + expect(shared.foo.bar.baz).toMatchObject([{ color: 'red' }]) + + // @ts-ignore + expect(config.theme.foo.bar.baz).toMatchObject([{ color: 'red' }, { color: 'blue' }]) + }) +}) + +describe('theme', () => { + it('shallow merges themes across configs', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + 'grey-darker': '#606f7b', + 'grey-dark': '#8795a1', + grey: '#b8c2cc', + 'grey-light': '#dae1e7', + 'grey-lighter': '#f1f5f8', + }, + fonts: { + sans: ['system-ui', 'Roboto', 'sans-serif'], + serif: ['Constantia', 'Lucida Bright', 'Georgia', 'serif'], + }, + screens: { + sm: '500px', + md: '750px', + lg: '1000px', + }, + }, + }, + { + theme: { + screens: { + mobile: '400px', + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + 'grey-darker': '#606f7b', + 'grey-dark': '#8795a1', + grey: '#b8c2cc', + 'grey-light': '#dae1e7', + 'grey-lighter': '#f1f5f8', + }, + fonts: { + sans: ['system-ui', 'Roboto', 'sans-serif'], + serif: ['Constantia', 'Lucida Bright', 'Georgia', 'serif'], + }, + screens: { + mobile: '400px', + }, + }) + }) + + it('deeply merges theme extensions across configs', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + grey: { + light: '#dae1e7', + }, + }, + }, + }, + { + theme: { + extend: { + colors: { + grey: { + dark: '#8795a1', + }, + }, + }, + }, + }, + ]) + + expect(config.theme).toEqual({ + colors: { + grey: { + dark: '#8795a1', + light: '#dae1e7', + }, + }, + }) + }) + + it('lazily evaluates functions in themes (1)', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + backgroundColors: ({ theme }) => theme('colors'), + textColors: ({ theme }) => theme('colors'), + }, + }, + { + theme: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + textColors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + }) + }) + + it('lazily evaluates functions in themes (2)', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + backgroundColors: ({ colors }) => colors, + textColors: ({ colors }) => colors, + }, + }, + { + theme: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColors: ({ theme }) => ({ + ...theme('colors'), + customBackground: '#bada55', + }), + textColors: ({ theme }) => ({ + ...theme('colors'), + customText: '#facade', + }), + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColors: { + red: 'red', + green: 'green', + blue: 'blue', + customBackground: '#bada55', + }, + textColors: { + red: 'red', + green: 'green', + blue: 'blue', + customText: '#facade', + }, + }) + }) + + it('theme values in the extend section extend the existing theme', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + opacity: { + 0: '0', + 50: '.5', + 100: '1', + }, + backgroundColors: ({ theme }) => theme('colors'), + }, + }, + { + theme: { + extend: { + opacity: { + 25: '25', + 75: '.75', + }, + backgroundColors: { + customBackground: '#bada55', + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + opacity: { + 0: '0', + 50: '.5', + 100: '1', + 25: '25', + 75: '.75', + }, + backgroundColors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + customBackground: '#bada55', + }, + }) + }) + + test('theme values in the extend section extend the user theme', async () => { + let config = await resolver.resolve([ + { + theme: { + opacity: { + 0: '0', + 50: '.5', + 100: '1', + }, + height: { + 0: 0, + full: '100%', + }, + width: { + 0: 0, + 1: '.25rem', + 2: '.5rem', + 3: '.75rem', + 4: '1rem', + }, + }, + }, + { + theme: { + opacity: { + 0: '0', + 20: '.2', + 40: '.4', + }, + height: ({ theme }) => theme('width'), + extend: { + opacity: { + 60: '.6', + 80: '.8', + 100: '1', + }, + height: { + customHeight: '500vh', + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + opacity: { + 0: '0', + 20: '.2', + 40: '.4', + 60: '.6', + 80: '.8', + 100: '1', + }, + height: { + 0: 0, + 1: '.25rem', + 2: '.5rem', + 3: '.75rem', + 4: '1rem', + customHeight: '500vh', + }, + width: { + 0: 0, + 1: '.25rem', + 2: '.5rem', + 3: '.75rem', + 4: '1rem', + }, + }) + }) + + it('can extend values that are depended on lazily', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + backgroundColors: ({ theme }) => theme('colors'), + }, + }, + { + theme: { + extend: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColors: { + customBackground: '#bada55', + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + red: 'red', + green: 'green', + blue: 'blue', + customBackground: '#bada55', + }, + }) + }) + + it('shallow merges extensions when they are simple arrays', async () => { + let config = await resolver.resolve([ + { + theme: { + fonts: { + sans: ['system-ui', 'Helvetica Neue', 'sans-serif'], + serif: ['Constantia', 'Georgia', 'serif'], + mono: ['Menlo', 'Courier New', 'monospace'], + }, + }, + }, + { + theme: { + extend: { + fonts: { + sans: ['Comic Sans'], + serif: ['Papyrus', { fontFeatureSettings: '"cv11"' }], + mono: [['Lobster', 'Papyrus'], { fontFeatureSettings: '"cv11"' }], + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + fonts: { + sans: ['Comic Sans'], + serif: ['Papyrus', { fontFeatureSettings: '"cv11"' }], + mono: [['Lobster', 'Papyrus'], { fontFeatureSettings: '"cv11"' }], + }, + }) + }) + + it('deep merges extensions when they are arrays of objects', async () => { + let config = await resolver.resolve([ + { + theme: { + typography: { + ArrayArray: { + css: [{ a: { underline: 'none' } }], + }, + ObjectArray: { + css: [{ a: { underline: 'none' } }], + }, + ArrayObject: { + css: { a: { underline: 'none' } }, + }, + }, + }, + }, + { + theme: { + extend: { + typography: { + ArrayArray: { + css: [{ a: { backgroundColor: 'red' } }, { a: { color: 'green' } }], + }, + ObjectArray: { + css: { a: { backgroundColor: 'red' } }, + }, + ArrayObject: { + css: [{ a: { backgroundColor: 'red' } }, { a: { color: 'green' } }], + }, + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + typography: { + ArrayArray: { + css: [ + { a: { underline: 'none' } }, + { a: { backgroundColor: 'red' } }, + { a: { color: 'green' } }, + ], + }, + ObjectArray: { + css: [{ a: { underline: 'none' } }, { a: { backgroundColor: 'red' } }], + }, + ArrayObject: { + css: [ + { a: { underline: 'none' } }, + { a: { backgroundColor: 'red' } }, + { a: { color: 'green' } }, + ], + }, + }, + }) + }) + + it('uses a default value passed to the `theme` function when the key is missing', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + borderColor: ({ theme }) => ({ + default: theme('colors.gray', 'currentColor'), + ...theme('colors'), + }), + }, + }, + { + theme: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + borderColor: { + default: 'currentColor', + red: 'red', + green: 'green', + blue: 'blue', + }, + }) + }) + + it('lets the `theme` function resolve simple keypaths', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + }, + }, + { + theme: { + textColor: ({ theme }) => ({ + lime: 'lime', + ...theme('colors'), + }), + backgroundColor: ({ theme }) => ({ + orange: 'orange', + ...theme('textColor'), + }), + borderColor: ({ theme }) => theme('backgroundColor'), + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + textColor: { + lime: 'lime', + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColor: { + lime: 'lime', + orange: 'orange', + red: 'red', + green: 'green', + blue: 'blue', + }, + borderColor: { + lime: 'lime', + orange: 'orange', + red: 'red', + green: 'green', + blue: 'blue', + }, + }) + }) + + it('lets the `theme` function resolve nested keypaths', async () => { + let config = await resolver.resolve([ + { + theme: { + spacing: { + 0: '0', + }, + width: ({ theme }) => ({ + ...theme('spacing'), + '1/3': '33.33333%', + }), + }, + }, + { + theme: { + minWidth: ({ theme }) => ({ + '1/3': theme('width.1/3'), + }), + }, + }, + ]) + + expect(config.theme).toMatchObject({ + spacing: { + 0: '0', + }, + width: { + 0: '0', + '1/3': '33.33333%', + }, + minWidth: { + '1/3': '33.33333%', + }, + }) + }) + + test('theme values in the extend section are lazily evaluated', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + borderColor: ({ theme }) => ({ + default: theme('colors.yellow', 'currentColor'), + ...theme('colors'), + }), + }, + }, + { + theme: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + extend: { + colors: { + orange: 'orange', + }, + borderColor: ({ theme }) => ({ + foo: theme('colors.orange'), + bar: theme('colors.red'), + }), + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + orange: 'orange', + red: 'red', + green: 'green', + blue: 'blue', + }, + borderColor: { + default: 'currentColor', + foo: 'orange', + bar: 'red', + orange: 'orange', + red: 'red', + green: 'green', + blue: 'blue', + }, + }) + }) + + test('lazily evaluated values have access to the config utils', async () => { + let config = await resolver.resolve([ + { + theme: { + spacing: { + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + margin: ({ theme }) => ({ ...theme('spacing') }), + }, + }, + { + theme: { + inset: ({ theme }) => theme('margin'), + shift: ({ theme }) => ({ ...theme('spacing') }), + extend: { + nudge: ({ theme }) => ({ ...theme('spacing') }), + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + spacing: { + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + inset: { + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + margin: { + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + shift: { + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + nudge: { + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + }) + }) + + it('does not mutate the original theme', async () => { + const userConfig: UserConfig = { + theme: { + extend: { + colors: { + orange: 'orange', + }, + }, + }, + } + + resolver.resolve([ + { + theme: { + colors: { + cyan: 'cyan', + magenta: 'magenta', + yellow: 'yellow', + }, + }, + }, + userConfig, + ]) + + expect(userConfig).toEqual({ + theme: { + extend: { + colors: { + orange: 'orange', + }, + }, + }, + }) + }) + + it('allows plugins to extend user themes', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { + theme: { + width: { + sm: '1rem', + md: '2rem', + lg: '3rem', + }, + screens: { + mobile: '400px', + }, + }, + }, + { + theme: { + width: { + '1px': '1px', + }, + }, + plugins: [ + stub(1, { + theme: { + extend: { + width: { + '2px': '2px', + '3px': '3px', + }, + }, + }, + }), + ], + }, + ]) + + expect(config).toMatchObject({ + theme: { + width: { + '1px': '1px', + '2px': '2px', + '3px': '3px', + }, + screens: { + mobile: '400px', + }, + }, + plugins: stub.all, + }) + }) + + it('prefers user theme extensions over plugin theme extensions', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { + theme: { + width: { + sm: '1rem', + md: '2rem', + lg: '3rem', + }, + screens: { + mobile: '400px', + }, + }, + }, + { + theme: { + extend: { + width: { + xl: '6rem', + }, + }, + }, + plugins: [ + stub(1, { + theme: { + extend: { + width: { + xl: '4rem', + }, + }, + }, + }), + ], + }, + ]) + + expect(config).toMatchObject({ + theme: { + width: { + sm: '1rem', + md: '2rem', + lg: '3rem', + xl: '6rem', + }, + screens: { + mobile: '400px', + }, + }, + plugins: stub.all, + }) + }) + + it('uses values from later theme extensions', async () => { + let config = await resolver.resolve([ + { + theme: { + colors: { + grey: { + light: '#ccc', + dark: '#333', + }, + }, + }, + }, + { + theme: { + extend: { + colors: { + grey: { + darker: '#222', + }, + }, + }, + }, + }, + { + theme: { + extend: { + colors: { + grey: { + light: '#ddd', + darker: '#111', + }, + }, + }, + }, + }, + { + theme: { + extend: { + colors: { + grey: { + light: '#eee', + }, + }, + }, + }, + }, + ]) + + expect(config.theme).toMatchObject({ + colors: { + grey: { + light: '#eee', + dark: '#333', + darker: '#111', + }, + }, + }) + }) + + it('provides helpers to resolvable theme functions', async () => { + let config = await resolver.resolve([ + { + theme: { + screens: { + sm: '640px', + md: '768px', + }, + fontWeight: { + bold: 700, + }, + spacing: { + 0: '0px', + 1: '1px', + 2: '2px', + 3: '3px', + 4: '4px', + }, + }, + }, + { + theme: { + example: ({ theme, colors, breakpoints }) => ({ + weight: theme('fontWeight.bold'), + black: colors.black, + white: colors.white, + ...breakpoints(theme('screens')), + }), + }, + }, + ]) + + expect(config.theme).toMatchObject({ + example: { + weight: 700, + black: '#000', + white: '#fff', + 'screen-sm': '640px', + 'screen-md': '768px', + }, + }) + }) + + it('replaces theme keys with later configs', async () => { + let config = await resolver.resolve([ + { + theme: { + animation: { + some: 'some', + }, + }, + }, + { + theme: { + animation: { + none: 'none', + }, + }, + }, + ]) + + expect(config.theme).toEqual({ + animation: { + none: 'none', + }, + }) + }) + + it('merges extend into theme keys', async () => { + let config = await resolver.resolve([ + { + theme: { + extend: { + animation: { + none: 'none', + }, + }, + + animation: { + some: 'some', + }, + }, + }, + { + theme: { + extend: { + animation: { + every: 'every', + }, + }, + }, + }, + ]) + + expect(config.theme).toEqual({ + animation: { + none: 'none', + some: 'some', + every: 'every', + }, + }) + }) + + it('presets can add to theme and theme.extend', async () => { + let config = await resolver.resolve([ + { + presets: [ + { + theme: { + extend: { + animation: { + some: 'some', + }, + }, + + animation: { + none: 'none', + }, + }, + }, + ], + }, + ]) + + expect(config.theme).toEqual({ + animation: { + none: 'none', + some: 'some', + }, + }) + }) + + it('plugins can add to theme and theme.extend', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { + plugins: [ + stub(1, { + theme: { + extend: { + animation: { + some: 'some', + }, + }, + + animation: { + none: 'none', + }, + }, + }), + ], + }, + ]) + + expect(config).toMatchObject({ + plugins: stub.all, + }) + + expect(config.theme).toEqual({ + animation: { + none: 'none', + some: 'some', + }, + }) + }) + + it('calls top-level theme keys when given functions', async () => { + let config = await resolver.resolve([ + { + theme: { + extend: { + animation: () => ({ + some: 'some', + }), + }, + + animation: () => ({ + none: 'none', + }), + }, + }, + ]) + + expect(config.theme).toEqual({ + animation: { + some: 'some', + none: 'none', + }, + }) + }) +}) + +describe('plugins', () => { + it('lets plugins modify the config', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + // + { prefix: '' }, + { plugins: [stub(1, { prefix: 'tw-' })] }, + ]) + + expect(config).toMatchObject({ + prefix: 'tw-', + plugins: stub.all, + }) + }) + + it('prefers user configs over plugin configs', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { prefix: '' }, + { + prefix: 'user-', + plugins: [stub(1, { prefix: 'tw-' })], + }, + ]) + + expect(config).toMatchObject({ + prefix: 'user-', + plugins: stub.all, + }) + }) + + it('allows plugins to register plugins', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { + prefix: '', + important: false, + separator: ':', + }, + { + plugins: [ + stub(1, { + prefix: 'tw-', + plugins: [ + stub(2, { + important: true, + }), + stub(3, { + separator: '_', + }), + ], + }), + ], + }, + ]) + + expect(config).toMatchObject({ + prefix: 'tw-', + important: true, + separator: '_', + plugins: stub.all, + }) + }) + + it('prefers outer plugin configs instead of inner plugin configs (todo -- terrible name)', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { + prefix: '', + }, + { + plugins: [ + stub(1, { + prefix: 'outer-', + plugins: [ + stub(2, { + prefix: 'inner-', + }), + ], + }), + ], + }, + ]) + + expect(config).toMatchObject({ + prefix: 'outer-', + plugins: stub.all, + }) + }) + + it('merges plugins across configs', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + { plugins: [stub(1)] }, + { plugins: [stub(2)] }, + { plugins: [stub(3)] }, + ]) + + expect(config).toMatchObject({ + plugins: stub.all, + }) + }) + + it('merges plugins from presets (recursively)', async () => { + let stub = stubs() + + let config = await resolver.resolve([ + // Plugins from multiple configs + { plugins: [stub(1), stub(2)] }, + { plugins: [stub(3), stub(4)] }, + + { + presets: [ + // Plugins from presets + { plugins: [stub(11), stub(12)] }, + + // Plugins from presets (recursively) + { + presets: [{ plugins: [stub(21), stub(22)] }], + plugins: [stub(23)], + }, + + // Plugins from presets inside plugins + { + plugins: [ + stub(31, { + presets: [{ plugins: [stub(32), stub(33)] }], + }), + ], + }, + ], + }, + + { + plugins: [ + // Plugins from plugins + stub(41, { + plugins: [stub(42), stub(43)], + }), + + // Plugins from plugins (recursively) + stub(51, { + plugins: [ + stub(52, { + plugins: [stub(53)], + }), + ], + }), + ], + }, + ]) + + expect(config).toMatchObject({ + plugins: stub.all, + }) + }) +}) + +describe('core plugins', () => { + it('can disable all core plugins', async () => { + let config = await resolver.resolve([ + // + { corePlugins: false }, + ]) + + expect(config.corePlugins).toStrictEqual(corePluginsDisabled) + }) + + it('can enable only some core plugins', async () => { + let config = await resolver.resolve([ + // + { corePlugins: ['colors', 'display'] }, + ]) + + expect(config.corePlugins).toStrictEqual({ + ...corePluginsDisabled, + colors: true, + display: true, + }) + }) + + it('can disable select core plugins', async () => { + let config = await resolver.resolve([{ corePlugins: { colors: false, display: false } }]) + + expect(config.corePlugins).toStrictEqual({ + ...corePlugins, + colors: false, + display: false, + }) + }) + + it('starts with default list of core plugins', async () => { + let config = await resolver.resolve([{ corePlugins: {} }, { corePlugins: { display: false } }]) + + expect(config).toMatchObject({ + corePlugins: { + ...corePlugins, + display: false, + }, + }) + }) + + it('can re-enable disabled core plugins', async () => { + let config = await resolver.resolve([ + { corePlugins: { display: false } }, + { corePlugins: { display: true } }, + ]) + + expect(config).toMatchObject({ + corePlugins: { + display: true, + }, + }) + }) + + it('merges core plugins across configs', async () => { + let config = await resolver.resolve([ + { corePlugins: ['colors', 'display', 'height'] }, + { corePlugins: { truncate: true } }, + { corePlugins: { display: false } }, + ]) + + expect(config.corePlugins).toStrictEqual({ + ...corePluginsDisabled, + colors: true, + height: true, + truncate: true, + }) + }) +}) diff --git a/packages/tailwindcss/src/compat/config/types.ts b/packages/tailwindcss/src/compat/config/types.ts new file mode 100644 index 000000000..4d7376632 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/types.ts @@ -0,0 +1,78 @@ +import type { PluginAPI } from '../api' +import type { CorePluginName } from '../generated/core-utils' +import type { PluginUtils } from './utils' + +export type ImportableFile = string | [path: string, options: Record] + +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 + corePlugins?: Partial> | CorePluginName[] | boolean +} + +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> + corePlugins: Record +} diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 3c8b68aa6..e9d4a0f38 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -135,9 +135,11 @@ describe('theme', async () => { { theme: { extend: { - animationDuration: ({ theme }: { theme: (path: string) => any }) => ({ - ...theme('transitionDuration'), - }), + animationDuration: ({ theme }: { theme: (path: string) => any }) => { + return { + ...theme('transitionDuration'), + } + }, }, }, }, @@ -152,4 +154,105 @@ describe('theme', async () => { " `) }) + + 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; + } + " + `) + }) }) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 2890329b0..2021d3e6f 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,14 +1,14 @@ import { substituteAtApply } from './apply' import { objectToAst, rule, type AstNode, type CssInJs } from './ast' +import { resolveConfig } from './compat/config/resolve-config' +import type { UserConfig } from './compat/config/types' import type { DesignSystem } from './design-system' import { withAlpha, withNegative } from './utilities' import { inferDataType } from './utils/infer-data-type' import { segment } from './utils/segment' -export type Config = Record - 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 @@ -243,35 +243,15 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a pluginObjects.push(plugin) } else { // Just a plain function without using the plugin(…) API - pluginObjects.push({ handler: plugin, config: {} as Config }) + pluginObjects.push({ handler: plugin, config: {} as UserConfig }) } } // Now merge all the configs and make all that crap work - let resolvedConfig: Config = { theme: {} } - - for (let { config } of pluginObjects) { - for (let key in config?.theme?.extend) { - resolvedConfig.theme[key] ??= {} - let value = config.theme.extend[key] - if (value instanceof Function) { - value = value({ - theme(path: string) { - if (path === 'transitionDuration') { - return { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}ms` - } - }, - } - } - }, - }) - } - Object.assign(resolvedConfig.theme[key], value) - } - } + let resolvedConfig = resolveConfig([ + compatabilityConfig, + ...pluginObjects.map(({ config }) => config ?? {}), + ]) let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig) @@ -280,3 +260,15 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a handler(pluginApi) } } + +export const compatabilityConfig = { + theme: { + transitionDuration: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}ms` + } + }, + }, + }, +} From d8cef56dbd0fba37310aa63db8dc6a18381f6457 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 12:45:10 -0400 Subject: [PATCH 05/20] wip --- packages/tailwindcss/src/ast.ts | 6 +-- .../tailwindcss/src/compat/config/types.ts | 8 ++-- packages/tailwindcss/src/plugin-api.test.ts | 40 ++++++++++++++++ packages/tailwindcss/src/plugin-api.ts | 48 ++++++++++++------- 4 files changed, 76 insertions(+), 26 deletions(-) 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/types.ts b/packages/tailwindcss/src/compat/config/types.ts index 4d7376632..54146309a 100644 --- a/packages/tailwindcss/src/compat/config/types.ts +++ b/packages/tailwindcss/src/compat/config/types.ts @@ -1,9 +1,9 @@ -import type { PluginAPI } from '../api' -import type { CorePluginName } from '../generated/core-utils' -import type { PluginUtils } from './utils' +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 @@ -55,7 +55,6 @@ export interface UserConfig { presets?: Preset[] plugins?: Plugin[] theme?: ThemeConfig - corePlugins?: Partial> | CorePluginName[] | boolean } export type ThemeValue = ResolvableTo> | null | undefined @@ -74,5 +73,4 @@ export interface ResolvedConfig { darkMode: DarkModeConfig plugins: RawPlugin[] theme: Record> - corePlugins: Record } diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index e9d4a0f38..fe4ea3b5f 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -255,4 +255,44 @@ describe('theme', async () => { " `) }) + + test('wip', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + duration: (value) => ({ 'animation-duration': value }), + }, + { + values: theme('animationDuration'), + }, + ) + }, + { + theme: { + extend: { + animationDuration: ({ theme }: { theme: (path: string) => any }) => ({ + ...theme('transitionDuration'), + }), + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['duration-316'])).toMatchInlineSnapshot(` + ".duration-316 { + animation-duration: 316ms; + } + " + `) + }) }) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 2021d3e6f..b77b875c0 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -3,10 +3,12 @@ import { objectToAst, rule, type AstNode, type CssInJs } from './ast' 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 { inferDataType } from './utils/infer-data-type' import { segment } from './utils/segment' +export type Config = UserConfig export type PluginFn = (api: PluginAPI) => void export type PluginWithConfig = { handler: PluginFn; config?: UserConfig } export type PluginWithOptions = { @@ -25,9 +27,7 @@ export type PluginAPI = { options?: Partial<{ type: string | string[] supportsNegativeValues: boolean - values: Record & { - [BARE_VALUE]?: (value: string) => string | null | undefined - } + values: Record modifiers: 'any' | Record }>, ): void @@ -136,7 +136,7 @@ export function buildPluginApi( let value: string | null = null { - let values = options?.values ?? {} + let values: Record = options?.values ?? {} if (isColor) { // Color utilities implicitly support `inherit`, `transparent`, and `currentColor` @@ -156,11 +156,17 @@ export function buildPluginApi( } else if (values[candidate.value.value]) { value = values[candidate.value.value] } else if (values[BARE_VALUE]) { - value = values[BARE_VALUE](candidate.value.value) ?? null + // We've snuk the bare value in here as a function even though values are + // typically only ever strings. This is a backwards compatibility hack. + let handleBareValue = values[BARE_VALUE] as unknown as ( + value: string, + ) => string | null + + value = handleBareValue(candidate.value.value) ?? null } } - if (!value) return + if (value === null) return // Resolve the modifier value let modifier: string | null @@ -182,12 +188,12 @@ export function buildPluginApi( } // A modifier was provided but is invalid - if (candidate.modifier && !modifier) { + if (candidate.modifier && modifier !== null) { // For arbitrary values, return `null` to avoid falling through to the next utility return candidate.value?.kind === 'arbitrary' ? null : undefined } - if (isColor && modifier) { + if (isColor && modifier !== null) { value = withAlpha(value, modifier) } @@ -224,7 +230,10 @@ export function buildPluginApi( } } -const BARE_VALUE = Symbol('BARE_VALUE') +// We can't use a symbol here because a lot of code in the ecosystem assumes an object with string / number keys +// for example using Object.entries() + Object.fromEntries() to modify an object which removes symbol keys +// We use `Math.random()` to ensure that users are unable to rely on the name of thiskey +const BARE_VALUE = `__BARE_VALUE__${Math.random().toString(36).slice(2)}` export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) { let pluginObjects = [] @@ -249,7 +258,7 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a // Now merge all the configs and make all that crap work let resolvedConfig = resolveConfig([ - compatabilityConfig, + createCompatabilityConfig(designSystem.theme), ...pluginObjects.map(({ config }) => config ?? {}), ]) @@ -261,14 +270,17 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a } } -export const compatabilityConfig = { - theme: { - transitionDuration: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}ms` - } +export function createCompatabilityConfig(theme: Theme) { + return { + theme: { + transitionDuration: { + DEFAULT: theme.get(['--default-transition-duration']) ?? null, + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}ms` + } + }, }, }, - }, + } } From bac43889f8d3a1d3ebe6b675802575c2def7a884 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 14:13:26 -0400 Subject: [PATCH 06/20] Cleanup types --- .../tailwindcss/src/compat/config/deep-merge.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/tailwindcss/src/compat/config/deep-merge.ts b/packages/tailwindcss/src/compat/config/deep-merge.ts index 3a32f19dc..49ac100b5 100644 --- a/packages/tailwindcss/src/compat/config/deep-merge.ts +++ b/packages/tailwindcss/src/compat/config/deep-merge.ts @@ -1,5 +1,3 @@ -type Customizer = (a: any, b: any) => any - export function isPlainObject(value: T): value is T & Record { if (Object.prototype.toString.call(value) !== '[object Object]') { return false @@ -12,32 +10,25 @@ export function isPlainObject(value: T): value is T & Record( target: T, sources: (Partial | null | undefined)[], - customizer: Customizer, + customizer: (a: any, b: any) => any, ) { type Key = keyof T type Value = T[Key] - type KnownSource = Record for (let source of sources) { if (source === null || source === undefined) { continue } - let keys = [ - // - ...Object.getOwnPropertyNames(source), - ...Object.getOwnPropertySymbols(source), - ] - - for (let k of keys as Key[]) { + 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 any + target[k] = source[k] as Value } else { - target[k] = deepMerge({}, [target[k], source[k]] as any, customizer) as any + target[k] = deepMerge({}, [target[k], source[k]], customizer) as Value } } } From 99bce20751a3f231fdff7179b9b8d8a88f5fdc97 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 14:13:32 -0400 Subject: [PATCH 07/20] Remove test --- .../src/compat/config/resolve.test.ts | 1520 ----------------- 1 file changed, 1520 deletions(-) delete mode 100644 packages/tailwindcss/src/compat/config/resolve.test.ts diff --git a/packages/tailwindcss/src/compat/config/resolve.test.ts b/packages/tailwindcss/src/compat/config/resolve.test.ts deleted file mode 100644 index 45fa67c05..000000000 --- a/packages/tailwindcss/src/compat/config/resolve.test.ts +++ /dev/null @@ -1,1520 +0,0 @@ -import { describe, expect, it, test } from 'vitest' -import { corePlugins } from '../generated/core-utils' -import { Resolver } from './resolve' -import type { ConfigurablePlugin, RawPlugin, UserConfig } from './types' - -let corePluginsDisabled = Object.fromEntries( - Object.entries(corePlugins).map(([name]) => [name, false]), -) - -let resolver = new Resolver() - -function stubs() { - let list: Record = {} - - function stub(id: number, config: UserConfig = {}): ConfigurablePlugin { - return (list[id] ??= { - config: () => config, - handler: Object.assign(() => {}, { - get [Symbol.toStringTag]() { - // The ID is for debugging - return `stub ${id}` - }, - }), - }) - } - - Object.defineProperty(stub, 'all', { - get() { - return Object.values(list).map((list) => list.handler) - }, - }) - - return stub as typeof stub & { - readonly all: RawPlugin[] - } -} - -describe('general', () => { - it('resolves to a minimal config when passed nothing', async () => { - let config = await resolver.resolve([]) - - expect(config).toEqual({ - blocklist: [], - content: { - files: [], - transform: {}, - }, - prefix: '', - separator: ':', - important: false, - darkMode: 'media', - plugins: [], - theme: {}, - corePlugins, - }) - }) - - it('replaces top level properties', async () => { - let config = await resolver.resolve([ - { prefix: '', important: false, separator: ':' }, - { prefix: 'tw-', important: true, separator: '_' }, - ]) - - expect(config).toMatchObject({ - prefix: 'tw-', - important: true, - separator: '_', - }) - }) - - it('can resolve 3+ configs into one', async () => { - let config = await resolver.resolve([ - { - theme: { - fontFamily: { - body: ['Arial', 'sans-serif'], - display: ['Georgia', 'serif'], - }, - colors: { - blue: 'blue', - }, - backgroundColor: ({ theme }) => theme('colors'), - }, - }, - { - theme: { - extend: { - fontFamily: { - hero: ['Futura', 'sans-serif'], - }, - colors: { - pink: 'pink', - }, - backgroundColor: () => ({ - customBackgroundThree: '#c0ffee', - }), - textDecorationColor: { - lime: 'lime', - }, - }, - }, - }, - { - theme: { - extend: { - fontFamily: { - quote: ['Helvetica', 'serif'], - }, - colors: { - green: 'green', - }, - backgroundColor: { - customBackgroundTwo: '#facade', - }, - textDecorationColor: ({ theme }) => theme('colors'), - }, - }, - }, - { - theme: { - extend: { - fontFamily: () => ({ - code: ['Menlo', 'monospace'], - }), - colors: { - red: 'red', - }, - backgroundColor: { - customBackgroundOne: '#bada55', - }, - textDecorationColor: { - orange: 'orange', - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - fontFamily: { - body: ['Arial', 'sans-serif'], - display: ['Georgia', 'serif'], - code: ['Menlo', 'monospace'], - quote: ['Helvetica', 'serif'], - hero: ['Futura', 'sans-serif'], - }, - colors: { - red: 'red', - green: 'green', - blue: 'blue', - pink: 'pink', - }, - backgroundColor: { - red: 'red', - green: 'green', - blue: 'blue', - pink: 'pink', - customBackgroundOne: '#bada55', - customBackgroundTwo: '#facade', - customBackgroundThree: '#c0ffee', - }, - textDecorationColor: { - red: 'red', - green: 'green', - blue: 'blue', - pink: 'pink', - orange: 'orange', - lime: 'lime', - }, - }) - }) - - it('does not duplicate extended configs every time resolve is called', async () => { - let shared = { - foo: { bar: { baz: [{ color: 'red' }] } }, - } - - const createConfig = (color: string) => - resolver.resolve([ - { - theme: { - foo: shared.foo, - extend: { - foo: { bar: { baz: { color } } }, - }, - }, - }, - ]) - - await createConfig('orange') - await createConfig('yellow') - await createConfig('green') - - let config = await createConfig('blue') - - expect(shared.foo.bar.baz).toMatchObject([{ color: 'red' }]) - - // @ts-ignore - expect(config.theme.foo.bar.baz).toMatchObject([{ color: 'red' }, { color: 'blue' }]) - }) -}) - -describe('theme', () => { - it('shallow merges themes across configs', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - 'grey-darker': '#606f7b', - 'grey-dark': '#8795a1', - grey: '#b8c2cc', - 'grey-light': '#dae1e7', - 'grey-lighter': '#f1f5f8', - }, - fonts: { - sans: ['system-ui', 'Roboto', 'sans-serif'], - serif: ['Constantia', 'Lucida Bright', 'Georgia', 'serif'], - }, - screens: { - sm: '500px', - md: '750px', - lg: '1000px', - }, - }, - }, - { - theme: { - screens: { - mobile: '400px', - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - 'grey-darker': '#606f7b', - 'grey-dark': '#8795a1', - grey: '#b8c2cc', - 'grey-light': '#dae1e7', - 'grey-lighter': '#f1f5f8', - }, - fonts: { - sans: ['system-ui', 'Roboto', 'sans-serif'], - serif: ['Constantia', 'Lucida Bright', 'Georgia', 'serif'], - }, - screens: { - mobile: '400px', - }, - }) - }) - - it('deeply merges theme extensions across configs', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - grey: { - light: '#dae1e7', - }, - }, - }, - }, - { - theme: { - extend: { - colors: { - grey: { - dark: '#8795a1', - }, - }, - }, - }, - }, - ]) - - expect(config.theme).toEqual({ - colors: { - grey: { - dark: '#8795a1', - light: '#dae1e7', - }, - }, - }) - }) - - it('lazily evaluates functions in themes (1)', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - backgroundColors: ({ theme }) => theme('colors'), - textColors: ({ theme }) => theme('colors'), - }, - }, - { - theme: { - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - backgroundColors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - textColors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - }) - }) - - it('lazily evaluates functions in themes (2)', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - backgroundColors: ({ colors }) => colors, - textColors: ({ colors }) => colors, - }, - }, - { - theme: { - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - backgroundColors: ({ theme }) => ({ - ...theme('colors'), - customBackground: '#bada55', - }), - textColors: ({ theme }) => ({ - ...theme('colors'), - customText: '#facade', - }), - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - backgroundColors: { - red: 'red', - green: 'green', - blue: 'blue', - customBackground: '#bada55', - }, - textColors: { - red: 'red', - green: 'green', - blue: 'blue', - customText: '#facade', - }, - }) - }) - - it('theme values in the extend section extend the existing theme', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - opacity: { - 0: '0', - 50: '.5', - 100: '1', - }, - backgroundColors: ({ theme }) => theme('colors'), - }, - }, - { - theme: { - extend: { - opacity: { - 25: '25', - 75: '.75', - }, - backgroundColors: { - customBackground: '#bada55', - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - opacity: { - 0: '0', - 50: '.5', - 100: '1', - 25: '25', - 75: '.75', - }, - backgroundColors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - customBackground: '#bada55', - }, - }) - }) - - test('theme values in the extend section extend the user theme', async () => { - let config = await resolver.resolve([ - { - theme: { - opacity: { - 0: '0', - 50: '.5', - 100: '1', - }, - height: { - 0: 0, - full: '100%', - }, - width: { - 0: 0, - 1: '.25rem', - 2: '.5rem', - 3: '.75rem', - 4: '1rem', - }, - }, - }, - { - theme: { - opacity: { - 0: '0', - 20: '.2', - 40: '.4', - }, - height: ({ theme }) => theme('width'), - extend: { - opacity: { - 60: '.6', - 80: '.8', - 100: '1', - }, - height: { - customHeight: '500vh', - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - opacity: { - 0: '0', - 20: '.2', - 40: '.4', - 60: '.6', - 80: '.8', - 100: '1', - }, - height: { - 0: 0, - 1: '.25rem', - 2: '.5rem', - 3: '.75rem', - 4: '1rem', - customHeight: '500vh', - }, - width: { - 0: 0, - 1: '.25rem', - 2: '.5rem', - 3: '.75rem', - 4: '1rem', - }, - }) - }) - - it('can extend values that are depended on lazily', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - backgroundColors: ({ theme }) => theme('colors'), - }, - }, - { - theme: { - extend: { - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - backgroundColors: { - customBackground: '#bada55', - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - red: 'red', - green: 'green', - blue: 'blue', - }, - backgroundColors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - red: 'red', - green: 'green', - blue: 'blue', - customBackground: '#bada55', - }, - }) - }) - - it('shallow merges extensions when they are simple arrays', async () => { - let config = await resolver.resolve([ - { - theme: { - fonts: { - sans: ['system-ui', 'Helvetica Neue', 'sans-serif'], - serif: ['Constantia', 'Georgia', 'serif'], - mono: ['Menlo', 'Courier New', 'monospace'], - }, - }, - }, - { - theme: { - extend: { - fonts: { - sans: ['Comic Sans'], - serif: ['Papyrus', { fontFeatureSettings: '"cv11"' }], - mono: [['Lobster', 'Papyrus'], { fontFeatureSettings: '"cv11"' }], - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - fonts: { - sans: ['Comic Sans'], - serif: ['Papyrus', { fontFeatureSettings: '"cv11"' }], - mono: [['Lobster', 'Papyrus'], { fontFeatureSettings: '"cv11"' }], - }, - }) - }) - - it('deep merges extensions when they are arrays of objects', async () => { - let config = await resolver.resolve([ - { - theme: { - typography: { - ArrayArray: { - css: [{ a: { underline: 'none' } }], - }, - ObjectArray: { - css: [{ a: { underline: 'none' } }], - }, - ArrayObject: { - css: { a: { underline: 'none' } }, - }, - }, - }, - }, - { - theme: { - extend: { - typography: { - ArrayArray: { - css: [{ a: { backgroundColor: 'red' } }, { a: { color: 'green' } }], - }, - ObjectArray: { - css: { a: { backgroundColor: 'red' } }, - }, - ArrayObject: { - css: [{ a: { backgroundColor: 'red' } }, { a: { color: 'green' } }], - }, - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - typography: { - ArrayArray: { - css: [ - { a: { underline: 'none' } }, - { a: { backgroundColor: 'red' } }, - { a: { color: 'green' } }, - ], - }, - ObjectArray: { - css: [{ a: { underline: 'none' } }, { a: { backgroundColor: 'red' } }], - }, - ArrayObject: { - css: [ - { a: { underline: 'none' } }, - { a: { backgroundColor: 'red' } }, - { a: { color: 'green' } }, - ], - }, - }, - }) - }) - - it('uses a default value passed to the `theme` function when the key is missing', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - borderColor: ({ theme }) => ({ - default: theme('colors.gray', 'currentColor'), - ...theme('colors'), - }), - }, - }, - { - theme: { - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - borderColor: { - default: 'currentColor', - red: 'red', - green: 'green', - blue: 'blue', - }, - }) - }) - - it('lets the `theme` function resolve simple keypaths', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - }, - }, - { - theme: { - textColor: ({ theme }) => ({ - lime: 'lime', - ...theme('colors'), - }), - backgroundColor: ({ theme }) => ({ - orange: 'orange', - ...theme('textColor'), - }), - borderColor: ({ theme }) => theme('backgroundColor'), - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - textColor: { - lime: 'lime', - red: 'red', - green: 'green', - blue: 'blue', - }, - backgroundColor: { - lime: 'lime', - orange: 'orange', - red: 'red', - green: 'green', - blue: 'blue', - }, - borderColor: { - lime: 'lime', - orange: 'orange', - red: 'red', - green: 'green', - blue: 'blue', - }, - }) - }) - - it('lets the `theme` function resolve nested keypaths', async () => { - let config = await resolver.resolve([ - { - theme: { - spacing: { - 0: '0', - }, - width: ({ theme }) => ({ - ...theme('spacing'), - '1/3': '33.33333%', - }), - }, - }, - { - theme: { - minWidth: ({ theme }) => ({ - '1/3': theme('width.1/3'), - }), - }, - }, - ]) - - expect(config.theme).toMatchObject({ - spacing: { - 0: '0', - }, - width: { - 0: '0', - '1/3': '33.33333%', - }, - minWidth: { - '1/3': '33.33333%', - }, - }) - }) - - test('theme values in the extend section are lazily evaluated', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - borderColor: ({ theme }) => ({ - default: theme('colors.yellow', 'currentColor'), - ...theme('colors'), - }), - }, - }, - { - theme: { - colors: { - red: 'red', - green: 'green', - blue: 'blue', - }, - extend: { - colors: { - orange: 'orange', - }, - borderColor: ({ theme }) => ({ - foo: theme('colors.orange'), - bar: theme('colors.red'), - }), - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - orange: 'orange', - red: 'red', - green: 'green', - blue: 'blue', - }, - borderColor: { - default: 'currentColor', - foo: 'orange', - bar: 'red', - orange: 'orange', - red: 'red', - green: 'green', - blue: 'blue', - }, - }) - }) - - test('lazily evaluated values have access to the config utils', async () => { - let config = await resolver.resolve([ - { - theme: { - spacing: { - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - margin: ({ theme }) => ({ ...theme('spacing') }), - }, - }, - { - theme: { - inset: ({ theme }) => theme('margin'), - shift: ({ theme }) => ({ ...theme('spacing') }), - extend: { - nudge: ({ theme }) => ({ ...theme('spacing') }), - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - spacing: { - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - inset: { - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - margin: { - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - shift: { - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - nudge: { - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - }) - }) - - it('does not mutate the original theme', async () => { - const userConfig: UserConfig = { - theme: { - extend: { - colors: { - orange: 'orange', - }, - }, - }, - } - - resolver.resolve([ - { - theme: { - colors: { - cyan: 'cyan', - magenta: 'magenta', - yellow: 'yellow', - }, - }, - }, - userConfig, - ]) - - expect(userConfig).toEqual({ - theme: { - extend: { - colors: { - orange: 'orange', - }, - }, - }, - }) - }) - - it('allows plugins to extend user themes', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { - theme: { - width: { - sm: '1rem', - md: '2rem', - lg: '3rem', - }, - screens: { - mobile: '400px', - }, - }, - }, - { - theme: { - width: { - '1px': '1px', - }, - }, - plugins: [ - stub(1, { - theme: { - extend: { - width: { - '2px': '2px', - '3px': '3px', - }, - }, - }, - }), - ], - }, - ]) - - expect(config).toMatchObject({ - theme: { - width: { - '1px': '1px', - '2px': '2px', - '3px': '3px', - }, - screens: { - mobile: '400px', - }, - }, - plugins: stub.all, - }) - }) - - it('prefers user theme extensions over plugin theme extensions', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { - theme: { - width: { - sm: '1rem', - md: '2rem', - lg: '3rem', - }, - screens: { - mobile: '400px', - }, - }, - }, - { - theme: { - extend: { - width: { - xl: '6rem', - }, - }, - }, - plugins: [ - stub(1, { - theme: { - extend: { - width: { - xl: '4rem', - }, - }, - }, - }), - ], - }, - ]) - - expect(config).toMatchObject({ - theme: { - width: { - sm: '1rem', - md: '2rem', - lg: '3rem', - xl: '6rem', - }, - screens: { - mobile: '400px', - }, - }, - plugins: stub.all, - }) - }) - - it('uses values from later theme extensions', async () => { - let config = await resolver.resolve([ - { - theme: { - colors: { - grey: { - light: '#ccc', - dark: '#333', - }, - }, - }, - }, - { - theme: { - extend: { - colors: { - grey: { - darker: '#222', - }, - }, - }, - }, - }, - { - theme: { - extend: { - colors: { - grey: { - light: '#ddd', - darker: '#111', - }, - }, - }, - }, - }, - { - theme: { - extend: { - colors: { - grey: { - light: '#eee', - }, - }, - }, - }, - }, - ]) - - expect(config.theme).toMatchObject({ - colors: { - grey: { - light: '#eee', - dark: '#333', - darker: '#111', - }, - }, - }) - }) - - it('provides helpers to resolvable theme functions', async () => { - let config = await resolver.resolve([ - { - theme: { - screens: { - sm: '640px', - md: '768px', - }, - fontWeight: { - bold: 700, - }, - spacing: { - 0: '0px', - 1: '1px', - 2: '2px', - 3: '3px', - 4: '4px', - }, - }, - }, - { - theme: { - example: ({ theme, colors, breakpoints }) => ({ - weight: theme('fontWeight.bold'), - black: colors.black, - white: colors.white, - ...breakpoints(theme('screens')), - }), - }, - }, - ]) - - expect(config.theme).toMatchObject({ - example: { - weight: 700, - black: '#000', - white: '#fff', - 'screen-sm': '640px', - 'screen-md': '768px', - }, - }) - }) - - it('replaces theme keys with later configs', async () => { - let config = await resolver.resolve([ - { - theme: { - animation: { - some: 'some', - }, - }, - }, - { - theme: { - animation: { - none: 'none', - }, - }, - }, - ]) - - expect(config.theme).toEqual({ - animation: { - none: 'none', - }, - }) - }) - - it('merges extend into theme keys', async () => { - let config = await resolver.resolve([ - { - theme: { - extend: { - animation: { - none: 'none', - }, - }, - - animation: { - some: 'some', - }, - }, - }, - { - theme: { - extend: { - animation: { - every: 'every', - }, - }, - }, - }, - ]) - - expect(config.theme).toEqual({ - animation: { - none: 'none', - some: 'some', - every: 'every', - }, - }) - }) - - it('presets can add to theme and theme.extend', async () => { - let config = await resolver.resolve([ - { - presets: [ - { - theme: { - extend: { - animation: { - some: 'some', - }, - }, - - animation: { - none: 'none', - }, - }, - }, - ], - }, - ]) - - expect(config.theme).toEqual({ - animation: { - none: 'none', - some: 'some', - }, - }) - }) - - it('plugins can add to theme and theme.extend', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { - plugins: [ - stub(1, { - theme: { - extend: { - animation: { - some: 'some', - }, - }, - - animation: { - none: 'none', - }, - }, - }), - ], - }, - ]) - - expect(config).toMatchObject({ - plugins: stub.all, - }) - - expect(config.theme).toEqual({ - animation: { - none: 'none', - some: 'some', - }, - }) - }) - - it('calls top-level theme keys when given functions', async () => { - let config = await resolver.resolve([ - { - theme: { - extend: { - animation: () => ({ - some: 'some', - }), - }, - - animation: () => ({ - none: 'none', - }), - }, - }, - ]) - - expect(config.theme).toEqual({ - animation: { - some: 'some', - none: 'none', - }, - }) - }) -}) - -describe('plugins', () => { - it('lets plugins modify the config', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - // - { prefix: '' }, - { plugins: [stub(1, { prefix: 'tw-' })] }, - ]) - - expect(config).toMatchObject({ - prefix: 'tw-', - plugins: stub.all, - }) - }) - - it('prefers user configs over plugin configs', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { prefix: '' }, - { - prefix: 'user-', - plugins: [stub(1, { prefix: 'tw-' })], - }, - ]) - - expect(config).toMatchObject({ - prefix: 'user-', - plugins: stub.all, - }) - }) - - it('allows plugins to register plugins', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { - prefix: '', - important: false, - separator: ':', - }, - { - plugins: [ - stub(1, { - prefix: 'tw-', - plugins: [ - stub(2, { - important: true, - }), - stub(3, { - separator: '_', - }), - ], - }), - ], - }, - ]) - - expect(config).toMatchObject({ - prefix: 'tw-', - important: true, - separator: '_', - plugins: stub.all, - }) - }) - - it('prefers outer plugin configs instead of inner plugin configs (todo -- terrible name)', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { - prefix: '', - }, - { - plugins: [ - stub(1, { - prefix: 'outer-', - plugins: [ - stub(2, { - prefix: 'inner-', - }), - ], - }), - ], - }, - ]) - - expect(config).toMatchObject({ - prefix: 'outer-', - plugins: stub.all, - }) - }) - - it('merges plugins across configs', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - { plugins: [stub(1)] }, - { plugins: [stub(2)] }, - { plugins: [stub(3)] }, - ]) - - expect(config).toMatchObject({ - plugins: stub.all, - }) - }) - - it('merges plugins from presets (recursively)', async () => { - let stub = stubs() - - let config = await resolver.resolve([ - // Plugins from multiple configs - { plugins: [stub(1), stub(2)] }, - { plugins: [stub(3), stub(4)] }, - - { - presets: [ - // Plugins from presets - { plugins: [stub(11), stub(12)] }, - - // Plugins from presets (recursively) - { - presets: [{ plugins: [stub(21), stub(22)] }], - plugins: [stub(23)], - }, - - // Plugins from presets inside plugins - { - plugins: [ - stub(31, { - presets: [{ plugins: [stub(32), stub(33)] }], - }), - ], - }, - ], - }, - - { - plugins: [ - // Plugins from plugins - stub(41, { - plugins: [stub(42), stub(43)], - }), - - // Plugins from plugins (recursively) - stub(51, { - plugins: [ - stub(52, { - plugins: [stub(53)], - }), - ], - }), - ], - }, - ]) - - expect(config).toMatchObject({ - plugins: stub.all, - }) - }) -}) - -describe('core plugins', () => { - it('can disable all core plugins', async () => { - let config = await resolver.resolve([ - // - { corePlugins: false }, - ]) - - expect(config.corePlugins).toStrictEqual(corePluginsDisabled) - }) - - it('can enable only some core plugins', async () => { - let config = await resolver.resolve([ - // - { corePlugins: ['colors', 'display'] }, - ]) - - expect(config.corePlugins).toStrictEqual({ - ...corePluginsDisabled, - colors: true, - display: true, - }) - }) - - it('can disable select core plugins', async () => { - let config = await resolver.resolve([{ corePlugins: { colors: false, display: false } }]) - - expect(config.corePlugins).toStrictEqual({ - ...corePlugins, - colors: false, - display: false, - }) - }) - - it('starts with default list of core plugins', async () => { - let config = await resolver.resolve([{ corePlugins: {} }, { corePlugins: { display: false } }]) - - expect(config).toMatchObject({ - corePlugins: { - ...corePlugins, - display: false, - }, - }) - }) - - it('can re-enable disabled core plugins', async () => { - let config = await resolver.resolve([ - { corePlugins: { display: false } }, - { corePlugins: { display: true } }, - ]) - - expect(config).toMatchObject({ - corePlugins: { - display: true, - }, - }) - }) - - it('merges core plugins across configs', async () => { - let config = await resolver.resolve([ - { corePlugins: ['colors', 'display', 'height'] }, - { corePlugins: { truncate: true } }, - { corePlugins: { display: false } }, - ]) - - expect(config.corePlugins).toStrictEqual({ - ...corePluginsDisabled, - colors: true, - height: true, - truncate: true, - }) - }) -}) From 835c0518ffe2dcf6b261026ce514f4933ae64e7d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 15:31:21 -0400 Subject: [PATCH 08/20] wip --- packages/tailwindcss/src/plugin-api.test.ts | 64 ++++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index fe4ea3b5f..5128eed29 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -256,7 +256,7 @@ describe('theme', async () => { `) }) - test('wip', async ({ expect }) => { + test('wip about the DEFAULT key', async ({ expect }) => { let input = css` @tailwind utilities; @plugin "my-plugin"; @@ -268,19 +268,19 @@ describe('theme', async () => { function ({ matchUtilities, theme }) { matchUtilities( { - duration: (value) => ({ 'animation-duration': value }), + 'animate-duration': (value) => ({ 'animation-delay': value }), }, { - values: theme('animationDuration'), + values: theme('transitionDuration'), }, ) }, { theme: { extend: { - animationDuration: ({ theme }: { theme: (path: string) => any }) => ({ - ...theme('transitionDuration'), - }), + transitionDuration: { + DEFAULT: '1500ms', + }, }, }, }, @@ -288,11 +288,57 @@ describe('theme', async () => { }, }) - expect(compiler.build(['duration-316'])).toMatchInlineSnapshot(` - ".duration-316 { - animation-duration: 316ms; + expect(compiler.build(['animate-duration'])).toMatchInlineSnapshot(` + ".animate-delay { + animation-delay: 1500ms; } " `) }) + + // 1. Access "new" theme keys from CSS using the old theme key notation + // e.g. read `--animation-*` using theme('animation') + // e.g. read `--animation-foo` using theme('animation.foo') + + 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(`""`) + }) }) From d6ccd800174227b5ec8cddca12c0529f49891e65 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 15:31:55 -0400 Subject: [PATCH 09/20] wip --- packages/tailwindcss/src/plugin-api.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index b77b875c0..7c96a937e 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -230,10 +230,10 @@ export function buildPluginApi( } } -// We can't use a symbol here because a lot of code in the ecosystem assumes an object with string / number keys -// for example using Object.entries() + Object.fromEntries() to modify an object which removes symbol keys -// We use `Math.random()` to ensure that users are unable to rely on the name of thiskey -const BARE_VALUE = `__BARE_VALUE__${Math.random().toString(36).slice(2)}` +// 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 +const BARE_VALUE = `__BARE_VALUE__` export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) { let pluginObjects = [] @@ -281,6 +281,10 @@ export function createCompatabilityConfig(theme: Theme) { } }, }, + + transitionTimingFunction: { + DEFAULT: theme.get(['--default-transition-timing-function']) ?? null, + }, }, } } From 1d2f9ba687826971c8dc43574b5c7f90e6e07127 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 16:39:38 -0400 Subject: [PATCH 10/20] wip --- packages/tailwindcss/src/plugin-api.test.ts | 159 ++++++++++++++++++-- packages/tailwindcss/src/plugin-api.ts | 143 +++++++++++++++++- 2 files changed, 292 insertions(+), 10 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 5128eed29..695f95e7e 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -1,4 +1,4 @@ -import { describe, test } from 'vitest' +import { describe, test, vi } from 'vitest' import { compile } from '.' import plugin from './plugin' @@ -289,17 +289,13 @@ describe('theme', async () => { }) expect(compiler.build(['animate-duration'])).toMatchInlineSnapshot(` - ".animate-delay { + ".animate-duration { animation-delay: 1500ms; } " `) }) - // 1. Access "new" theme keys from CSS using the old theme key notation - // e.g. read `--animation-*` using theme('animation') - // e.g. read `--animation-foo` using theme('animation.foo') - test('plugins can read CSS theme keys using the old theme key notation', async ({ expect }) => { let input = css` @tailwind utilities; @@ -337,8 +333,153 @@ describe('theme', async () => { }, }) - expect( - compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist']), - ).toMatchInlineSnapshot(`""`) + expect(compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist'])) + .toMatchInlineSnapshot(` + ".animation { + --animation: pulse 1s linear infinite; + } + .animation-spin { + --animation: spin 1s linear infinite; + } + .animation2 { + --animation: pulse 1s linear infinite; + } + .animation2-twist { + --animation: 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: pulse 1s linear infinite; + } + .animation-bounce { + --animation: bounce 1s linear infinite; + } + .animation-spin { + --animation: 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: 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: 'spin 1s linear infinite', + bounce: 'bounce 1s linear infinite', + }) }) }) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 7c96a937e..b296bf590 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,10 +1,12 @@ import { substituteAtApply } from './apply' import { objectToAst, rule, type AstNode, type CssInJs } from './ast' +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' @@ -208,6 +210,13 @@ export function buildPluginApi( } }, theme(path) { + let readFromCss = createReadFromCss(designSystem.theme) + let cssThemeValue = readFromCss(path) + + if (typeof cssThemeValue !== 'object') { + return cssThemeValue + } + if (path === 'colors') { let result: Record = resolvedConfig.theme?.colors ?? {} @@ -218,9 +227,25 @@ export function buildPluginApi( return result } - let parts = segment(path, '.') let result = resolvedConfig.theme ?? {} + // 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, '.') + + let obj = get(result, parts) ?? {} + + if (typeof obj === 'object') { + deepMerge(obj, [cssThemeValue], (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, cssThemeValue) + } + for (let part of parts) { result = result?.[part] } @@ -288,3 +313,119 @@ export function createCompatabilityConfig(theme: Theme) { }, } } + +function createReadFromCss(theme: Theme) { + type ThemeValue = + // A normal string value + | string + + // A nested tuple with additional data + | [main: string, extra: Record] + + return function readFromCss(path: string) { + // 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[] = [] + + // 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.namespace(`--${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 +} From 106a1b662200fe74234a406f81baf32180ece68b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 16:51:10 -0400 Subject: [PATCH 11/20] wip --- packages/tailwindcss/src/plugin-api.test.ts | 56 +++--- packages/tailwindcss/src/plugin-api.ts | 191 ++++++++++---------- packages/tailwindcss/src/theme.ts | 15 ++ 3 files changed, 140 insertions(+), 122 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 695f95e7e..45bc8bbf4 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -335,20 +335,20 @@ describe('theme', async () => { expect(compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist'])) .toMatchInlineSnapshot(` - ".animation { - --animation: pulse 1s linear infinite; - } - .animation-spin { - --animation: spin 1s linear infinite; - } - .animation2 { - --animation: pulse 1s linear infinite; - } - .animation2-twist { - --animation: spin 1s linear infinite; - } - " - `) + ".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 }) => { @@ -389,17 +389,17 @@ describe('theme', async () => { expect(compiler.build(['animation', 'animation-spin', 'animation-bounce'])) .toMatchInlineSnapshot(` - ".animation { - --animation: pulse 1s linear infinite; - } - .animation-bounce { - --animation: bounce 1s linear infinite; - } - .animation-spin { - --animation: spin 1s linear infinite; - } - " - `) + ".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 }) => { @@ -440,7 +440,7 @@ describe('theme', async () => { expect(compiler.build(['animation'])).toMatchInlineSnapshot(` ".animation { - --animation: pulse 1s linear infinite; + --animation: var(--animation, pulse 1s linear infinite); } " `) @@ -478,8 +478,8 @@ describe('theme', async () => { }) expect(fn).toHaveBeenCalledWith({ - spin: 'spin 1s linear infinite', - bounce: 'bounce 1s linear infinite', + spin: 'var(--animation-simple-spin, spin 1s linear infinite)', + bounce: 'var(--animation-simple-bounce, bounce 1s linear infinite)', }) }) }) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index b296bf590..ab7430ac5 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -210,21 +210,10 @@ export function buildPluginApi( } }, theme(path) { - let readFromCss = createReadFromCss(designSystem.theme) - let cssThemeValue = readFromCss(path) + let cssValue = readFromCss(designSystem.theme, path) - if (typeof cssThemeValue !== 'object') { - return cssThemeValue - } - - if (path === 'colors') { - let result: Record = resolvedConfig.theme?.colors ?? {} - - for (let [key] of designSystem.theme.namespace('--color')) { - result[key ?? 'DEFAULT'] = designSystem.theme.resolve(key, ['--color'])! - } - - return result + if (typeof cssValue !== 'object') { + return cssValue } let result = resolvedConfig.theme ?? {} @@ -237,13 +226,13 @@ export function buildPluginApi( let obj = get(result, parts) ?? {} if (typeof obj === 'object') { - deepMerge(obj, [cssThemeValue], (a, b) => b) + 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, cssThemeValue) + set(result, parts, cssValue) } for (let part of parts) { @@ -314,96 +303,106 @@ export function createCompatabilityConfig(theme: Theme) { } } -function createReadFromCss(theme: Theme) { +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()}`) - return function readFromCss(path: string) { - // 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[] = [] - // 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 - // Make sure the original path is included last because it should take precedence - paths.push(original) + // 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) - let map = new Map() - let nested = new DefaultMap>(() => new Map()) - - for (let path of paths) { - path = path.replace('-DEFAULT', '') - - let ns = theme.namespace(`--${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 upgrade of themeUpgradeMap[prefix]) { + paths.push(upgrade + suffix) } - - 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 } + + // 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[]) { @@ -429,3 +428,7 @@ function set(obj: any, path: string[], value: any) { obj[path[path.length - 1]] = value } + +let themeUpgradeMap: Record = { + colors: ['color'], +} diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 7d150c350..7e99e28e4 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 = From 22ccdebd55d0730407c658189c019587b06bf422 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 17:46:29 -0400 Subject: [PATCH 12/20] wip --- packages/tailwindcss/src/plugin-api.ts | 419 +++++++++++++++++++++++-- 1 file changed, 400 insertions(+), 19 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index ab7430ac5..8229abee9 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -284,25 +284,6 @@ export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, a } } -export function createCompatabilityConfig(theme: Theme) { - return { - theme: { - transitionDuration: { - DEFAULT: theme.get(['--default-transition-duration']) ?? null, - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}ms` - } - }, - }, - - transitionTimingFunction: { - DEFAULT: theme.get(['--default-transition-timing-function']) ?? null, - }, - }, - } -} - function readFromCss(theme: Theme, path: string) { type ThemeValue = // A normal string value @@ -432,3 +413,403 @@ function set(obj: any, path: string[], value: any) { let themeUpgradeMap: Record = { colors: ['color'], } + +function createCompatabilityConfig(theme: Theme): UserConfig { + return { + theme: { + transitionTimingFunction: { + DEFAULT: theme.get(['--default-transition-timing-function']) ?? null, + }, + + zIndex: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + order: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + gridRowStart: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + gridRowEnd: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + lineClamp: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + aspectRatio: { + [BARE_VALUE]: (value: string) => { + throw new Error('TODO') + // TODO: + // if (fraction === null) return + // let [lhs, rhs] = segment(fraction, '/') + // if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return + // return fraction + }, + }, + + flexShrink: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + flexGrow: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + scale: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + rotate: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}deg` + } + }, + }, + + skew: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}deg` + } + }, + }, + + columns: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return value + } + }, + }, + + gridTemplateColumns: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `repeat(${value}, minmax(0, 1fr))` + } + }, + }, + + gridTemplateRows: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `repeat(${value}, minmax(0, 1fr))` + } + }, + }, + + divideWidth: ({ theme }) => ({ + ...theme('borderWidth'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }), + + brightness: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + backdropBrightness: ({ theme }) => ({ + ...theme('brightness'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + contrast: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + backdropContrast: ({ theme }) => ({ + ...theme('contrast'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + grayscale: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + backdropGrayscale: ({ theme }) => ({ + ...theme('grayscale'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + hueRotate: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}deg` + } + }, + }, + + backdropHueRotate: ({ theme }) => ({ + ...theme('hueRotate'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}deg` + } + }, + }), + + invert: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + backdropInvert: ({ theme }) => ({ + ...theme('invert'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + saturate: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + backdropSaturate: ({ theme }) => ({ + ...theme('saturate'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + sepia: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + backdropSepia: ({ theme }) => ({ + ...theme('sepia'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + backdropOpacity: ({ theme }) => ({ + ...theme('opacity'), + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }), + + transitionDuration: { + DEFAULT: theme.get(['--default-transition-duration']) ?? null, + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}ms` + } + }, + }, + + transitionDelay: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}ms` + } + }, + }, + + outlineWidth: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + outlineOffset: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + ringWidth: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + ringOffsetWidth: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + opacity: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + textUnderlineOffset: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + border: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + gradientColorStopPositions: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}%` + } + }, + }, + + strokeWidth: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + textDecorationThickness: { + [BARE_VALUE]: (value: string) => { + if (!Number.isNaN(Number(value))) { + return `${value}px` + } + }, + }, + + accentColor: ({ theme }) => theme('colors'), + backdropBlur: ({ theme }) => theme('blur'), + backgroundColor: ({ theme }) => theme('colors'), + backgroundOpacity: ({ theme }) => theme('opacity'), + borderColor: ({ theme }) => theme('colors'), + borderOpacity: ({ theme }) => theme('opacity'), + borderSpacing: ({ theme }) => theme('spacing'), + boxShadowColor: ({ theme }) => theme('colors'), + caretColor: ({ theme }) => theme('colors'), + divideColor: ({ theme }) => theme('borderColor'), + divideOpacity: ({ theme }) => theme('borderOpacity'), + fill: ({ theme }) => theme('colors'), + flexBasis: ({ theme }) => theme('spacing'), + gap: ({ theme }) => theme('spacing'), + gradientColorStops: ({ theme }) => theme('colors'), + height: ({ theme }) => theme('spacing'), + inset: ({ theme }) => theme('spacing'), + margin: ({ theme }) => theme('spacing'), + maxHeight: ({ theme }) => theme('spacing'), + maxWidth: ({ theme }) => theme('spacing'), + minHeight: ({ theme }) => theme('spacing'), + minWidth: ({ theme }) => theme('spacing'), + outlineColor: ({ theme }) => theme('colors'), + padding: ({ theme }) => theme('spacing'), + placeholderColor: ({ theme }) => theme('colors'), + placeholderOpacity: ({ theme }) => theme('opacity'), + ringColor: ({ theme }) => theme('colors'), + ringOffsetColor: ({ theme }) => theme('colors'), + ringOpacity: ({ theme }) => theme('opacity'), + scrollMargin: ({ theme }) => theme('spacing'), + scrollPadding: ({ theme }) => theme('spacing'), + space: ({ theme }) => theme('spacing'), + stroke: ({ theme }) => theme('colors'), + textColor: ({ theme }) => theme('colors'), + textDecorationColor: ({ theme }) => theme('colors'), + textIndent: ({ theme }) => theme('spacing'), + textOpacity: ({ theme }) => theme('opacity'), + translate: ({ theme }) => theme('spacing'), + size: ({ theme }) => theme('spacing'), + width: ({ theme }) => theme('spacing'), + }, + } +} From 3f112a7c8029730e4f9ecc5963cae2aed41a0043 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 14 Aug 2024 17:52:02 -0400 Subject: [PATCH 13/20] wip --- packages/tailwindcss/src/plugin-api.ts | 4 ++++ packages/tailwindcss/src/theme.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 8229abee9..add6f28f7 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -420,6 +420,10 @@ function createCompatabilityConfig(theme: Theme): UserConfig { transitionTimingFunction: { DEFAULT: theme.get(['--default-transition-timing-function']) ?? null, }, + fontFamily: { + DEFAULT: theme.get(['--default-font-family']) ?? null, + mono: theme.get(['--default-mono-font-family']) ?? null, + }, zIndex: { [BARE_VALUE]: (value: string) => { diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 7e99e28e4..fc14e507c 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -270,6 +270,7 @@ export type ThemeKey = | '--translate' | '--width' | '--z-index' + | `--default-${string}` export type ColorThemeKey = | '--color' From 265d1c7b695c760686c037c69f74bdff270ab311 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:38:49 -0400 Subject: [PATCH 14/20] WIP --- packages/tailwindcss/src/plugin-api.ts | 491 ++++++--------------- packages/tailwindcss/src/utilities.test.ts | 2 +- 2 files changed, 129 insertions(+), 364 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index add6f28f7..4194e2941 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,5 +1,6 @@ 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' @@ -29,7 +30,10 @@ 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 @@ -138,17 +142,19 @@ export function buildPluginApi( let value: string | null = null { - let values: Record = options?.values ?? {} + 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, - } + values = Object.assign( + { + inherit: 'inherit', + transparent: 'transparent', + current: 'currentColor', + }, + values, + ) } if (!candidate.value) { @@ -157,14 +163,8 @@ export function buildPluginApi( value = candidate.value.value } else if (values[candidate.value.value]) { value = values[candidate.value.value] - } else if (values[BARE_VALUE]) { - // We've snuk the bare value in here as a function even though values are - // typically only ever strings. This is a backwards compatibility hack. - let handleBareValue = values[BARE_VALUE] as unknown as ( - value: string, - ) => string | null - - value = handleBareValue(candidate.value.value) ?? null + } else if (values.__BARE_VALUE__) { + value = values.__BARE_VALUE__(candidate.value) ?? null } } @@ -190,7 +190,7 @@ export function buildPluginApi( } // A modifier was provided but is invalid - if (candidate.modifier && modifier !== null) { + if (candidate.modifier && modifier === null) { // For arbitrary values, return `null` to avoid falling through to the next utility return candidate.value?.kind === 'arbitrary' ? null : undefined } @@ -244,11 +244,6 @@ export function buildPluginApi( } } -// 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 -const BARE_VALUE = `__BARE_VALUE__` - export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) { let pluginObjects = [] @@ -414,406 +409,176 @@ 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: { - transitionTimingFunction: { - DEFAULT: theme.get(['--default-transition-timing-function']) ?? null, - }, - fontFamily: { - DEFAULT: theme.get(['--default-font-family']) ?? null, - mono: theme.get(['--default-mono-font-family']) ?? null, - }, - - zIndex: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - order: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - gridRowStart: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - gridRowEnd: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - lineClamp: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - aspectRatio: { - [BARE_VALUE]: (value: string) => { - throw new Error('TODO') - // TODO: - // if (fraction === null) return - // let [lhs, rhs] = segment(fraction, '/') - // if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return - // return fraction - }, - }, - - flexShrink: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - flexGrow: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - scale: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - - rotate: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}deg` - } - }, - }, - - skew: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}deg` - } - }, - }, - - columns: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return value - } - }, - }, - - gridTemplateColumns: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `repeat(${value}, minmax(0, 1fr))` - } - }, - }, - - gridTemplateRows: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `repeat(${value}, minmax(0, 1fr))` - } - }, - }, - - divideWidth: ({ theme }) => ({ - ...theme('borderWidth'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, + 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 }), - - brightness: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - + backdropBlur: ({ theme }) => theme('blur'), backdropBrightness: ({ theme }) => ({ ...theme('brightness'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, + ...barePercentages, }), - - contrast: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - backdropContrast: ({ theme }) => ({ ...theme('contrast'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, + ...barePercentages, }), - - grayscale: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - backdropGrayscale: ({ theme }) => ({ ...theme('grayscale'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, + ...barePercentages, }), - - hueRotate: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}deg` - } - }, - }, - backdropHueRotate: ({ theme }) => ({ ...theme('hueRotate'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}deg` - } - }, + ...bareDegrees, }), - - invert: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - backdropInvert: ({ theme }) => ({ ...theme('invert'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, + ...barePercentages, }), - - saturate: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - - backdropSaturate: ({ theme }) => ({ - ...theme('saturate'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }), - - sepia: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - - backdropSepia: ({ theme }) => ({ - ...theme('sepia'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }), - backdropOpacity: ({ theme }) => ({ ...theme('opacity'), - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, + ...barePercentages, + }), + backdropSaturate: ({ theme }) => ({ + ...theme('saturate'), + ...barePercentages, + }), + backdropSepia: ({ theme }) => ({ + ...theme('sepia'), + ...barePercentages, }), - - transitionDuration: { - DEFAULT: theme.get(['--default-transition-duration']) ?? null, - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}ms` - } - }, - }, - - transitionDelay: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}ms` - } - }, - }, - - outlineWidth: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - outlineOffset: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - ringWidth: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - ringOffsetWidth: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - opacity: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - - textUnderlineOffset: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - border: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - gradientColorStopPositions: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}%` - } - }, - }, - - strokeWidth: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - textDecorationThickness: { - [BARE_VALUE]: (value: string) => { - if (!Number.isNaN(Number(value))) { - return `${value}px` - } - }, - }, - - accentColor: ({ theme }) => theme('colors'), - backdropBlur: ({ theme }) => theme('blur'), 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'), - size: ({ theme }) => theme('spacing'), width: ({ theme }) => theme('spacing'), + zIndex: bareIntegers, }, } } 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` From 35eb103b8d2ce6c4281eeb4deac7e7c3342661f9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Aug 2024 14:57:37 -0400 Subject: [PATCH 15/20] Remove unncessary spread --- packages/tailwindcss/src/plugin-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 4194e2941..7ef13aff9 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -568,7 +568,7 @@ function createCompatabilityConfig(theme: Theme): UserConfig { textIndent: ({ theme }) => theme('spacing'), textOpacity: ({ theme }) => theme('opacity'), textUnderlineOffset: barePixels, - transitionDelay: { ...bareMilliseconds }, + transitionDelay: bareMilliseconds, transitionDuration: { DEFAULT: theme.get(['--default-transition-duration']) ?? null, ...bareMilliseconds, From 1c4a8260df19d771a8a41911e57909d91c094e7b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Aug 2024 14:57:45 -0400 Subject: [PATCH 16/20] Support fractional bare values --- packages/tailwindcss/src/plugin-api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 7ef13aff9..143765321 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -140,6 +140,7 @@ export function buildPluginApi( // Resolve the candidate value let value: string | null = null + let isFraction = false { let values = options?.values ?? {} @@ -165,6 +166,8 @@ export function buildPluginApi( 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 } } @@ -190,7 +193,7 @@ export function buildPluginApi( } // A modifier was provided but is invalid - if (candidate.modifier && modifier === null) { + 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 } From 6ad5f1eafd372aeb66facf412eed6f67cb728272 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 15 Aug 2024 14:58:20 -0400 Subject: [PATCH 17/20] Add bare values test for all theme keys --- packages/tailwindcss/src/plugin-api.test.ts | 238 ++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 45bc8bbf4..42b73d6d4 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -482,4 +482,242 @@ describe('theme', async () => { 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; + } + " + `) + }) }) From def1f826abf68c7daa2bd995aee897bdec54f96f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 16 Aug 2024 12:00:30 -0400 Subject: [PATCH 18/20] Remove files --- .../tailwindcss/src/compat/config/colors.ts | 293 ----- .../tailwindcss/src/compat/config/default.ts | 1064 ----------------- 2 files changed, 1357 deletions(-) delete mode 100644 packages/tailwindcss/src/compat/config/colors.ts delete mode 100644 packages/tailwindcss/src/compat/config/default.ts diff --git a/packages/tailwindcss/src/compat/config/colors.ts b/packages/tailwindcss/src/compat/config/colors.ts deleted file mode 100644 index eed63663f..000000000 --- a/packages/tailwindcss/src/compat/config/colors.ts +++ /dev/null @@ -1,293 +0,0 @@ -export const colors = { - inherit: 'inherit', - current: 'currentColor', - transparent: 'transparent', - black: '#000', - white: '#fff', - slate: { - 50: '#f8fafc', - 100: '#f1f5f9', - 200: '#e2e8f0', - 300: '#cbd5e1', - 400: '#94a3b8', - 500: '#64748b', - 600: '#475569', - 700: '#334155', - 800: '#1e293b', - 900: '#0f172a', - 950: '#020617', - }, - gray: { - 50: '#f9fafb', - 100: '#f3f4f6', - 200: '#e5e7eb', - 300: '#d1d5db', - 400: '#9ca3af', - 500: '#6b7280', - 600: '#4b5563', - 700: '#374151', - 800: '#1f2937', - 900: '#111827', - 950: '#030712', - }, - zinc: { - 50: '#fafafa', - 100: '#f4f4f5', - 200: '#e4e4e7', - 300: '#d4d4d8', - 400: '#a1a1aa', - 500: '#71717a', - 600: '#52525b', - 700: '#3f3f46', - 800: '#27272a', - 900: '#18181b', - 950: '#09090b', - }, - neutral: { - 50: '#fafafa', - 100: '#f5f5f5', - 200: '#e5e5e5', - 300: '#d4d4d4', - 400: '#a3a3a3', - 500: '#737373', - 600: '#525252', - 700: '#404040', - 800: '#262626', - 900: '#171717', - 950: '#0a0a0a', - }, - stone: { - 50: '#fafaf9', - 100: '#f5f5f4', - 200: '#e7e5e4', - 300: '#d6d3d1', - 400: '#a8a29e', - 500: '#78716c', - 600: '#57534e', - 700: '#44403c', - 800: '#292524', - 900: '#1c1917', - 950: '#0c0a09', - }, - red: { - 50: '#fef2f2', - 100: '#fee2e2', - 200: '#fecaca', - 300: '#fca5a5', - 400: '#f87171', - 500: '#ef4444', - 600: '#dc2626', - 700: '#b91c1c', - 800: '#991b1b', - 900: '#7f1d1d', - 950: '#450a0a', - }, - orange: { - 50: '#fff7ed', - 100: '#ffedd5', - 200: '#fed7aa', - 300: '#fdba74', - 400: '#fb923c', - 500: '#f97316', - 600: '#ea580c', - 700: '#c2410c', - 800: '#9a3412', - 900: '#7c2d12', - 950: '#431407', - }, - amber: { - 50: '#fffbeb', - 100: '#fef3c7', - 200: '#fde68a', - 300: '#fcd34d', - 400: '#fbbf24', - 500: '#f59e0b', - 600: '#d97706', - 700: '#b45309', - 800: '#92400e', - 900: '#78350f', - 950: '#451a03', - }, - yellow: { - 50: '#fefce8', - 100: '#fef9c3', - 200: '#fef08a', - 300: '#fde047', - 400: '#facc15', - 500: '#eab308', - 600: '#ca8a04', - 700: '#a16207', - 800: '#854d0e', - 900: '#713f12', - 950: '#422006', - }, - lime: { - 50: '#f7fee7', - 100: '#ecfccb', - 200: '#d9f99d', - 300: '#bef264', - 400: '#a3e635', - 500: '#84cc16', - 600: '#65a30d', - 700: '#4d7c0f', - 800: '#3f6212', - 900: '#365314', - 950: '#1a2e05', - }, - green: { - 50: '#f0fdf4', - 100: '#dcfce7', - 200: '#bbf7d0', - 300: '#86efac', - 400: '#4ade80', - 500: '#22c55e', - 600: '#16a34a', - 700: '#15803d', - 800: '#166534', - 900: '#14532d', - 950: '#052e16', - }, - emerald: { - 50: '#ecfdf5', - 100: '#d1fae5', - 200: '#a7f3d0', - 300: '#6ee7b7', - 400: '#34d399', - 500: '#10b981', - 600: '#059669', - 700: '#047857', - 800: '#065f46', - 900: '#064e3b', - 950: '#022c22', - }, - teal: { - 50: '#f0fdfa', - 100: '#ccfbf1', - 200: '#99f6e4', - 300: '#5eead4', - 400: '#2dd4bf', - 500: '#14b8a6', - 600: '#0d9488', - 700: '#0f766e', - 800: '#115e59', - 900: '#134e4a', - 950: '#042f2e', - }, - cyan: { - 50: '#ecfeff', - 100: '#cffafe', - 200: '#a5f3fc', - 300: '#67e8f9', - 400: '#22d3ee', - 500: '#06b6d4', - 600: '#0891b2', - 700: '#0e7490', - 800: '#155e75', - 900: '#164e63', - 950: '#083344', - }, - sky: { - 50: '#f0f9ff', - 100: '#e0f2fe', - 200: '#bae6fd', - 300: '#7dd3fc', - 400: '#38bdf8', - 500: '#0ea5e9', - 600: '#0284c7', - 700: '#0369a1', - 800: '#075985', - 900: '#0c4a6e', - 950: '#082f49', - }, - blue: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - 950: '#172554', - }, - indigo: { - 50: '#eef2ff', - 100: '#e0e7ff', - 200: '#c7d2fe', - 300: '#a5b4fc', - 400: '#818cf8', - 500: '#6366f1', - 600: '#4f46e5', - 700: '#4338ca', - 800: '#3730a3', - 900: '#312e81', - 950: '#1e1b4b', - }, - violet: { - 50: '#f5f3ff', - 100: '#ede9fe', - 200: '#ddd6fe', - 300: '#c4b5fd', - 400: '#a78bfa', - 500: '#8b5cf6', - 600: '#7c3aed', - 700: '#6d28d9', - 800: '#5b21b6', - 900: '#4c1d95', - 950: '#2e1065', - }, - purple: { - 50: '#faf5ff', - 100: '#f3e8ff', - 200: '#e9d5ff', - 300: '#d8b4fe', - 400: '#c084fc', - 500: '#a855f7', - 600: '#9333ea', - 700: '#7e22ce', - 800: '#6b21a8', - 900: '#581c87', - 950: '#3b0764', - }, - fuchsia: { - 50: '#fdf4ff', - 100: '#fae8ff', - 200: '#f5d0fe', - 300: '#f0abfc', - 400: '#e879f9', - 500: '#d946ef', - 600: '#c026d3', - 700: '#a21caf', - 800: '#86198f', - 900: '#701a75', - 950: '#4a044e', - }, - pink: { - 50: '#fdf2f8', - 100: '#fce7f3', - 200: '#fbcfe8', - 300: '#f9a8d4', - 400: '#f472b6', - 500: '#ec4899', - 600: '#db2777', - 700: '#be185d', - 800: '#9d174d', - 900: '#831843', - 950: '#500724', - }, - rose: { - 50: '#fff1f2', - 100: '#ffe4e6', - 200: '#fecdd3', - 300: '#fda4af', - 400: '#fb7185', - 500: '#f43f5e', - 600: '#e11d48', - 700: '#be123c', - 800: '#9f1239', - 900: '#881337', - 950: '#4c0519', - }, -} diff --git a/packages/tailwindcss/src/compat/config/default.ts b/packages/tailwindcss/src/compat/config/default.ts deleted file mode 100644 index 7a9602915..000000000 --- a/packages/tailwindcss/src/compat/config/default.ts +++ /dev/null @@ -1,1064 +0,0 @@ -import type { UserConfig } from './types' - -export default { - content: ['auto'], - presets: [], - darkMode: 'media', // or 'class' - theme: { - accentColor: ({ theme }) => ({ - ...theme('colors'), - auto: 'auto', - }), - animation: { - none: 'none', - spin: 'spin 1s linear infinite', - ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', - pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', - bounce: 'bounce 1s infinite', - }, - aria: { - busy: 'busy="true"', - checked: 'checked="true"', - disabled: 'disabled="true"', - expanded: 'expanded="true"', - hidden: 'hidden="true"', - pressed: 'pressed="true"', - readonly: 'readonly="true"', - required: 'required="true"', - selected: 'selected="true"', - }, - aspectRatio: { - auto: 'auto', - square: '1 / 1', - video: '16 / 9', - }, - backdropBlur: ({ theme }) => theme('blur'), - backdropBrightness: ({ theme }) => theme('brightness'), - backdropContrast: ({ theme }) => theme('contrast'), - backdropGrayscale: ({ theme }) => theme('grayscale'), - backdropHueRotate: ({ theme }) => theme('hueRotate'), - backdropInvert: ({ theme }) => theme('invert'), - backdropOpacity: ({ theme }) => theme('opacity'), - backdropSaturate: ({ theme }) => theme('saturate'), - backdropSepia: ({ theme }) => theme('sepia'), - backgroundColor: ({ theme }) => theme('colors'), - backgroundImage: { - none: 'none', - 'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))', - 'gradient-to-tr': 'linear-gradient(to top right, var(--tw-gradient-stops))', - 'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))', - 'gradient-to-br': 'linear-gradient(to bottom right, var(--tw-gradient-stops))', - 'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))', - 'gradient-to-bl': 'linear-gradient(to bottom left, var(--tw-gradient-stops))', - 'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))', - 'gradient-to-tl': 'linear-gradient(to top left, var(--tw-gradient-stops))', - }, - backgroundOpacity: ({ theme }) => theme('opacity'), - backgroundPosition: { - bottom: 'bottom', - center: 'center', - left: 'left', - 'left-bottom': 'left bottom', - 'left-top': 'left top', - right: 'right', - 'right-bottom': 'right bottom', - 'right-top': 'right top', - top: 'top', - }, - backgroundSize: { - auto: 'auto', - cover: 'cover', - contain: 'contain', - }, - blur: { - 0: '0', - none: '0', - sm: '4px', - DEFAULT: '8px', - md: '12px', - lg: '16px', - xl: '24px', - '2xl': '40px', - '3xl': '64px', - }, - borderColor: ({ theme }) => ({ - ...theme('colors'), - DEFAULT: theme('colors.gray.200', 'currentColor'), - }), - borderOpacity: ({ theme }) => theme('opacity'), - borderRadius: { - none: '0px', - sm: '0.125rem', - DEFAULT: '0.25rem', - md: '0.375rem', - lg: '0.5rem', - xl: '0.75rem', - '2xl': '1rem', - '3xl': '1.5rem', - full: '9999px', - }, - borderSpacing: ({ theme }) => ({ - ...theme('spacing'), - }), - borderWidth: { - DEFAULT: '1px', - 0: '0px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - boxShadow: { - sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', - DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', - md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', - lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', - xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', - '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', - inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)', - none: 'none', - }, - boxShadowColor: ({ theme }) => theme('colors'), - brightness: { - 0: '0', - 50: '.5', - 75: '.75', - 90: '.9', - 95: '.95', - 100: '1', - 105: '1.05', - 110: '1.1', - 125: '1.25', - 150: '1.5', - 200: '2', - }, - caretColor: ({ theme }) => theme('colors'), - colors: ({ colors }) => ({ - inherit: colors.inherit, - current: colors.current, - transparent: colors.transparent, - black: colors.black, - white: colors.white, - slate: colors.slate, - gray: colors.gray, - zinc: colors.zinc, - neutral: colors.neutral, - stone: colors.stone, - red: colors.red, - orange: colors.orange, - amber: colors.amber, - yellow: colors.yellow, - lime: colors.lime, - green: colors.green, - emerald: colors.emerald, - teal: colors.teal, - cyan: colors.cyan, - sky: colors.sky, - blue: colors.blue, - indigo: colors.indigo, - violet: colors.violet, - purple: colors.purple, - fuchsia: colors.fuchsia, - pink: colors.pink, - rose: colors.rose, - }), - columns: { - auto: 'auto', - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - 7: '7', - 8: '8', - 9: '9', - 10: '10', - 11: '11', - 12: '12', - '3xs': '16rem', - '2xs': '18rem', - xs: '20rem', - sm: '24rem', - md: '28rem', - lg: '32rem', - xl: '36rem', - '2xl': '42rem', - '3xl': '48rem', - '4xl': '56rem', - '5xl': '64rem', - '6xl': '72rem', - '7xl': '80rem', - }, - container: {}, - content: { - none: 'none', - }, - contrast: { - 0: '0', - 50: '.5', - 75: '.75', - 100: '1', - 125: '1.25', - 150: '1.5', - 200: '2', - }, - cursor: { - auto: 'auto', - default: 'default', - pointer: 'pointer', - wait: 'wait', - text: 'text', - move: 'move', - help: 'help', - 'not-allowed': 'not-allowed', - none: 'none', - 'context-menu': 'context-menu', - progress: 'progress', - cell: 'cell', - crosshair: 'crosshair', - 'vertical-text': 'vertical-text', - alias: 'alias', - copy: 'copy', - 'no-drop': 'no-drop', - grab: 'grab', - grabbing: 'grabbing', - 'all-scroll': 'all-scroll', - 'col-resize': 'col-resize', - 'row-resize': 'row-resize', - 'n-resize': 'n-resize', - 'e-resize': 'e-resize', - 's-resize': 's-resize', - 'w-resize': 'w-resize', - 'ne-resize': 'ne-resize', - 'nw-resize': 'nw-resize', - 'se-resize': 'se-resize', - 'sw-resize': 'sw-resize', - 'ew-resize': 'ew-resize', - 'ns-resize': 'ns-resize', - 'nesw-resize': 'nesw-resize', - 'nwse-resize': 'nwse-resize', - 'zoom-in': 'zoom-in', - 'zoom-out': 'zoom-out', - }, - divideColor: ({ theme }) => theme('borderColor'), - divideOpacity: ({ theme }) => theme('borderOpacity'), - divideWidth: ({ theme }) => theme('borderWidth'), - dropShadow: { - sm: '0 1px 1px rgb(0 0 0 / 0.05)', - DEFAULT: ['0 1px 2px rgb(0 0 0 / 0.1)', '0 1px 1px rgb(0 0 0 / 0.06)'], - md: ['0 4px 3px rgb(0 0 0 / 0.07)', '0 2px 2px rgb(0 0 0 / 0.06)'], - lg: ['0 10px 8px rgb(0 0 0 / 0.04)', '0 4px 3px rgb(0 0 0 / 0.1)'], - xl: ['0 20px 13px rgb(0 0 0 / 0.03)', '0 8px 5px rgb(0 0 0 / 0.08)'], - '2xl': '0 25px 25px rgb(0 0 0 / 0.15)', - none: '0 0 #0000', - }, - fill: ({ theme }) => ({ - none: 'none', - ...theme('colors'), - }), - flex: { - 1: '1 1 0%', - auto: '1 1 auto', - initial: '0 1 auto', - none: 'none', - }, - flexBasis: ({ theme }) => ({ - auto: 'auto', - ...theme('spacing'), - '1/2': '50%', - '1/3': '33.333333%', - '2/3': '66.666667%', - '1/4': '25%', - '2/4': '50%', - '3/4': '75%', - '1/5': '20%', - '2/5': '40%', - '3/5': '60%', - '4/5': '80%', - '1/6': '16.666667%', - '2/6': '33.333333%', - '3/6': '50%', - '4/6': '66.666667%', - '5/6': '83.333333%', - '1/12': '8.333333%', - '2/12': '16.666667%', - '3/12': '25%', - '4/12': '33.333333%', - '5/12': '41.666667%', - '6/12': '50%', - '7/12': '58.333333%', - '8/12': '66.666667%', - '9/12': '75%', - '10/12': '83.333333%', - '11/12': '91.666667%', - full: '100%', - }), - flexGrow: { - 0: '0', - DEFAULT: '1', - }, - flexShrink: { - 0: '0', - DEFAULT: '1', - }, - fontFamily: { - sans: [ - 'ui-sans-serif', - 'system-ui', - 'sans-serif', - '"Apple Color Emoji"', - '"Segoe UI Emoji"', - '"Segoe UI Symbol"', - '"Noto Color Emoji"', - ], - serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], - mono: [ - 'ui-monospace', - 'SFMono-Regular', - 'Menlo', - 'Monaco', - 'Consolas', - '"Liberation Mono"', - '"Courier New"', - 'monospace', - ], - }, - fontSize: { - xs: ['0.75rem', { lineHeight: '1rem' }], - sm: ['0.875rem', { lineHeight: '1.25rem' }], - base: ['1rem', { lineHeight: '1.5rem' }], - lg: ['1.125rem', { lineHeight: '1.75rem' }], - xl: ['1.25rem', { lineHeight: '1.75rem' }], - '2xl': ['1.5rem', { lineHeight: '2rem' }], - '3xl': ['1.875rem', { lineHeight: '2.25rem' }], - '4xl': ['2.25rem', { lineHeight: '2.5rem' }], - '5xl': ['3rem', { lineHeight: '1' }], - '6xl': ['3.75rem', { lineHeight: '1' }], - '7xl': ['4.5rem', { lineHeight: '1' }], - '8xl': ['6rem', { lineHeight: '1' }], - '9xl': ['8rem', { lineHeight: '1' }], - }, - fontWeight: { - thin: '100', - extralight: '200', - light: '300', - normal: '400', - medium: '500', - semibold: '600', - bold: '700', - extrabold: '800', - black: '900', - }, - gap: ({ theme }) => theme('spacing'), - gradientColorStops: ({ theme }) => theme('colors'), - gradientColorStopPositions: { - '0%': '0%', - '5%': '5%', - '10%': '10%', - '15%': '15%', - '20%': '20%', - '25%': '25%', - '30%': '30%', - '35%': '35%', - '40%': '40%', - '45%': '45%', - '50%': '50%', - '55%': '55%', - '60%': '60%', - '65%': '65%', - '70%': '70%', - '75%': '75%', - '80%': '80%', - '85%': '85%', - '90%': '90%', - '95%': '95%', - '100%': '100%', - }, - grayscale: { - 0: '0', - DEFAULT: '100%', - }, - gridAutoColumns: { - auto: 'auto', - min: 'min-content', - max: 'max-content', - fr: 'minmax(0, 1fr)', - }, - gridAutoRows: { - auto: 'auto', - min: 'min-content', - max: 'max-content', - fr: 'minmax(0, 1fr)', - }, - gridColumn: { - auto: 'auto', - 'span-1': 'span 1 / span 1', - 'span-2': 'span 2 / span 2', - 'span-3': 'span 3 / span 3', - 'span-4': 'span 4 / span 4', - 'span-5': 'span 5 / span 5', - 'span-6': 'span 6 / span 6', - 'span-7': 'span 7 / span 7', - 'span-8': 'span 8 / span 8', - 'span-9': 'span 9 / span 9', - 'span-10': 'span 10 / span 10', - 'span-11': 'span 11 / span 11', - 'span-12': 'span 12 / span 12', - 'span-full': '1 / -1', - }, - gridColumnEnd: { - auto: 'auto', - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - 7: '7', - 8: '8', - 9: '9', - 10: '10', - 11: '11', - 12: '12', - 13: '13', - }, - gridColumnStart: { - auto: 'auto', - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - 7: '7', - 8: '8', - 9: '9', - 10: '10', - 11: '11', - 12: '12', - 13: '13', - }, - gridRow: { - auto: 'auto', - 'span-1': 'span 1 / span 1', - 'span-2': 'span 2 / span 2', - 'span-3': 'span 3 / span 3', - 'span-4': 'span 4 / span 4', - 'span-5': 'span 5 / span 5', - 'span-6': 'span 6 / span 6', - 'span-7': 'span 7 / span 7', - 'span-8': 'span 8 / span 8', - 'span-9': 'span 9 / span 9', - 'span-10': 'span 10 / span 10', - 'span-11': 'span 11 / span 11', - 'span-12': 'span 12 / span 12', - 'span-full': '1 / -1', - }, - gridRowEnd: { - auto: 'auto', - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - 7: '7', - 8: '8', - 9: '9', - 10: '10', - 11: '11', - 12: '12', - 13: '13', - }, - gridRowStart: { - auto: 'auto', - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - 7: '7', - 8: '8', - 9: '9', - 10: '10', - 11: '11', - 12: '12', - 13: '13', - }, - gridTemplateColumns: { - none: 'none', - subgrid: 'subgrid', - 1: 'repeat(1, minmax(0, 1fr))', - 2: 'repeat(2, minmax(0, 1fr))', - 3: 'repeat(3, minmax(0, 1fr))', - 4: 'repeat(4, minmax(0, 1fr))', - 5: 'repeat(5, minmax(0, 1fr))', - 6: 'repeat(6, minmax(0, 1fr))', - 7: 'repeat(7, minmax(0, 1fr))', - 8: 'repeat(8, minmax(0, 1fr))', - 9: 'repeat(9, minmax(0, 1fr))', - 10: 'repeat(10, minmax(0, 1fr))', - 11: 'repeat(11, minmax(0, 1fr))', - 12: 'repeat(12, minmax(0, 1fr))', - }, - gridTemplateRows: { - none: 'none', - subgrid: 'subgrid', - 1: 'repeat(1, minmax(0, 1fr))', - 2: 'repeat(2, minmax(0, 1fr))', - 3: 'repeat(3, minmax(0, 1fr))', - 4: 'repeat(4, minmax(0, 1fr))', - 5: 'repeat(5, minmax(0, 1fr))', - 6: 'repeat(6, minmax(0, 1fr))', - 7: 'repeat(7, minmax(0, 1fr))', - 8: 'repeat(8, minmax(0, 1fr))', - 9: 'repeat(9, minmax(0, 1fr))', - 10: 'repeat(10, minmax(0, 1fr))', - 11: 'repeat(11, minmax(0, 1fr))', - 12: 'repeat(12, minmax(0, 1fr))', - }, - height: ({ theme }) => ({ - auto: 'auto', - ...theme('spacing'), - '1/2': '50%', - '1/3': '33.333333%', - '2/3': '66.666667%', - '1/4': '25%', - '2/4': '50%', - '3/4': '75%', - '1/5': '20%', - '2/5': '40%', - '3/5': '60%', - '4/5': '80%', - '1/6': '16.666667%', - '2/6': '33.333333%', - '3/6': '50%', - '4/6': '66.666667%', - '5/6': '83.333333%', - full: '100%', - screen: '100vh', - svh: '100svh', - lvh: '100lvh', - dvh: '100dvh', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - }), - hueRotate: { - 0: '0deg', - 15: '15deg', - 30: '30deg', - 60: '60deg', - 90: '90deg', - 180: '180deg', - }, - inset: ({ theme }) => ({ - auto: 'auto', - ...theme('spacing'), - '1/2': '50%', - '1/3': '33.333333%', - '2/3': '66.666667%', - '1/4': '25%', - '2/4': '50%', - '3/4': '75%', - full: '100%', - }), - invert: { - 0: '0', - DEFAULT: '100%', - }, - keyframes: { - spin: { - to: { - transform: 'rotate(360deg)', - }, - }, - ping: { - '75%, 100%': { - transform: 'scale(2)', - opacity: '0', - }, - }, - pulse: { - '50%': { - opacity: '.5', - }, - }, - bounce: { - '0%, 100%': { - transform: 'translateY(-25%)', - animationTimingFunction: 'cubic-bezier(0.8,0,1,1)', - }, - '50%': { - transform: 'none', - animationTimingFunction: 'cubic-bezier(0,0,0.2,1)', - }, - }, - }, - letterSpacing: { - tighter: '-0.05em', - tight: '-0.025em', - normal: '0em', - wide: '0.025em', - wider: '0.05em', - widest: '0.1em', - }, - lineHeight: { - none: '1', - tight: '1.25', - snug: '1.375', - normal: '1.5', - relaxed: '1.625', - loose: '2', - 3: '.75rem', - 4: '1rem', - 5: '1.25rem', - 6: '1.5rem', - 7: '1.75rem', - 8: '2rem', - 9: '2.25rem', - 10: '2.5rem', - }, - listStyleType: { - none: 'none', - disc: 'disc', - decimal: 'decimal', - }, - listStyleImage: { - none: 'none', - }, - margin: ({ theme }) => ({ - auto: 'auto', - ...theme('spacing'), - }), - lineClamp: { - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - }, - maxHeight: ({ theme }) => ({ - ...theme('spacing'), - none: 'none', - full: '100%', - screen: '100vh', - svh: '100svh', - lvh: '100lvh', - dvh: '100dvh', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - }), - maxWidth: ({ theme, breakpoints }) => ({ - ...theme('spacing'), - none: 'none', - xs: '20rem', - sm: '24rem', - md: '28rem', - lg: '32rem', - xl: '36rem', - '2xl': '42rem', - '3xl': '48rem', - '4xl': '56rem', - '5xl': '64rem', - '6xl': '72rem', - '7xl': '80rem', - full: '100%', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - prose: '65ch', - ...breakpoints(theme('screens')), - }), - minHeight: ({ theme }) => ({ - ...theme('spacing'), - full: '100%', - screen: '100vh', - svh: '100svh', - lvh: '100lvh', - dvh: '100dvh', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - }), - minWidth: ({ theme }) => ({ - ...theme('spacing'), - full: '100%', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - }), - objectPosition: { - bottom: 'bottom', - center: 'center', - left: 'left', - 'left-bottom': 'left bottom', - 'left-top': 'left top', - right: 'right', - 'right-bottom': 'right bottom', - 'right-top': 'right top', - top: 'top', - }, - opacity: { - 0: '0', - 5: '0.05', - 10: '0.1', - 15: '0.15', - 20: '0.2', - 25: '0.25', - 30: '0.3', - 35: '0.35', - 40: '0.4', - 45: '0.45', - 50: '0.5', - 55: '0.55', - 60: '0.6', - 65: '0.65', - 70: '0.7', - 75: '0.75', - 80: '0.8', - 85: '0.85', - 90: '0.9', - 95: '0.95', - 100: '1', - }, - order: { - first: '-9999', - last: '9999', - none: '0', - 1: '1', - 2: '2', - 3: '3', - 4: '4', - 5: '5', - 6: '6', - 7: '7', - 8: '8', - 9: '9', - 10: '10', - 11: '11', - 12: '12', - }, - outlineColor: ({ theme }) => theme('colors'), - outlineOffset: { - 0: '0px', - 1: '1px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - outlineWidth: { - 0: '0px', - 1: '1px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - padding: ({ theme }) => theme('spacing'), - placeholderColor: ({ theme }) => theme('colors'), - placeholderOpacity: ({ theme }) => theme('opacity'), - ringColor: ({ theme }) => ({ - DEFAULT: theme('colors.blue.500', '#3b82f6'), - ...theme('colors'), - }), - ringOffsetColor: ({ theme }) => theme('colors'), - ringOffsetWidth: { - 0: '0px', - 1: '1px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - ringOpacity: ({ theme }) => ({ - DEFAULT: '0.5', - ...theme('opacity'), - }), - ringWidth: { - DEFAULT: '3px', - 0: '0px', - 1: '1px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - rotate: { - 0: '0deg', - 1: '1deg', - 2: '2deg', - 3: '3deg', - 6: '6deg', - 12: '12deg', - 45: '45deg', - 90: '90deg', - 180: '180deg', - }, - saturate: { - 0: '0', - 50: '.5', - 100: '1', - 150: '1.5', - 200: '2', - }, - scale: { - 0: '0', - 50: '.5', - 75: '.75', - 90: '.9', - 95: '.95', - 100: '1', - 105: '1.05', - 110: '1.1', - 125: '1.25', - 150: '1.5', - }, - screens: { - sm: '640px', - md: '768px', - lg: '1024px', - xl: '1280px', - '2xl': '1536px', - }, - scrollMargin: ({ theme }) => ({ - ...theme('spacing'), - }), - scrollPadding: ({ theme }) => theme('spacing'), - sepia: { - 0: '0', - DEFAULT: '100%', - }, - skew: { - 0: '0deg', - 1: '1deg', - 2: '2deg', - 3: '3deg', - 6: '6deg', - 12: '12deg', - }, - space: ({ theme }) => ({ - ...theme('spacing'), - }), - spacing: { - px: '1px', - 0: '0px', - 0.5: '0.125rem', - 1: '0.25rem', - 1.5: '0.375rem', - 2: '0.5rem', - 2.5: '0.625rem', - 3: '0.75rem', - 3.5: '0.875rem', - 4: '1rem', - 5: '1.25rem', - 6: '1.5rem', - 7: '1.75rem', - 8: '2rem', - 9: '2.25rem', - 10: '2.5rem', - 11: '2.75rem', - 12: '3rem', - 14: '3.5rem', - 16: '4rem', - 20: '5rem', - 24: '6rem', - 28: '7rem', - 32: '8rem', - 36: '9rem', - 40: '10rem', - 44: '11rem', - 48: '12rem', - 52: '13rem', - 56: '14rem', - 60: '15rem', - 64: '16rem', - 72: '18rem', - 80: '20rem', - 96: '24rem', - }, - stroke: ({ theme }) => ({ - none: 'none', - ...theme('colors'), - }), - strokeWidth: { - 0: '0', - 1: '1', - 2: '2', - }, - supports: {}, - data: {}, - textColor: ({ theme }) => theme('colors'), - textDecorationColor: ({ theme }) => theme('colors'), - textDecorationThickness: { - auto: 'auto', - 'from-font': 'from-font', - 0: '0px', - 1: '1px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - textIndent: ({ theme }) => ({ - ...theme('spacing'), - }), - textOpacity: ({ theme }) => theme('opacity'), - textUnderlineOffset: { - auto: 'auto', - 0: '0px', - 1: '1px', - 2: '2px', - 4: '4px', - 8: '8px', - }, - transformOrigin: { - center: 'center', - top: 'top', - 'top-right': 'top right', - right: 'right', - 'bottom-right': 'bottom right', - bottom: 'bottom', - 'bottom-left': 'bottom left', - left: 'left', - 'top-left': 'top left', - }, - transitionDelay: { - 0: '0s', - 75: '75ms', - 100: '100ms', - 150: '150ms', - 200: '200ms', - 300: '300ms', - 500: '500ms', - 700: '700ms', - 1000: '1000ms', - }, - transitionDuration: { - DEFAULT: '150ms', - 0: '0s', - 75: '75ms', - 100: '100ms', - 150: '150ms', - 200: '200ms', - 300: '300ms', - 500: '500ms', - 700: '700ms', - 1000: '1000ms', - }, - transitionProperty: { - none: 'none', - all: 'all', - DEFAULT: - 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter', - colors: 'color, background-color, border-color, text-decoration-color, fill, stroke', - opacity: 'opacity', - shadow: 'box-shadow', - transform: 'transform', - }, - transitionTimingFunction: { - DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)', - linear: 'linear', - in: 'cubic-bezier(0.4, 0, 1, 1)', - out: 'cubic-bezier(0, 0, 0.2, 1)', - 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)', - }, - translate: ({ theme }) => ({ - ...theme('spacing'), - '1/2': '50%', - '1/3': '33.333333%', - '2/3': '66.666667%', - '1/4': '25%', - '2/4': '50%', - '3/4': '75%', - full: '100%', - }), - size: ({ theme }) => ({ - auto: 'auto', - ...theme('spacing'), - '1/2': '50%', - '1/3': '33.333333%', - '2/3': '66.666667%', - '1/4': '25%', - '2/4': '50%', - '3/4': '75%', - '1/5': '20%', - '2/5': '40%', - '3/5': '60%', - '4/5': '80%', - '1/6': '16.666667%', - '2/6': '33.333333%', - '3/6': '50%', - '4/6': '66.666667%', - '5/6': '83.333333%', - '1/12': '8.333333%', - '2/12': '16.666667%', - '3/12': '25%', - '4/12': '33.333333%', - '5/12': '41.666667%', - '6/12': '50%', - '7/12': '58.333333%', - '8/12': '66.666667%', - '9/12': '75%', - '10/12': '83.333333%', - '11/12': '91.666667%', - full: '100%', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - }), - width: ({ theme }) => ({ - auto: 'auto', - ...theme('spacing'), - '1/2': '50%', - '1/3': '33.333333%', - '2/3': '66.666667%', - '1/4': '25%', - '2/4': '50%', - '3/4': '75%', - '1/5': '20%', - '2/5': '40%', - '3/5': '60%', - '4/5': '80%', - '1/6': '16.666667%', - '2/6': '33.333333%', - '3/6': '50%', - '4/6': '66.666667%', - '5/6': '83.333333%', - '1/12': '8.333333%', - '2/12': '16.666667%', - '3/12': '25%', - '4/12': '33.333333%', - '5/12': '41.666667%', - '6/12': '50%', - '7/12': '58.333333%', - '8/12': '66.666667%', - '9/12': '75%', - '10/12': '83.333333%', - '11/12': '91.666667%', - full: '100%', - screen: '100vw', - svw: '100svw', - lvw: '100lvw', - dvw: '100dvw', - min: 'min-content', - max: 'max-content', - fit: 'fit-content', - }), - willChange: { - auto: 'auto', - scroll: 'scroll-position', - contents: 'contents', - transform: 'transform', - }, - zIndex: { - auto: 'auto', - 0: '0', - 10: '10', - 20: '20', - 30: '30', - 40: '40', - 50: '50', - }, - }, - plugins: [], -} From b7bf7e8f49c7dd22583ab2f40d03e650826c3589 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 16 Aug 2024 12:03:11 -0400 Subject: [PATCH 19/20] wip --- packages/tailwindcss/src/plugin-api.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index 42b73d6d4..2177633e8 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -256,7 +256,7 @@ describe('theme', async () => { `) }) - test('wip about the DEFAULT key', async ({ expect }) => { + test('plugins can override the default key', async ({ expect }) => { let input = css` @tailwind utilities; @plugin "my-plugin"; @@ -311,7 +311,7 @@ describe('theme', async () => { return plugin(function ({ matchUtilities, theme }) { matchUtilities( { - animation: (value) => ({ '--animation': value }), + animation: (value) => ({ animation: value }), }, { values: theme('animation'), @@ -320,7 +320,7 @@ describe('theme', async () => { matchUtilities( { - animation2: (value) => ({ '--animation': value }), + animation2: (value) => ({ animation: value }), }, { values: { @@ -336,16 +336,16 @@ describe('theme', async () => { expect(compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist'])) .toMatchInlineSnapshot(` ".animation { - --animation: var(--animation, pulse 1s linear infinite); + animation: var(--animation, pulse 1s linear infinite); } .animation-spin { - --animation: var(--animation-spin, spin 1s linear infinite); + animation: var(--animation-spin, spin 1s linear infinite); } .animation2 { - --animation: var(--animation, pulse 1s linear infinite); + animation: var(--animation, pulse 1s linear infinite); } .animation2-twist { - --animation: var(--animation-spin, spin 1s linear infinite); + animation: var(--animation-spin, spin 1s linear infinite); } " `) From 40914a93a37f4992d072a39adf7a40bb20b6a5e2 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Fri, 16 Aug 2024 16:30:52 -0400 Subject: [PATCH 20/20] WIP --- packages/tailwindcss/src/compile.ts | 50 ++-- packages/tailwindcss/src/plugin-api.test.ts | 188 ++++++++++++- packages/tailwindcss/src/plugin-api.ts | 293 +++++++++++--------- packages/tailwindcss/src/sort.test.ts | 2 +- 4 files changed, 376 insertions(+), 157 deletions(-) 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 index 2177633e8..d6d07996b 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -1,6 +1,8 @@ -import { describe, test, vi } from 'vitest' +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 @@ -721,3 +723,187 @@ describe('theme', async () => { `) }) }) + +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 143765321..71399dba8 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -38,6 +38,20 @@ export type PluginAPI = { }>, ): 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/%._-]*$/ @@ -47,6 +61,143 @@ export function buildPluginApi( 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))) @@ -73,145 +224,11 @@ export function buildPluginApi( } }, - addUtilities(utilities) { - for (let [name, css] of Object.entries(utilities)) { - if (name.startsWith('@keyframes ')) { - ast.push(rule(name, objectToAst(css))) - continue - } + addUtilities, + matchUtilities, + addComponents: addUtilities, + matchComponents: matchUtilities, - 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 - }) - } - }, - - 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 - }) - } - }, theme(path) { let cssValue = readFromCss(designSystem.theme, path) 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]'],