mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
52012d90d7
commit
c65f20ace0
@ -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
|
||||
|
||||
|
||||
@ -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',
|
||||
{
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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, [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user