Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Jordan Pittman
894d540deb Add stub test for registering plugins in CSS 2024-07-04 20:24:16 -04:00
Jordan Pittman
fdd26011f1 Add mechanism to return registered plugins from CSS 2024-07-04 20:04:23 -04:00
Jordan Pittman
103de984eb wip 2024-07-04 19:39:43 -04:00
Jordan Pittman
b6426b4f57 wip 2024-07-04 18:11:07 -04:00
Jordan Pittman
ec26eafbb4 Work on v3-compatible plugin API 2024-07-04 17:57:39 -04:00
6 changed files with 427 additions and 8 deletions

View file

@ -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,
}
}

View file

@ -1,7 +1,9 @@
import { toCss } from './ast' import { toCss } from './ast'
import { parseCandidate, parseVariant } from './candidate' import { parseCandidate, parseVariant } from './candidate'
import { compileAstNodes, compileCandidates } from './compile' import { compileAstNodes, compileCandidates } from './compile'
import type { ResolvedConfig } from './config'
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
import { registerPlugins, type Plugin } from './plugins'
import { getClassOrder } from './sort' import { getClassOrder } from './sort'
import type { Theme } from './theme' import type { Theme } from './theme'
import { Utilities, createUtilities } from './utilities' import { Utilities, createUtilities } from './utilities'
@ -12,6 +14,7 @@ export type DesignSystem = {
theme: Theme theme: Theme
utilities: Utilities utilities: Utilities
variants: Variants variants: Variants
plugins: Plugin[]
candidatesToCss(classes: string[]): (string | null)[] candidatesToCss(classes: string[]): (string | null)[]
getClassOrder(classes: string[]): [string, bigint | null][] getClassOrder(classes: string[]): [string, bigint | null][]
@ -25,7 +28,7 @@ export type DesignSystem = {
getUsedVariants(): ReturnType<typeof parseVariant>[] getUsedVariants(): ReturnType<typeof parseVariant>[]
} }
export function buildDesignSystem(theme: Theme): DesignSystem { export function buildDesignSystem(theme: Theme, config: ResolvedConfig): DesignSystem {
let utilities = createUtilities(theme) let utilities = createUtilities(theme)
let variants = createVariants(theme) let variants = createVariants(theme)
@ -37,6 +40,7 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
theme, theme,
utilities, utilities,
variants, variants,
plugins: config.plugins,
candidatesToCss(classes: string[]) { candidatesToCss(classes: string[]) {
let result: (string | null)[] = [] let result: (string | null)[] = []
@ -77,5 +81,7 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
}, },
} }
registerPlugins(designSystem)
return designSystem return designSystem
} }

View file

@ -1,13 +1,21 @@
import { version } from '../package.json' import { version } from '../package.json'
import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast' import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast'
import { compileCandidates } from './compile' import { compileCandidates } from './compile'
import { resolve, type UserConfig } from './config'
import * as CSS from './css-parser' import * as CSS from './css-parser'
import { buildDesignSystem } from './design-system' import { buildDesignSystem } from './design-system'
import { Theme } from './theme' import { Theme } from './theme'
export function compile(css: string): { export function compile(
css: string,
config?: UserConfig,
): {
build(candidates: string[]): string build(candidates: string[]): string
readonly plugins: string[]
} { } {
let resolved = resolve(config ?? {})
let plugins: string[] = []
let ast = CSS.parse(css) let ast = CSS.parse(css)
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
@ -47,6 +55,20 @@ export function compile(css: string): {
replaceWith(node.nodes) 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 if (node.selector !== '@theme' && node.selector !== '@theme reference') return
let isReference = node.selector === '@theme reference' let isReference = node.selector === '@theme reference'
@ -125,7 +147,7 @@ export function compile(css: string): {
firstThemeRule.nodes = nodes firstThemeRule.nodes = nodes
} }
let designSystem = buildDesignSystem(theme) let designSystem = buildDesignSystem(theme, resolved)
let tailwindUtilitiesNode: Rule | null = null let tailwindUtilitiesNode: Rule | null = null
@ -188,6 +210,7 @@ export function compile(css: string): {
let previousAstNodeCount = 0 let previousAstNodeCount = 0
return { return {
plugins,
build(newRawCandidates: string[]) { build(newRawCandidates: string[]) {
let didChange = false let didChange = false
@ -248,5 +271,5 @@ export function __unstable__loadDesignSystem(css: string) {
}) })
}) })
return buildDesignSystem(theme) return buildDesignSystem(theme, resolve({}))
} }

View file

@ -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;
}"
`)
})

View file

@ -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<string, string | CssTree> {}
export interface PluginAPI {
addVariant(name: string, format: SelectorFormat): void
addVariants(variants: Record<string, SelectorFormat>): void
addUtility(name: string, format: UtilityFormat): void
addUtilities(utilities: Record<string, UtilityFormat>): void
}
export type Plugin = FunctionalPlugin | ConfigurablePlugin
export interface FunctionalPlugin {
(api: PluginAPI): void | Promise<void>
}
export interface ConfigurablePlugin {
handler(api: PluginAPI): void | Promise<void>
}
/**
* 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<Candidate, { kind: 'functional' | 'static' }>,
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<string, UtilityFormat>) {
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<string, SelectorFormat>) {
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,
})
}
}

View file

@ -1,12 +1,30 @@
import { Features, transform } from 'lightningcss' import { Features, transform } from 'lightningcss'
import { compile } from '..' import { compile } from '..'
import { type UserConfig } from '../config'
import type { Plugin } from '../plugins'
export function compileCss(css: string, candidates: string[] = []) { export function compileCss(css: string, candidates: string[] = [], config: UserConfig = {}) {
return optimizeCss(compile(css).build(candidates)).trim() return optimizeCss(compile(css, config).build(candidates)).trim()
} }
export function run(candidates: string[]) { export function run(candidates: string[], config: UserConfig = {}) {
return optimizeCss(compile('@tailwind utilities;').build(candidates)).trim() 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( export function optimizeCss(