diff --git a/packages/tailwindcss/src/config.ts b/packages/tailwindcss/src/config.ts new file mode 100644 index 000000000..74d56ff7d --- /dev/null +++ b/packages/tailwindcss/src/config.ts @@ -0,0 +1,26 @@ +import type { Plugin } from './plugins' + +export interface UserConfig { + /** + * A list of registered plugins + */ + plugins?: Plugin[] +} + +export interface ResolvedConfig { + /** + * A list of registered plugins + */ + plugins: Plugin[] +} + +export function resolve(config: UserConfig) { + let defaults: ResolvedConfig = { + plugins: [], + } + + return { + ...defaults, + ...config, + } +} diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index bebd82a33..dcfe13861 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -1,7 +1,9 @@ import { toCss } from './ast' import { parseCandidate, parseVariant } from './candidate' import { compileAstNodes, compileCandidates } from './compile' +import type { ResolvedConfig } from './config' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' +import { registerPlugins, type Plugin } from './plugins' import { getClassOrder } from './sort' import type { Theme } from './theme' import { Utilities, createUtilities } from './utilities' @@ -12,6 +14,7 @@ export type DesignSystem = { theme: Theme utilities: Utilities variants: Variants + plugins: Plugin[] candidatesToCss(classes: string[]): (string | null)[] getClassOrder(classes: string[]): [string, bigint | null][] @@ -25,7 +28,7 @@ export type DesignSystem = { getUsedVariants(): ReturnType[] } -export function buildDesignSystem(theme: Theme): DesignSystem { +export function buildDesignSystem(theme: Theme, config: ResolvedConfig): DesignSystem { let utilities = createUtilities(theme) let variants = createVariants(theme) @@ -37,6 +40,7 @@ export function buildDesignSystem(theme: Theme): DesignSystem { theme, utilities, variants, + plugins: config.plugins, candidatesToCss(classes: string[]) { let result: (string | null)[] = [] @@ -77,5 +81,7 @@ export function buildDesignSystem(theme: Theme): DesignSystem { }, } + registerPlugins(designSystem) + return designSystem } diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 34a095584..8817c5de7 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -1,13 +1,21 @@ import { version } from '../package.json' import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast' import { compileCandidates } from './compile' +import { resolve, type UserConfig } from './config' import * as CSS from './css-parser' import { buildDesignSystem } from './design-system' import { Theme } from './theme' -export function compile(css: string): { +export function compile( + css: string, + config?: UserConfig, +): { build(candidates: string[]): string + readonly plugins: string[] } { + let resolved = resolve(config ?? {}) + let plugins: string[] = [] + let ast = CSS.parse(css) if (process.env.NODE_ENV !== 'test') { @@ -47,6 +55,20 @@ export function compile(css: string): { replaceWith(node.nodes) } + if (node.selector.startsWith('@plugin')) { + let path = node.selector + // Ignore `@plugin` when parsing the selector + .slice(7) + // Remove any surrounding spaces + .trim() + // Remove the surrounding quotes + .slice(1, -1) + + plugins.push(path) + replaceWith([]) + return WalkAction.Skip + } + if (node.selector !== '@theme' && node.selector !== '@theme reference') return let isReference = node.selector === '@theme reference' @@ -125,7 +147,7 @@ export function compile(css: string): { firstThemeRule.nodes = nodes } - let designSystem = buildDesignSystem(theme) + let designSystem = buildDesignSystem(theme, resolved) let tailwindUtilitiesNode: Rule | null = null @@ -188,6 +210,7 @@ export function compile(css: string): { let previousAstNodeCount = 0 return { + plugins, build(newRawCandidates: string[]) { let didChange = false @@ -248,5 +271,5 @@ export function __unstable__loadDesignSystem(css: string) { }) }) - return buildDesignSystem(theme) + return buildDesignSystem(theme, resolve({})) } diff --git a/packages/tailwindcss/src/plugins.test.ts b/packages/tailwindcss/src/plugins.test.ts new file mode 100644 index 000000000..b8662db8c --- /dev/null +++ b/packages/tailwindcss/src/plugins.test.ts @@ -0,0 +1,177 @@ +import { test, vi } from 'vitest' +import { compile } from '.' +import type { UserConfig } from './config' +import type { Plugin } from './plugins' +import { compileWithPlugins, run } from './test-utils/run' + +const css = String.raw + +test('Plugins are called', ({ expect }) => { + let fn = vi.fn() + let handler = vi.fn() + let config: UserConfig = { + plugins: [fn, { handler }], + } + + run([], config) + + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + addUtility: expect.any(Function), + addUtilities: expect.any(Function), + addVariant: expect.any(Function), + addVariants: expect.any(Function), + }), + ) + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + addUtility: expect.any(Function), + addUtilities: expect.any(Function), + addVariant: expect.any(Function), + addVariants: expect.any(Function), + }), + ) +}) + +test('Asynchronous plugins are waited on', async ({ expect }) => { + let before = vi.fn() + let after = vi.fn() + let config: UserConfig = { + plugins: [ + async () => { + before() + await new Promise((resolve) => setTimeout(resolve)) + after() + }, + ], + } + + await run([], config) + + expect(before).toHaveBeenCalled() + expect(after).toHaveBeenCalled() +}) + +test('Custom static utilities', ({ expect }) => { + let config: UserConfig = { + plugins: [ + ({ addUtilities }) => { + addUtilities({ + '.my-red': { + color: 'red', + '& > *': { + color: 'lightred', + }, + }, + '.my-blue': { + color: 'red', + }, + }) + }, + ], + } + + expect(run(['my-red'], config)).toMatchInlineSnapshot(` + ".my-red { + color: red; + }" + `) + expect(run(['-my-red', 'my-red-[--value]'], config)).toEqual('') +}) + +test('Custom functional utilities', ({ expect }) => { + let config: UserConfig = { + plugins: [ + ({ addUtilities }) => { + addUtilities({ + '.my-fn': (value, { modifier }) => ({ + color: modifier ? `${value ?? ''} / ${modifier ?? ''}` : `${value ?? ''}`, + }), + }) + }, + ], + } + + expect(run(['my-fn', 'my-fn-[--value]', 'my-fn-[--value]/25'], config)).toMatchInlineSnapshot(` + ".my-fn { + color: ; + } + + .my-fn-\\[--value\\] { + color: var(--value); + } + + .my-fn-\\[--value\\]\\/25 { + color: var(--value) / 25; + }" + `) +}) + +test('Custom static variants', ({ expect }) => { + let config: UserConfig = { + plugins: [ + ({ addVariant }) => { + addVariant('hocus', ['&:hover', '&:focus']) + addVariant('hactive', ['&:hover', '&:active']) + }, + ], + } + + expect(run(['hocus:underline'], config)).toMatchInlineSnapshot(` + ".hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + }" + `) + expect(run(['hover/foo:underline', 'hover-[123]:underline'], config)).toEqual('') +}) + +test('Custom functional variants', ({ expect }) => { + let config: UserConfig = { + plugins: [ + ({ addVariant }) => { + addVariant('in', ({ value, modifier }) => { + return modifier ? [] : value ? `&:is(${value} *)` : [] + }) + }, + ], + } + + expect(run(['in-[:checked]:underline'], config)).toMatchInlineSnapshot(` + ".in-\\[\\:checked\\]\\:underline:is(:checked *) { + text-decoration-line: underline; + }" + `) + expect(run(['in-[:checked]/foo:underline'], config)).toEqual('') +}) + +test('Plugins registered in CSS can be returned', ({ expect }) => { + let builder = compile(css` + @plugin "my-plugin"; + @plugin "./my-plugin"; + `) + + expect(builder.plugins).toEqual(['my-plugin', './my-plugin']) +}) + +test.only('Plugins registered in CSS can be used', async ({ expect }) => { + vi.doMock('my-plugin', () => { + let plugin: Plugin = ({ addVariant }) => { + addVariant('hocus', ['&:hover', '&:focus']) + } + + return { + default: plugin, + } + }) + + let result = await compileWithPlugins('@plugin "my-plugin"; @tailwind utilities;', [ + 'hocus:underline', + ]) + + expect(result).toMatchInlineSnapshot(` + ".hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + }" + `) +}) diff --git a/packages/tailwindcss/src/plugins.ts b/packages/tailwindcss/src/plugins.ts new file mode 100644 index 000000000..ce06ed16a --- /dev/null +++ b/packages/tailwindcss/src/plugins.ts @@ -0,0 +1,169 @@ +import { decl, rule, type AstNode, type Rule } from './ast' +import type { Candidate } from './candidate' +import type { DesignSystem } from './design-system' + +export type MatchData = { + value: string | null + modifier: string | null +} + +export type SelectorFormat = CssSelectors | ((candidate: MatchData) => CssSelectors) +export type UtilityFormat = CssTree | ((value: string | null, candidate: MatchData) => CssTree) + +export type CssSelectors = string | string[] +export interface CssTree extends Record {} + +export interface PluginAPI { + addVariant(name: string, format: SelectorFormat): void + addVariants(variants: Record): void + + addUtility(name: string, format: UtilityFormat): void + addUtilities(utilities: Record): void +} + +export type Plugin = FunctionalPlugin | ConfigurablePlugin + +export interface FunctionalPlugin { + (api: PluginAPI): void | Promise +} + +export interface ConfigurablePlugin { + handler(api: PluginAPI): void | Promise +} + +/** + * Convert a recursive object structure to an AST + * Leaf nodes are declarations and branches are rules + **/ +function treeToAst(props: CssTree): AstNode[] { + let ast: AstNode[] = [] + + for (let [name, value] of Object.entries(props)) { + if (typeof value === 'string') { + if (value.startsWith('@')) { + ast.push(rule(name, [])) + } else { + ast.push(decl(name, value)) + } + } else { + ast.push(rule(name, treeToAst(value))) + } + } + + return ast +} + +/** + * Convert a recursive object structure to an AST + * Leaf nodes are declarations and branches are rules + **/ +function selectorsToAst(r: Rule, selectors: CssSelectors): AstNode[] { + if (typeof selectors === 'string') { + return [rule(selectors, r.nodes)] + } + + return selectors.map((selector) => rule(selector, r.nodes)) +} + +/** + * Convert a recursive object structure to an AST + * Leaf nodes are declarations and branches are rules + **/ +function utilityToAst( + candidate: Extract, + props: UtilityFormat, +): AstNode[] { + if (candidate.kind === 'static') { + if (candidate.negative) { + return [] + } + + if (typeof props === 'object') { + return treeToAst(props) + } + + return [] + } + + if (typeof props === 'object') { + return treeToAst(props) + } + + let match = { + value: candidate.value?.value ?? null, + modifier: candidate.modifier?.value ?? null, + } + + return treeToAst(props(match.value, match)) +} + +export function registerPlugins(design: DesignSystem) { + let { utilities, variants } = design + + function addUtility(name: string, format: UtilityFormat) { + if (name.startsWith('.')) { + name = name.slice(1) + } + + if (typeof format === 'object') { + utilities.static(name, (candidate) => utilityToAst(candidate, format)) + + return + } + + utilities.functional(name, (candidate) => utilityToAst(candidate, format)) + } + + function addUtilities(list: Record) { + for (let [className, format] of Object.entries(list)) { + addUtility(className, format) + } + } + + function addVariant(name: string, format: SelectorFormat) { + if (typeof format === 'string' || Array.isArray(format)) { + variants.static( + name, + (r) => { + r.nodes = selectorsToAst(r, format) + }, + { compounds: true }, + ) + + return + } + + variants.functional( + name, + (r, variant) => { + let match = { + value: variant.value?.value ?? null, + modifier: variant.modifier?.value ?? null, + } + + let resolved = format(match) + + r.nodes = selectorsToAst(r, resolved) + }, + { compounds: true }, + ) + } + + function addVariants(list: Record) { + for (let [name, format] of Object.entries(list)) { + addVariant(name, format) + } + } + + for (let plugin of design.plugins) { + let handler = typeof plugin === 'function' ? plugin : plugin.handler + + handler({ + // v3 compatible legacy API + addUtility, + addUtilities, + addVariant, + addVariants, + }) + } +} diff --git a/packages/tailwindcss/src/test-utils/run.ts b/packages/tailwindcss/src/test-utils/run.ts index d360f2eaa..af0cfea05 100644 --- a/packages/tailwindcss/src/test-utils/run.ts +++ b/packages/tailwindcss/src/test-utils/run.ts @@ -1,12 +1,30 @@ import { Features, transform } from 'lightningcss' import { compile } from '..' +import { type UserConfig } from '../config' +import type { Plugin } from '../plugins' -export function compileCss(css: string, candidates: string[] = []) { - return optimizeCss(compile(css).build(candidates)).trim() +export function compileCss(css: string, candidates: string[] = [], config: UserConfig = {}) { + return optimizeCss(compile(css, config).build(candidates)).trim() } -export function run(candidates: string[]) { - return optimizeCss(compile('@tailwind utilities;').build(candidates)).trim() +export function run(candidates: string[], config: UserConfig = {}) { + return optimizeCss(compile('@tailwind utilities;', config).build(candidates)).trim() +} + +export async function compileWithPlugins( + css: string, + candidates: string[], + config: UserConfig = {}, +) { + let builder = compile(css, config) + + let plugins: Plugin[] = await Promise.all( + builder.plugins.map((pluginPath) => import(pluginPath).then((mod) => mod.default)), + ) + + config.plugins = [...(config.plugins ?? []), ...plugins] + + return optimizeCss(builder.build(candidates)).trim() } export function optimizeCss(