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(