Work on v3-compatible plugin API

This commit is contained in:
Jordan Pittman 2024-07-04 17:31:58 -04:00
parent 06e96e0767
commit ec26eafbb4
6 changed files with 235 additions and 7 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 { 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<typeof parseVariant>[]
}
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
}

View file

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

View file

@ -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('')
})

View file

@ -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<string, string | CssTree> {}
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<string, UtilityFormat>): void
}
export interface Plugin {
(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 },
)
}
for (let plugin of design.plugins) {
plugin({
// v3 compatible legacy API
addUtilities,
addVariant,
})
}
}

View file

@ -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(