Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/tailwindcss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
19 changes: 10 additions & 9 deletions packages/tailwindcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1299,7 +1300,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
Expand All @@ -1317,7 +1318,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
Expand All @@ -1335,7 +1336,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', '&:hover, &:focus')
}
},
Expand Down Expand Up @@ -1366,7 +1367,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', ['&:hover', '&:focus'])
}
},
Expand Down Expand Up @@ -1398,7 +1399,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'&:hover': '@slot',
'&:focus': '@slot',
Expand Down Expand Up @@ -1432,7 +1433,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'@media (hover: hover)': {
'&:hover': '@slot',
Expand Down Expand Up @@ -1480,7 +1481,7 @@ describe('plugins', () => {
`,
{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('hocus', {
'&': {
'--custom-property': '@slot',
Expand Down Expand Up @@ -1518,7 +1519,7 @@ describe('plugins', () => {

{
loadPlugin: async () => {
return ({ addVariant }) => {
return ({ addVariant }: PluginAPI) => {
addVariant('dark', '&:is([data-theme=dark] *)')
}
},
Expand Down Expand Up @@ -2087,7 +2088,7 @@ test('addBase', async () => {

{
loadPlugin: async () => {
return ({ addBase }) => {
return ({ addBase }: PluginAPI) => {
addBase({
body: {
'font-feature-settings': '"tnum"',
Expand Down
12 changes: 5 additions & 7 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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')) {
Expand Down
33 changes: 33 additions & 0 deletions packages/tailwindcss/src/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this is better or not 🤔

Suggested change
if ('__isOptionsFunction' in plugin) {
if (Object.hasOwn(plugin, '__isOptionsFunction')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in one is cleaner if TypeScript is happy with it.

// 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)
}
}
}
5 changes: 5 additions & 0 deletions packages/tailwindcss/src/plugin.cts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There doesn't appear to be a way for tsup to compile a default export to module.exports in my testing so we came up with this + two separate entry points in the tsup config. I like this because it feels like it's more clear that it's a workaround. We might be able to do something like this for the postcss plugin too but would need to test.

61 changes: 61 additions & 0 deletions packages/tailwindcss/src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
"
`)
})
26 changes: 26 additions & 0 deletions packages/tailwindcss/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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
Loading