Compare commits
5 commits
main
...
feat/v4-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894d540deb | ||
|
|
fdd26011f1 | ||
|
|
103de984eb | ||
|
|
b6426b4f57 | ||
|
|
ec26eafbb4 |
6 changed files with 427 additions and 8 deletions
26
packages/tailwindcss/src/config.ts
Normal file
26
packages/tailwindcss/src/config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({}))
|
||||
}
|
||||
|
|
|
|||
177
packages/tailwindcss/src/plugins.test.ts
Normal file
177
packages/tailwindcss/src/plugins.test.ts
Normal 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;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
169
packages/tailwindcss/src/plugins.ts
Normal file
169
packages/tailwindcss/src/plugins.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue