From c65f20ace02520e4b0899482263171fc70301975 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 2 Sep 2024 12:49:09 -0400 Subject: [PATCH] Support plugin options in CSS (#14264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #14239 — that PR needs to be merged first. This PR allows plugins defined with `plugin.withOptions` to receive options in CSS when using `@plugin` as long as the options are simple key/value pairs. For example, the following is now valid and will include the forms plugin with only the base styles enabled: ```css @plugin "@tailwindcss/forms" { strategy: base; } ``` We handle `null`, `true`, `false`, and numeric values as expected and will convert them to their JavaScript equivalents. Comma separated values are turned into arrays. All other values are converted to strings. For example, in the following plugin definition, the options that are passed to the plugin will be the correct types: - `debug` will be the boolean value `true` - `threshold` will be the number `0.5` - `message` will be the string `"Hello world"` - `features` will be the array `["base", "responsive"]` ```css @plugin "my-plugin" { debug: false; threshold: 0.5; message: Hello world; features: base, responsive; } ``` If you need to pass a number or boolean value as a string, you can do so by wrapping the value in quotes: ```css @plugin "my-plugin" { debug: "false"; threshold: "0.5"; message: "Hello world"; } ``` When duplicate options are encountered the last value wins: ```css @plugin "my-plugin" { message: Hello world; message: Hello plugin; /* this will be the value of `message` */ } ``` It's important to note that this feature is **only available for plugins defined with `plugin.withOptions`**. If you try to pass options to a plugin that doesn't support them, you'll get an error message when building: ```css @plugin "my-plugin" { debug: false; threshold: 0.5; } /* Error: The plugin "my-plugin" does not accept options */ ``` Additionally, if you try to pass in more complex values like objects or selectors you'll get an error message: ```css @plugin "my-plugin" { color: { red: 100; green: 200; blue: 300 }; } /* Error: Objects are not supported in `@plugin` options. */ ``` ```css @plugin "my-plugin" { .some-selector > * { primary: "blue"; secondary: "green"; } } /* Error: `@plugin` can only contain declarations. */ ``` --------- Co-authored-by: Philipp Spiess Co-authored-by: Robin Malfait Co-authored-by: Adam Wathan --- CHANGELOG.md | 1 + integrations/cli/plugins.test.ts | 45 +++++ packages/tailwindcss/src/index.test.ts | 225 ++++++++++++++++++++++++- packages/tailwindcss/src/index.ts | 67 +++++++- packages/tailwindcss/src/plugin-api.ts | 23 ++- 5 files changed, 347 insertions(+), 14 deletions(-) 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, [