diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ab5fc24..2e955e7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/package.json b/packages/tailwindcss/package.json index 55a8d8343..5bd5b7922 100644 --- a/packages/tailwindcss/package.json +++ b/packages/tailwindcss/package.json @@ -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", diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index c92d6c2d2..e04515229 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -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"', diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 1a9f1feb5..f48f47ec8 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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 } @@ -40,7 +38,7 @@ async function parseCss(css: string, { loadPlugin = throwOnPlugin }: CompileOpti // Find all `@theme` declarations let theme = new Theme() - let pluginLoaders: Promise[] = [] + 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')) { diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 6e9230af5..3599c7a20 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -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 + +export type PluginFn = (api: PluginAPI) => void +export type PluginWithConfig = { handler: PluginFn; config?: Partial } +export type PluginWithOptions = { + (options?: T): PluginWithConfig + __isOptionsFunction: true +} + +export type Plugin = PluginFn | PluginWithConfig | PluginWithOptions + 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) + } + } +} diff --git a/packages/tailwindcss/src/plugin.cts b/packages/tailwindcss/src/plugin.cts new file mode 100644 index 000000000..e7000e927 --- /dev/null +++ b/packages/tailwindcss/src/plugin.cts @@ -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 diff --git a/packages/tailwindcss/src/plugin.test.ts b/packages/tailwindcss/src/plugin.test.ts new file mode 100644 index 000000000..d04fafd09 --- /dev/null +++ b/packages/tailwindcss/src/plugin.test.ts @@ -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; + } + } + " + `) +}) diff --git a/packages/tailwindcss/src/plugin.ts b/packages/tailwindcss/src/plugin.ts new file mode 100644 index 000000000..63d2ef8c6 --- /dev/null +++ b/packages/tailwindcss/src/plugin.ts @@ -0,0 +1,26 @@ +import type { Config, PluginFn, PluginWithConfig, PluginWithOptions } from './plugin-api' + +function createPlugin(handler: PluginFn, config?: Partial): PluginWithConfig { + return { + handler, + config, + } +} + +createPlugin.withOptions = function ( + pluginFunction: (options?: T) => PluginFn, + configFunction: (options?: T) => Partial = () => ({}), +): PluginWithOptions { + function optionsFunction(options: T): PluginWithConfig { + return { + handler: pluginFunction(options), + config: configFunction(options), + } + } + + optionsFunction.__isOptionsFunction = true as const + + return optionsFunction as PluginWithOptions +} + +export default createPlugin diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 5154fc10c..8880c8a46 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -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', diff --git a/packages/tailwindcss/tsup.config.ts b/packages/tailwindcss/tsup.config.ts index 8e19667be..77ccda19b 100644 --- a/packages/tailwindcss/tsup.config.ts +++ b/packages/tailwindcss/tsup.config.ts @@ -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', + }, + }, +])