From e299ea381f9107e3b32304d770bbaaade3ac5768 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 13 Aug 2024 10:25:29 -0400 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + packages/tailwindcss/package.json | 8 +++ packages/tailwindcss/src/index.test.ts | 19 +++---- packages/tailwindcss/src/index.ts | 12 ++--- packages/tailwindcss/src/plugin-api.ts | 33 ++++++++++++ packages/tailwindcss/src/plugin.cts | 5 ++ packages/tailwindcss/src/plugin.test.ts | 61 ++++++++++++++++++++++ packages/tailwindcss/src/plugin.ts | 26 +++++++++ packages/tailwindcss/src/utilities.test.ts | 31 +++++------ packages/tailwindcss/tsup.config.ts | 34 +++++++++--- 10 files changed, 191 insertions(+), 39 deletions(-) create mode 100644 packages/tailwindcss/src/plugin.cts create mode 100644 packages/tailwindcss/src/plugin.test.ts create mode 100644 packages/tailwindcss/src/plugin.ts 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', + }, + }, +])