Support plugin options in CSS (#14264)

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 <hello@philippspiess.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Jordan Pittman 2024-09-02 12:49:09 -04:00 committed by GitHub
parent 52012d90d7
commit c65f20ace0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 347 additions and 14 deletions

View File

@ -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

View File

@ -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`
<input type="text" class="form-input" />
<textarea class="form-textarea"></textarea>
`,
'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',
{

View File

@ -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`

View File

@ -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)

View File

@ -346,12 +346,33 @@ function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] {
return ast
}
type Primitive = string | number | boolean | null
export type CssPluginOptions = Record<string, Primitive | Primitive[]>
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, [