From b053ede163bb8d4cf9a3d3ca3c32501af4f8a69e Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Mon, 18 Nov 2024 13:50:16 +0100 Subject: [PATCH] Support complex `addUtility()` configs --- integrations/cli/plugins.test.ts | 40 +++++++++ .../tailwindcss/src/compat/plugin-api.test.ts | 87 +++++++++++++++++-- packages/tailwindcss/src/compat/plugin-api.ts | 62 ++++++++----- 3 files changed, 159 insertions(+), 30 deletions(-) diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts index 8d889b7b9a62..833b0f6779a6 100644 --- a/integrations/cli/plugins.test.ts +++ b/integrations/cli/plugins.test.ts @@ -122,6 +122,46 @@ test( }, ) +test( + 'builds the `@tailwindcss/aspect-ratio` plugin utilities', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/aspect-ratio": "^0.4.2", + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'index.html': html` +
+ +
+ `, + 'src/index.css': css` + @import 'tailwindcss'; + @plugin '@tailwindcss/aspect-ratio'; + `, + }, + }, + async ({ fs, exec }) => { + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css') + + await fs.expectFileToContain('dist/out.css', [ + // + candidate`aspect-w-16`, + candidate`aspect-h-9`, + ]) + }, +) + test( 'builds the `tailwindcss-animate` plugin utilities', { diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 6aabab529865..d5babfbf8b03 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -2760,7 +2760,7 @@ describe('addUtilities()', () => { base, module: ({ addUtilities }: PluginAPI) => { addUtilities({ - '.text-trim > *': { + ':hover > *': { 'text-box-trim': 'both', 'text-box-edge': 'cap alphabetic', }, @@ -2842,18 +2842,89 @@ describe('addUtilities()', () => { }, ) - expect(optimizeCss(compiled.build(['form-input', 'lg:form-textarea'])).trim()) - .toMatchInlineSnapshot(` - ".form-input, .form-input::placeholder { + expect(compiled.build(['form-input', 'lg:form-textarea']).trim()).toMatchInlineSnapshot(` + ".form-input { + background-color: red; + &::placeholder { background-color: red; } - + } + .lg\\:form-textarea { @media (width >= 1024px) { - .lg\\:form-textarea:hover:focus { + &:hover:focus { background-color: red; } - }" - `) + } + }" + `) + }) + + test('nests complex utility names', async () => { + let compiled = await compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + async loadModule(id, base) { + return { + base, + module: ({ addUtilities }: PluginAPI) => { + addUtilities({ + '.a .b:hover .c': { + color: 'red', + }, + '.d > *': { + color: 'red', + }, + '.e .bar:not(.f):has(.g)': { + color: 'red', + }, + }) + }, + } + }, + }, + ) + + expect(compiled.build(['a', 'b', 'c', 'd', 'e', 'f', 'g']).trim()).toMatchInlineSnapshot( + ` + "@layer utilities { + .a { + & .b:hover .c { + color: red; + } + } + .b { + .a &:hover .c { + color: red; + } + } + .c { + .a .b:hover & { + color: red; + } + } + .d { + & > * { + color: red; + } + } + .e { + & .bar:not(.f):has(.g) { + color: red; + } + } + .g { + .e .bar:not(.f):has(&) { + color: red; + } + } + }" + `, + ) }) }) diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 9afb93140092..a6562102e90e 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -5,9 +5,11 @@ import { substituteFunctions } from '../css-functions' import * as CSS from '../css-parser' import type { DesignSystem } from '../design-system' import { withAlpha } from '../utilities' +import { DefaultMap } from '../utils/default-map' import { inferDataType } from '../utils/infer-data-type' import { segment } from '../utils/segment' import { toKeyPath } from '../utils/to-key-path' +import * as ValueParser from '../value-parser' import { compoundsForSelectors, substituteAtSlot } from '../variants' import type { ResolvedConfig, UserConfig } from './config/types' import { createThemeFn } from './plugin-functions' @@ -198,40 +200,56 @@ export function buildPluginApi( ) // Merge entries for the same class - let utils: Record = {} + let utils = new DefaultMap(() => []) for (let [name, css] of entries) { - let [className, ...parts] = segment(name, ':') - - // Modify classes using pseudo-classes or pseudo-elements to use nested rules - if (parts.length > 0) { - let pseudos = parts.map((p) => `:${p.trim()}`).join('') - css = { - [`&${pseudos}`]: css, - } - } - - utils[className] ??= [] - css = Array.isArray(css) ? css : [css] - utils[className].push(...css) - } - - for (let [name, css] of Object.entries(utils)) { if (name.startsWith('@keyframes ')) { ast.push(rule(name, objectToAst(css))) continue } - if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) { + if (name[0] === '.' && IS_VALID_UTILITY_NAME.test(name.slice(1))) { + utils.get(name.slice(1)).push(...objectToAst(css)) + continue + } + + let selectorAst = ValueParser.parse(name) + let foundValidUtility = false + ValueParser.walk(selectorAst, (node) => { + if ( + node.kind === 'word' && + node.value[0] === '.' && + IS_VALID_UTILITY_NAME.test(node.value.slice(1)) + ) { + let value = node.value + node.value = '&' + let selector = ValueParser.toCss(selectorAst) + + let className = value.slice(1) + utils.get(className).push(rule(selector, objectToAst(css))) + foundValidUtility = true + + node.value = value + return + } + + if (node.kind === 'function' && node.value === 'not') { + return ValueParser.ValueWalkAction.Skip + } + }) + + if (!foundValidUtility) { throw new Error( `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, ) } + } - designSystem.utilities.static(name.slice(1), () => { - let ast = objectToAst(css) - substituteAtApply(ast, designSystem) - return ast + for (let [className, ast] of utils) { + designSystem.utilities.static(className, () => { + let clonedAst = structuredClone(ast) + substituteAtApply(clonedAst, designSystem) + return clonedAst }) } },