From ec26eafbb48de13f9f318a6fdb3f7f5b14563a5b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 4 Jul 2024 17:31:58 -0400 Subject: [PATCH 1/5] Work on v3-compatible plugin API --- packages/tailwindcss/src/config.ts | 26 ++++ packages/tailwindcss/src/design-system.ts | 8 +- packages/tailwindcss/src/index.ts | 10 +- packages/tailwindcss/src/plugins.test.ts | 38 ++++++ packages/tailwindcss/src/plugins.ts | 151 +++++++++++++++++++++ packages/tailwindcss/src/test-utils/run.ts | 9 +- 6 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 packages/tailwindcss/src/config.ts create mode 100644 packages/tailwindcss/src/plugins.test.ts create mode 100644 packages/tailwindcss/src/plugins.ts 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..431d22bbe 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -1,13 +1,19 @@ 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 } { + let resolved = resolve(config ?? {}) + let ast = CSS.parse(css) if (process.env.NODE_ENV !== 'test') { @@ -125,7 +131,7 @@ export function compile(css: string): { firstThemeRule.nodes = nodes } - let designSystem = buildDesignSystem(theme) + let designSystem = buildDesignSystem(theme, resolved) let tailwindUtilitiesNode: Rule | null = null diff --git a/packages/tailwindcss/src/plugins.test.ts b/packages/tailwindcss/src/plugins.test.ts new file mode 100644 index 000000000..92c4dceb3 --- /dev/null +++ b/packages/tailwindcss/src/plugins.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest' +import type { UserConfig } from './config' +import { run } from './test-utils/run' + +test('Custom static utilities', () => { + let config: UserConfig = { + plugins: [ + ({ addUtilities }) => { + addUtilities({ + '.my-red': { + color: 'red', + }, + '.my-blue': { + color: 'red', + }, + '.my-fn': (value, { modifier }) => ({ + color: modifier ? `${value ?? ''} / ${modifier ?? ''}` : `${value ?? ''}`, + }), + }) + }, + ], + } + + expect(run(['my-red', 'my-fn-[--value]', 'my-fn-[--value]/25'], config)).toMatchInlineSnapshot(` + ".my-fn-\\[--value\\] { + color: var(--value); + } + + .my-fn-\\[--value\\]\\/25 { + color: var(--value) / 25; + } + + .my-red { + color: red; + }" + `) + expect(run(['-my-red', 'my-red-[--value]'], config)).toEqual('') +}) diff --git a/packages/tailwindcss/src/plugins.ts b/packages/tailwindcss/src/plugins.ts new file mode 100644 index 000000000..fa6a73e38 --- /dev/null +++ b/packages/tailwindcss/src/plugins.ts @@ -0,0 +1,151 @@ +import { decl, rule, type AstNode, type Rule } from './ast' +import type { Candidate } from './candidate' +import type { DesignSystem } from './design-system' + +type MatchData = { + value: string | null + modifier: string | null +} + +type CssSelectors = string | string[] + +export interface CssTree extends Record {} + +type SelectorFormat = CssSelectors | ((candidate: MatchData) => CssSelectors) +type UtilityFormat = CssTree | ((value: string | null, candidate: MatchData) => CssTree) + +export interface PluginAPI { + addVariant(name: string, selector: SelectorFormat): void + addUtilities(utilities: Record): void +} + +export interface Plugin { + (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 }, + ) + } + + for (let plugin of design.plugins) { + plugin({ + // v3 compatible legacy API + addUtilities, + addVariant, + }) + } +} diff --git a/packages/tailwindcss/src/test-utils/run.ts b/packages/tailwindcss/src/test-utils/run.ts index d360f2eaa..899fd34e9 100644 --- a/packages/tailwindcss/src/test-utils/run.ts +++ b/packages/tailwindcss/src/test-utils/run.ts @@ -1,12 +1,13 @@ import { Features, transform } from 'lightningcss' import { compile } from '..' +import { type UserConfig } from '../config' -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 function optimizeCss( From b6426b4f5771c5f4c8f81e9d0a2415ce7c0d2339 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 4 Jul 2024 18:11:07 -0400 Subject: [PATCH 2/5] wip --- packages/tailwindcss/src/plugins.test.ts | 68 +++++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/tailwindcss/src/plugins.test.ts b/packages/tailwindcss/src/plugins.test.ts index 92c4dceb3..7212be371 100644 --- a/packages/tailwindcss/src/plugins.test.ts +++ b/packages/tailwindcss/src/plugins.test.ts @@ -13,6 +13,24 @@ test('Custom static utilities', () => { '.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', () => { + let config: UserConfig = { + plugins: [ + ({ addUtilities }) => { + addUtilities({ '.my-fn': (value, { modifier }) => ({ color: modifier ? `${value ?? ''} / ${modifier ?? ''}` : `${value ?? ''}`, }), @@ -21,18 +39,54 @@ test('Custom static utilities', () => { ], } - expect(run(['my-red', 'my-fn-[--value]', 'my-fn-[--value]/25'], config)).toMatchInlineSnapshot(` - ".my-fn-\\[--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; - } - - .my-red { - color: red; }" `) - expect(run(['-my-red', 'my-red-[--value]'], config)).toEqual('') +}) + +test('Custom static variants', () => { + 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', () => { + 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('') }) From 103de984eb05d2a5e9b8aa62b04f364d98a759c4 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 4 Jul 2024 19:39:43 -0400 Subject: [PATCH 3/5] wip --- packages/tailwindcss/src/plugins.test.ts | 60 ++++++++++++++++++++++-- packages/tailwindcss/src/plugins.ts | 34 ++++++++++---- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/tailwindcss/src/plugins.test.ts b/packages/tailwindcss/src/plugins.test.ts index 7212be371..274e4a829 100644 --- a/packages/tailwindcss/src/plugins.test.ts +++ b/packages/tailwindcss/src/plugins.test.ts @@ -1,14 +1,64 @@ -import { expect, test } from 'vitest' +import { test, vi } from 'vitest' import type { UserConfig } from './config' import { run } from './test-utils/run' -test('Custom static utilities', () => { +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', @@ -26,7 +76,7 @@ test('Custom static utilities', () => { expect(run(['-my-red', 'my-red-[--value]'], config)).toEqual('') }) -test('Custom functional utilities', () => { +test('Custom functional utilities', ({ expect }) => { let config: UserConfig = { plugins: [ ({ addUtilities }) => { @@ -54,7 +104,7 @@ test('Custom functional utilities', () => { `) }) -test('Custom static variants', () => { +test('Custom static variants', ({ expect }) => { let config: UserConfig = { plugins: [ ({ addVariant }) => { @@ -72,7 +122,7 @@ test('Custom static variants', () => { expect(run(['hover/foo:underline', 'hover-[123]:underline'], config)).toEqual('') }) -test('Custom functional variants', () => { +test('Custom functional variants', ({ expect }) => { let config: UserConfig = { plugins: [ ({ addVariant }) => { diff --git a/packages/tailwindcss/src/plugins.ts b/packages/tailwindcss/src/plugins.ts index fa6a73e38..ce06ed16a 100644 --- a/packages/tailwindcss/src/plugins.ts +++ b/packages/tailwindcss/src/plugins.ts @@ -2,27 +2,35 @@ import { decl, rule, type AstNode, type Rule } from './ast' import type { Candidate } from './candidate' import type { DesignSystem } from './design-system' -type MatchData = { +export type MatchData = { value: string | null modifier: string | null } -type CssSelectors = string | string[] +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 {} -type SelectorFormat = CssSelectors | ((candidate: MatchData) => CssSelectors) -type UtilityFormat = CssTree | ((value: string | null, candidate: MatchData) => CssTree) - export interface PluginAPI { - addVariant(name: string, selector: SelectorFormat): void + addVariant(name: string, format: SelectorFormat): void + addVariants(variants: Record): void + + addUtility(name: string, format: UtilityFormat): void addUtilities(utilities: Record): void } -export interface Plugin { +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 @@ -141,11 +149,21 @@ export function registerPlugins(design: DesignSystem) { ) } + function addVariants(list: Record) { + for (let [name, format] of Object.entries(list)) { + addVariant(name, format) + } + } + for (let plugin of design.plugins) { - plugin({ + let handler = typeof plugin === 'function' ? plugin : plugin.handler + + handler({ // v3 compatible legacy API + addUtility, addUtilities, addVariant, + addVariants, }) } } From fdd26011f1186cf6ebc753fcf0c42896c207bb34 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 4 Jul 2024 20:04:23 -0400 Subject: [PATCH 4/5] Add mechanism to return registered plugins from CSS --- packages/tailwindcss/src/index.ts | 19 ++++++++++++++++++- packages/tailwindcss/src/plugins.test.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 431d22bbe..8817c5de7 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -11,8 +11,10 @@ export function compile( config?: UserConfig, ): { build(candidates: string[]): string + readonly plugins: string[] } { let resolved = resolve(config ?? {}) + let plugins: string[] = [] let ast = CSS.parse(css) @@ -53,6 +55,20 @@ export function compile( 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' @@ -194,6 +210,7 @@ export function compile( let previousAstNodeCount = 0 return { + plugins, build(newRawCandidates: string[]) { let didChange = false @@ -254,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 index 274e4a829..6d1075a93 100644 --- a/packages/tailwindcss/src/plugins.test.ts +++ b/packages/tailwindcss/src/plugins.test.ts @@ -1,7 +1,10 @@ import { test, vi } from 'vitest' +import { compile } from '.' import type { UserConfig } from './config' import { run } from './test-utils/run' +const css = String.raw + test('Plugins are called', ({ expect }) => { let fn = vi.fn() let handler = vi.fn() @@ -140,3 +143,12 @@ test('Custom functional variants', ({ expect }) => { `) 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']) +}) From 894d540deb412bae073f9545b14be96f13c0a0ec Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 4 Jul 2024 20:24:16 -0400 Subject: [PATCH 5/5] Add stub test for registering plugins in CSS --- packages/tailwindcss/src/plugins.test.ts | 25 +++++++++++++++++++++- packages/tailwindcss/src/test-utils/run.ts | 17 +++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/plugins.test.ts b/packages/tailwindcss/src/plugins.test.ts index 6d1075a93..b8662db8c 100644 --- a/packages/tailwindcss/src/plugins.test.ts +++ b/packages/tailwindcss/src/plugins.test.ts @@ -1,7 +1,8 @@ import { test, vi } from 'vitest' import { compile } from '.' import type { UserConfig } from './config' -import { run } from './test-utils/run' +import type { Plugin } from './plugins' +import { compileWithPlugins, run } from './test-utils/run' const css = String.raw @@ -152,3 +153,25 @@ test('Plugins registered in CSS can be returned', ({ expect }) => { 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/test-utils/run.ts b/packages/tailwindcss/src/test-utils/run.ts index 899fd34e9..af0cfea05 100644 --- a/packages/tailwindcss/src/test-utils/run.ts +++ b/packages/tailwindcss/src/test-utils/run.ts @@ -1,6 +1,7 @@ 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[] = [], config: UserConfig = {}) { return optimizeCss(compile(css, config).build(candidates)).trim() @@ -10,6 +11,22 @@ 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( input: string, { file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},