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) + } }