Skip to content

Commit c65f20a

Browse files
thecrypticacephilipp-spiessRobinMalfaitadamwathan
authored
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 <[email protected]> Co-authored-by: Robin Malfait <[email protected]> Co-authored-by: Adam Wathan <[email protected]>
1 parent 52012d9 commit c65f20a

File tree

5 files changed

+347
-14
lines changed

5 files changed

+347
-14
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add new standalone builds of Tailwind CSS v4 ([#14270](https://github.com/tailwindlabs/tailwindcss/pull/14270))
1313
- Support JavaScript configuration files using `@config` ([#14239](https://github.com/tailwindlabs/tailwindcss/pull/14239))
14+
- Support plugin options in `@plugin` ([#14264](https://github.com/tailwindlabs/tailwindcss/pull/14264))
1415

1516
### Fixed
1617

integrations/cli/plugins.test.ts

+45
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,51 @@ test(
7777
},
7878
)
7979

80+
test(
81+
'builds the `@tailwindcss/forms` plugin utilities (with options)',
82+
{
83+
fs: {
84+
'package.json': json`
85+
{
86+
"dependencies": {
87+
"@tailwindcss/forms": "^0.5.7",
88+
"tailwindcss": "workspace:^",
89+
"@tailwindcss/cli": "workspace:^"
90+
}
91+
}
92+
`,
93+
'index.html': html`
94+
<input type="text" class="form-input" />
95+
<textarea class="form-textarea"></textarea>
96+
`,
97+
'src/index.css': css`
98+
@import 'tailwindcss';
99+
@plugin '@tailwindcss/forms' {
100+
strategy: base;
101+
}
102+
`,
103+
},
104+
},
105+
async ({ fs, exec }) => {
106+
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
107+
108+
await fs.expectFileToContain('dist/out.css', [
109+
//
110+
`::-webkit-date-and-time-value`,
111+
`[type='checkbox']:indeterminate`,
112+
])
113+
114+
// No classes are included even though they are used in the HTML
115+
// because the `base` strategy is used
116+
await fs.expectFileNotToContain('dist/out.css', [
117+
//
118+
candidate`form-input`,
119+
candidate`form-textarea`,
120+
candidate`form-radio`,
121+
])
122+
},
123+
)
124+
80125
test(
81126
'builds the `tailwindcss-animate` plugin utilities',
82127
{

packages/tailwindcss/src/index.test.ts

+220-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs'
22
import path from 'node:path'
33
import { describe, expect, it, test } from 'vitest'
44
import { compile } from '.'
5+
import plugin from './plugin'
56
import type { PluginAPI } from './plugin-api'
67
import { compileCss, optimizeCss, run } from './test-utils/run'
78

@@ -1294,13 +1295,27 @@ describe('Parsing themes values from CSS', () => {
12941295
})
12951296

12961297
describe('plugins', () => {
1297-
test('@plugin can not have a body.', async () =>
1298+
test('@plugin need a path', () =>
12981299
expect(
12991300
compile(
13001301
css`
1301-
@plugin {
1302-
color: red;
1303-
}
1302+
@plugin;
1303+
`,
1304+
{
1305+
loadPlugin: async () => {
1306+
return ({ addVariant }: PluginAPI) => {
1307+
addVariant('hocus', '&:hover, &:focus')
1308+
}
1309+
},
1310+
},
1311+
),
1312+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
1313+
1314+
test('@plugin can not have an empty path', () =>
1315+
expect(
1316+
compile(
1317+
css`
1318+
@plugin '';
13041319
`,
13051320
{
13061321
loadPlugin: async () => {
@@ -1310,7 +1325,7 @@ describe('plugins', () => {
13101325
},
13111326
},
13121327
),
1313-
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot have a body.]`))
1328+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` must have a path.]`))
13141329
13151330
test('@plugin cannot be nested.', () =>
13161331
expect(
@@ -1330,6 +1345,206 @@ describe('plugins', () => {
13301345
),
13311346
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: \`@plugin\` cannot be nested.]`))
13321347
1348+
test('@plugin can accept options', async () => {
1349+
expect.hasAssertions()
1350+
1351+
let { build } = await compile(
1352+
css`
1353+
@tailwind utilities;
1354+
@plugin "my-plugin" {
1355+
color: red;
1356+
}
1357+
`,
1358+
{
1359+
loadPlugin: async () => {
1360+
return plugin.withOptions((options) => {
1361+
expect(options).toEqual({
1362+
color: 'red',
1363+
})
1364+
1365+
return ({ addUtilities }) => {
1366+
addUtilities({
1367+
'.text-primary': {
1368+
color: options.color,
1369+
},
1370+
})
1371+
}
1372+
})
1373+
},
1374+
},
1375+
)
1376+
1377+
let compiled = build(['text-primary'])
1378+
1379+
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
1380+
".text-primary {
1381+
color: red;
1382+
}"
1383+
`)
1384+
})
1385+
1386+
test('@plugin options can be null, booleans, string, numbers, or arrays including those types', async () => {
1387+
expect.hasAssertions()
1388+
1389+
await compile(
1390+
css`
1391+
@tailwind utilities;
1392+
@plugin "my-plugin" {
1393+
is-null: null;
1394+
is-true: true;
1395+
is-false: false;
1396+
is-int: 1234567;
1397+
is-float: 1.35;
1398+
is-sci: 1.35e-5;
1399+
is-str-null: 'null';
1400+
is-str-true: 'true';
1401+
is-str-false: 'false';
1402+
is-str-int: '1234567';
1403+
is-str-float: '1.35';
1404+
is-str-sci: '1.35e-5';
1405+
is-arr: foo, bar;
1406+
is-arr-mixed: null, true, false, 1234567, 1.35, foo, 'bar', 'true';
1407+
}
1408+
`,
1409+
{
1410+
loadPlugin: async () => {
1411+
return plugin.withOptions((options) => {
1412+
expect(options).toEqual({
1413+
'is-null': null,
1414+
'is-true': true,
1415+
'is-false': false,
1416+
'is-int': 1234567,
1417+
'is-float': 1.35,
1418+
'is-sci': 1.35e-5,
1419+
'is-str-null': 'null',
1420+
'is-str-true': 'true',
1421+
'is-str-false': 'false',
1422+
'is-str-int': '1234567',
1423+
'is-str-float': '1.35',
1424+
'is-str-sci': '1.35e-5',
1425+
'is-arr': ['foo', 'bar'],
1426+
'is-arr-mixed': [null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'],
1427+
})
1428+
1429+
return () => {}
1430+
})
1431+
},
1432+
},
1433+
)
1434+
})
1435+
1436+
test('@plugin options can only be simple key/value pairs', () => {
1437+
expect(
1438+
compile(
1439+
css`
1440+
@plugin "my-plugin" {
1441+
color: red;
1442+
sizes {
1443+
sm: 1rem;
1444+
md: 2rem;
1445+
}
1446+
}
1447+
`,
1448+
{
1449+
loadPlugin: async () => {
1450+
return plugin.withOptions((options) => {
1451+
return ({ addUtilities }) => {
1452+
addUtilities({
1453+
'.text-primary': {
1454+
color: options.color,
1455+
},
1456+
})
1457+
}
1458+
})
1459+
},
1460+
},
1461+
),
1462+
).rejects.toThrowErrorMatchingInlineSnapshot(
1463+
`
1464+
[Error: Unexpected \`@plugin\` option:
1465+
1466+
sizes {
1467+
sm: 1rem;
1468+
md: 2rem;
1469+
}
1470+
1471+
1472+
\`@plugin\` options must be a flat list of declarations.]
1473+
`,
1474+
)
1475+
})
1476+
1477+
test('@plugin options can only be provided to plugins using withOptions', () => {
1478+
expect(
1479+
compile(
1480+
css`
1481+
@plugin "my-plugin" {
1482+
color: red;
1483+
}
1484+
`,
1485+
{
1486+
loadPlugin: async () => {
1487+
return plugin(({ addUtilities }) => {
1488+
addUtilities({
1489+
'.text-primary': {
1490+
color: 'red',
1491+
},
1492+
})
1493+
})
1494+
},
1495+
},
1496+
),
1497+
).rejects.toThrowErrorMatchingInlineSnapshot(
1498+
`[Error: The plugin "my-plugin" does not accept options]`,
1499+
)
1500+
})
1501+
1502+
test('@plugin errors on array-like syntax', () => {
1503+
expect(
1504+
compile(
1505+
css`
1506+
@plugin "my-plugin" {
1507+
--color: [ 'red', 'green', 'blue'];
1508+
}
1509+
`,
1510+
{
1511+
loadPlugin: async () => plugin(() => {}),
1512+
},
1513+
),
1514+
).rejects.toThrowErrorMatchingInlineSnapshot(
1515+
`[Error: The plugin "my-plugin" does not accept options]`,
1516+
)
1517+
})
1518+
1519+
test('@plugin errors on object-like syntax', () => {
1520+
expect(
1521+
compile(
1522+
css`
1523+
@plugin "my-plugin" {
1524+
--color: {
1525+
red: 100;
1526+
green: 200;
1527+
blue: 300;
1528+
};
1529+
}
1530+
`,
1531+
{
1532+
loadPlugin: async () => plugin(() => {}),
1533+
},
1534+
),
1535+
).rejects.toThrowErrorMatchingInlineSnapshot(
1536+
`
1537+
[Error: Unexpected \`@plugin\` option: Value of declaration \`--color: {
1538+
red: 100;
1539+
green: 200;
1540+
blue: 300;
1541+
};\` is not supported.
1542+
1543+
Using an object as a plugin option is currently only supported in JavaScript configuration files.]
1544+
`,
1545+
)
1546+
})
1547+
13331548
test('addVariant with string selector', async () => {
13341549
let { build } = await compile(
13351550
css`

0 commit comments

Comments
 (0)