Add support for the tailwindcss/plugin export (#14173)

This PR adds support for the `tailwindcss/plugin` import which has
historically been used to define custom plugins:

```js
import plugin from "tailwindcss/plugin";

export default plugin(function ({ addBase }) {
  addBase({
    // ...
  });
});
```

This also adds support for `plugin.withOptions` which was used to define
plugins that took optional initilization options when they were
registered in your `tailwind.config.js` file:

```js
import plugin from "tailwindcss/plugin";

export default plugin.withOptions((options = {}) => {
  return function ({ addBase }) {
    addBase({
      // ...
    });
  };
});
```

We've stubbed out support for the `config` argument but we're not
actually doing anything with it at the time of this PR. The scope of
this PR is just to allow people to create plugins that currently work
using the raw function syntax but using the `plugin` and
`plugin.withOptions` APIs. Support for `config` will land separately.

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
This commit is contained in:
Jordan Pittman 2024-08-13 10:25:29 -04:00 committed by GitHub
parent 9ab47329b8
commit e299ea381f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 191 additions and 39 deletions

View File

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add support for `addBase` plugins using the `@plugin` directive ([#14172](https://github.com/tailwindlabs/tailwindcss/pull/14172))
- Add support for the `tailwindcss/plugin` export ([#14173](https://github.com/tailwindlabs/tailwindcss/pull/14173))
## [4.0.0-alpha.19] - 2024-08-09

View File

@ -23,6 +23,10 @@
"require": "./dist/lib.js",
"import": "./src/index.ts"
},
"./plugin": {
"require": "./src/plugin.cts",
"import": "./src/plugin.ts"
},
"./package.json": "./package.json",
"./index.css": "./index.css",
"./index": "./index.css",
@ -43,6 +47,10 @@
"require": "./dist/lib.js",
"import": "./dist/lib.mjs"
},
"./plugin": {
"require": "./dist/plugin.js",
"import": "./src/plugin.mjs"
},
"./package.json": "./package.json",
"./index.css": "./index.css",
"./index": "./index.css",

View File

@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it, test } from 'vitest'
import { compile } from '.'
import type { PluginAPI } from './plugin-api'
import { compileCss, optimizeCss, run } from './test-utils/run'
const css = String.raw
@ -1299,7 +1300,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
@ -1317,7 +1318,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
@ -1335,7 +1336,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
@ -1366,7 +1367,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', ['&:hover', '&:focus'])
}
},
@ -1398,7 +1399,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'&:hover': '@slot',
'&:focus': '@slot',
@ -1432,7 +1433,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'@media (hover: hover)': {
'&:hover': '@slot',
@ -1480,7 +1481,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'&': {
'--custom-property': '@slot',
@ -1518,7 +1519,7 @@ describe('plugins', () => {
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('dark', '&:is([data-theme=dark] *)')
}
},
@ -2087,7 +2088,7 @@ test('addBase', async () => {
{
loadPlugin: async () => {
return ({ addBase }) => {
return ({ addBase }: PluginAPI) => {
addBase({
body: {
'font-feature-settings': '"tnum"',

View File

@ -4,14 +4,12 @@ import { WalkAction, comment, decl, rule, toCss, walk, type Rule } from './ast'
import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { buildPluginApi, type PluginAPI } from './plugin-api'
import { registerPlugins, type Plugin } from './plugin-api'
import { Theme } from './theme'
import { segment } from './utils/segment'
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type Plugin = (api: PluginAPI) => void
type CompileOptions = {
loadPlugin?: (path: string) => Promise<Plugin>
}
@ -40,7 +38,7 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
// Find all `@theme` declarations
let theme = new Theme()
let pluginLoaders: Promise<Plugin>[] = []
let pluginPaths: string[] = []
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
@ -60,7 +58,7 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
throw new Error('`@plugin` cannot be nested.')
}
pluginLoaders.push(loadPlugin(node.selector.slice(9, -1)))
pluginPaths.push(node.selector.slice(9, -1))
replaceWith([])
return
}
@ -281,9 +279,9 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti
customUtility(designSystem)
}
let pluginApi = buildPluginApi(designSystem, ast)
let plugins = await Promise.all(pluginPaths.map(loadPlugin))
await Promise.all(pluginLoaders.map((loader) => loader.then((plugin) => plugin(pluginApi))))
registerPlugins(plugins, designSystem, ast)
// Replace `@apply` rules with the actual utility classes.
if (css.includes('@apply')) {

View File

@ -4,6 +4,17 @@ import type { DesignSystem } from './design-system'
import { withAlpha, withNegative } from './utilities'
import { inferDataType } from './utils/infer-data-type'
export type Config = Record<string, any>
export type PluginFn = (api: PluginAPI) => void
export type PluginWithConfig = { handler: PluginFn; config?: Partial<Config> }
export type PluginWithOptions<T> = {
(options?: T): PluginWithConfig
__isOptionsFunction: true
}
export type Plugin = PluginFn | PluginWithConfig | PluginWithOptions<any>
export type PluginAPI = {
addBase(base: CssInJs): void
addVariant(name: string, variant: string | string[] | CssInJs): void
@ -177,3 +188,25 @@ export function buildPluginApi(designSystem: DesignSystem, ast: AstNode[]): Plug
},
}
}
export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) {
let pluginApi = buildPluginApi(designSystem, ast)
for (let plugin of plugins) {
if ('__isOptionsFunction' in plugin) {
// Happens with `plugin.withOptions()` when no options were passed:
// e.g. `require("my-plugin")` instead of `require("my-plugin")(options)`
plugin().handler(pluginApi)
} else if ('handler' in plugin) {
// Happens with `plugin(…)`:
// e.g. `require("my-plugin")`
//
// or with `plugin.withOptions()` when the user passed options:
// e.g. `require("my-plugin")(options)`
plugin.handler(pluginApi)
} else {
// Just a plain function without using the plugin(…) API
plugin(pluginApi)
}
}
}

View File

@ -0,0 +1,5 @@
// This file exists so that `plugin.ts` can be written one time but be compatible with both CJS and
// ESM. Without it we get a `.default` export when using `require` in CJS.
// @ts-ignore
module.exports = require('./plugin.ts').default

View File

@ -0,0 +1,61 @@
import { test } from 'vitest'
import { compile } from '.'
import plugin from './plugin'
const css = String.raw
test('plugin', async ({ expect }) => {
let input = css`
@plugin "my-plugin";
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin(function ({ addBase }) {
addBase({
body: {
margin: '0',
},
})
})
},
})
expect(compiler.build([])).toMatchInlineSnapshot(`
"@layer base {
body {
margin: 0;
}
}
"
`)
})
test('plugin.withOptions', async ({ expect }) => {
let input = css`
@plugin "my-plugin";
`
let compiler = await compile(input, {
loadPlugin: async () => {
return plugin.withOptions(function (opts = { foo: '1px' }) {
return function ({ addBase }) {
addBase({
body: {
margin: opts.foo,
},
})
}
})
},
})
expect(compiler.build([])).toMatchInlineSnapshot(`
"@layer base {
body {
margin: 1px;
}
}
"
`)
})

View File

@ -0,0 +1,26 @@
import type { Config, PluginFn, PluginWithConfig, PluginWithOptions } from './plugin-api'
function createPlugin(handler: PluginFn, config?: Partial<Config>): PluginWithConfig {
return {
handler,
config,
}
}
createPlugin.withOptions = function <T>(
pluginFunction: (options?: T) => PluginFn,
configFunction: (options?: T) => Partial<Config> = () => ({}),
): PluginWithOptions<T> {
function optionsFunction(options: T): PluginWithConfig {
return {
handler: pluginFunction(options),
config: configFunction(options),
}
}
optionsFunction.__isOptionsFunction = true as const
return optionsFunction as PluginWithOptions<T>
}
export default createPlugin

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest'
import { compile } from '.'
import type { PluginAPI } from './plugin-api'
import { compileCss, optimizeCss, run } from './test-utils/run'
const css = String.raw
@ -15411,7 +15412,7 @@ describe('legacy: addUtilities', () => {
`,
{
async loadPlugin() {
return ({ addUtilities }) => {
return ({ addUtilities }: PluginAPI) => {
addUtilities({
'.text-trim': {
'text-box-trim': 'both',
@ -15451,7 +15452,7 @@ describe('legacy: addUtilities', () => {
`,
{
async loadPlugin() {
return ({ addUtilities }) => {
return ({ addUtilities }: PluginAPI) => {
addUtilities({
'.text-trim': {
WebkitAppearance: 'none',
@ -15489,7 +15490,7 @@ describe('legacy: addUtilities', () => {
`,
{
async loadPlugin() {
return ({ addUtilities }) => {
return ({ addUtilities }: PluginAPI) => {
addUtilities({
'.foo': {
'@apply flex dark:underline': {},
@ -15542,7 +15543,7 @@ describe('legacy: addUtilities', () => {
`,
{
async loadPlugin() {
return ({ addUtilities }) => {
return ({ addUtilities }: PluginAPI) => {
addUtilities({
'.text-trim > *': {
'text-box-trim': 'both',
@ -15572,7 +15573,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
'border-block': (value) => ({ 'border-block-width': value }),
@ -15653,7 +15654,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
'border-block': (value, { modifier }) => ({
@ -15719,7 +15720,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
'border-block': (value, { modifier }) => ({
@ -15789,7 +15790,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
scrollbar: (value) => ({ 'scrollbar-color': value }),
@ -15840,7 +15841,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
scrollbar: (value) => ({ '--scrollbar-angle': value }),
@ -15874,7 +15875,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
scrollbar: (value) => ({ 'scrollbar-color': value }),
@ -15914,7 +15915,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
scrollbar: (value) => ({ 'scrollbar-color': value }),
@ -16033,7 +16034,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
scrollbar: (value) => ({ 'scrollbar-color': value }),
@ -16106,7 +16107,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
scrollbar: (value, { modifier }) => ({
@ -16161,7 +16162,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
foo: (value) => ({
@ -16226,7 +16227,7 @@ describe('legacy: matchUtilities', () => {
`,
{
async loadPlugin() {
return ({ matchUtilities }) => {
return ({ matchUtilities }: PluginAPI) => {
matchUtilities({
'.text-trim > *': () => ({
'text-box-trim': 'both',

View File

@ -1,11 +1,29 @@
import { defineConfig } from 'tsup'
export default defineConfig({
format: ['esm', 'cjs'],
clean: true,
minify: true,
dts: true,
entry: {
lib: 'src/index.ts',
export default defineConfig([
{
format: ['esm', 'cjs'],
clean: true,
minify: true,
dts: true,
entry: {
lib: 'src/index.ts',
},
},
})
{
format: ['esm'],
minify: true,
dts: true,
entry: {
plugin: 'src/plugin.ts',
},
},
{
format: ['cjs'],
minify: true,
dts: true,
entry: {
plugin: 'src/plugin.cts',
},
},
])