diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3c14e4e..cd1132782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new standalone builds of Tailwind CSS v4 ([#14270](https://github.com/tailwindlabs/tailwindcss/pull/14270)) - Support JavaScript configuration files using `@config` ([#14239](https://github.com/tailwindlabs/tailwindcss/pull/14239)) +- Support plugin options in `@plugin` ([#14264](https://github.com/tailwindlabs/tailwindcss/pull/14264)) ### Fixed diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts index 0dce0c2bc..6fe04f022 100644 --- a/integrations/cli/plugins.test.ts +++ b/integrations/cli/plugins.test.ts @@ -77,6 +77,51 @@ test( }, ) +test( + 'builds the `@tailwindcss/forms` plugin utilities (with options)', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/forms": "^0.5.7", + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'index.html': html` + + + `, + 'src/index.css': css` + @import 'tailwindcss'; + @plugin '@tailwindcss/forms' { + strategy: base; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css') + + await fs.expectFileToContain('dist/out.css', [ + // + `::-webkit-date-and-time-value`, + `[type='checkbox']:indeterminate`, + ]) + + // No classes are included even though they are used in the HTML + // because the `base` strategy is used + await fs.expectFileNotToContain('dist/out.css', [ + // + candidate`form-input`, + candidate`form-textarea`, + candidate`form-radio`, + ]) + }, +) + test( 'builds the `tailwindcss-animate` plugin utilities', { diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 15dfaa942..a22097a60 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 plugin from './plugin' import type { PluginAPI } from './plugin-api' import { compileCss, optimizeCss, run } from './test-utils/run' @@ -1294,13 +1295,11 @@ describe('Parsing themes values from CSS', () => { }) describe('plugins', () => { - test('@plugin can not have a body.', async () => + test('@plugin need a path', () => expect( compile( css` - @plugin { - color: red; - } + @plugin; `, { loadPlugin: async () => { @@ -1310,7 +1309,23 @@ describe('plugins', () => { }, }, ), - ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot have a body.]`)) + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`)) + + test('@plugin can not have an empty path', () => + expect( + compile( + css` + @plugin ''; + `, + { + loadPlugin: async () => { + return ({ addVariant }: PluginAPI) => { + addVariant('hocus', '&:hover, &:focus') + } + }, + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`)) test('@plugin cannot be nested.', () => expect( @@ -1330,6 +1345,206 @@ describe('plugins', () => { ), ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`)) + test('@plugin can accept options', async () => { + expect.hasAssertions() + + let { build } = await compile( + css` + @tailwind utilities; + @plugin "my-plugin" { + color: red; + } + `, + { + loadPlugin: async () => { + return plugin.withOptions((options) => { + expect(options).toEqual({ + color: 'red', + }) + + return ({ addUtilities }) => { + addUtilities({ + '.text-primary': { + color: options.color, + }, + }) + } + }) + }, + }, + ) + + let compiled = build(['text-primary']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + ".text-primary { + color: red; + }" + `) + }) + + test('@plugin options can be null, booleans, string, numbers, or arrays including those types', async () => { + expect.hasAssertions() + + await compile( + css` + @tailwind utilities; + @plugin "my-plugin" { + is-null: null; + is-true: true; + is-false: false; + is-int: 1234567; + is-float: 1.35; + is-sci: 1.35e-5; + is-str-null: 'null'; + is-str-true: 'true'; + is-str-false: 'false'; + is-str-int: '1234567'; + is-str-float: '1.35'; + is-str-sci: '1.35e-5'; + is-arr: foo, bar; + is-arr-mixed: null, true, false, 1234567, 1.35, foo, 'bar', 'true'; + } + `, + { + loadPlugin: async () => { + return plugin.withOptions((options) => { + expect(options).toEqual({ + 'is-null': null, + 'is-true': true, + 'is-false': false, + 'is-int': 1234567, + 'is-float': 1.35, + 'is-sci': 1.35e-5, + 'is-str-null': 'null', + 'is-str-true': 'true', + 'is-str-false': 'false', + 'is-str-int': '1234567', + 'is-str-float': '1.35', + 'is-str-sci': '1.35e-5', + 'is-arr': ['foo', 'bar'], + 'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'], + }) + + return () => {} + }) + }, + }, + ) + }) + + test('@plugin options can only be simple key/value pairs', () => { + expect( + compile( + css` + @plugin "my-plugin" { + color: red; + sizes { + sm: 1rem; + md: 2rem; + } + } + `, + { + loadPlugin: async () => { + return plugin.withOptions((options) => { + return ({ addUtilities }) => { + addUtilities({ + '.text-primary': { + color: options.color, + }, + }) + } + }) + }, + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [Error: Unexpected \`@plugin\` option: + + sizes { + sm: 1rem; + md: 2rem; + } + + + \`@plugin\` options must be a flat list of declarations.] + `, + ) + }) + + test('@plugin options can only be provided to plugins using withOptions', () => { + expect( + compile( + css` + @plugin "my-plugin" { + color: red; + } + `, + { + loadPlugin: async () => { + return plugin(({ addUtilities }) => { + addUtilities({ + '.text-primary': { + color: 'red', + }, + }) + }) + }, + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The plugin "my-plugin" does not accept options]`, + ) + }) + + test('@plugin errors on array-like syntax', () => { + expect( + compile( + css` + @plugin "my-plugin" { + --color: [ 'red', 'green', 'blue']; + } + `, + { + loadPlugin: async () => plugin(() => {}), + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The plugin "my-plugin" does not accept options]`, + ) + }) + + test('@plugin errors on object-like syntax', () => { + expect( + compile( + css` + @plugin "my-plugin" { + --color: { + red: 100; + green: 200; + blue: 300; + }; + } + `, + { + loadPlugin: async () => plugin(() => {}), + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [Error: Unexpected \`@plugin\` option: Value of declaration \`--color: { + red: 100; + green: 200; + blue: 300; + };\` is not supported. + + Using an object as a plugin option is currently only supported in JavaScript configuration files.] + `, + ) + }) + test('addVariant with string selector', async () => { let { build } = await compile( css` diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index c7f3916c8..2af735f0b 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -6,7 +6,7 @@ import { compileCandidates } from './compile' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './functions' -import { registerPlugins, type Plugin } from './plugin-api' +import { registerPlugins, type CssPluginOptions, type Plugin } from './plugin-api' import { Theme } from './theme' import { segment } from './utils/segment' @@ -48,7 +48,7 @@ async function parseCss( // Find all `@theme` declarations let theme = new Theme() - let pluginPaths: string[] = [] + let pluginPaths: [string, CssPluginOptions | null][] = [] let configPaths: string[] = [] let customVariants: ((designSystem: DesignSystem) => void)[] = [] let customUtilities: ((designSystem: DesignSystem) => void)[] = [] @@ -61,15 +61,60 @@ async function parseCss( // Collect paths from `@plugin` at-rules if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) { - if (node.nodes.length > 0) { - throw new Error('`@plugin` cannot have a body.') - } - if (parent !== null) { throw new Error('`@plugin` cannot be nested.') } - pluginPaths.push(node.selector.slice(9, -1)) + let pluginPath = node.selector.slice(9, -1) + if (pluginPath.length === 0) { + throw new Error('`@plugin` must have a path.') + } + + let options: CssPluginOptions = {} + + for (let decl of node.nodes ?? []) { + if (decl.kind !== 'declaration') { + throw new Error( + `Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`, + ) + } + + if (decl.value === undefined) continue + + // Parse the declaration value as a primitive type + // These are the same primitive values supported by JSON + let value: CssPluginOptions[keyof CssPluginOptions] = decl.value + + let parts = segment(value, ',').map((part) => { + part = part.trim() + + if (part === 'null') { + return null + } else if (part === 'true') { + return true + } else if (part === 'false') { + return false + } else if (!Number.isNaN(Number(part))) { + return Number(part) + } else if ( + (part[0] === '"' && part[part.length - 1] === '"') || + (part[0] === "'" && part[part.length - 1] === "'") + ) { + return part.slice(1, -1) + } else if (part[0] === '{' && part[part.length - 1] === '}') { + throw new Error( + `Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`, + ) + } + + return part + }) + + options[decl.property] = parts.length === 1 ? parts[0] : parts + } + + pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null]) + replaceWith([]) return } @@ -304,7 +349,13 @@ async function parseCss( })), ) - let plugins = await Promise.all(pluginPaths.map(loadPlugin)) + let plugins = await Promise.all( + pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ + path: pluginPath, + plugin: await loadPlugin(pluginPath), + options: pluginOptions, + })), + ) let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 6f86f30b0..53732e37b 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -346,12 +346,33 @@ function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] { return ast } +type Primitive = string | number | boolean | null +export type CssPluginOptions = Record + +interface PluginDetail { + path: string + plugin: Plugin + options: CssPluginOptions | null +} + export function registerPlugins( - plugins: Plugin[], + pluginDetails: PluginDetail[], designSystem: DesignSystem, ast: AstNode[], configs: ConfigFile[], ) { + let plugins = pluginDetails.map((detail) => { + if (!detail.options) { + return detail.plugin + } + + if ('__isOptionsFunction' in detail.plugin) { + return detail.plugin(detail.options) + } + + throw new Error(`The plugin "${detail.path}" does not accept options`) + }) + let userConfig = [{ config: { plugins } }, ...configs] let resolvedConfig = resolveConfig(designSystem, [