diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ae3f8f5..4d7d05648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008)) - Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008)) +- Add `@utility` at-rule for defining custom utilities in CSS ([#14044](https://github.com/tailwindlabs/tailwindcss/pull/14044)) ## [4.0.0-alpha.17] - 2024-07-04 diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index 359a5dbe1..6dc97150f 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -249,6 +249,25 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi base = base.slice(1) } + // Candidates that start with a dash are the negative versions of another + // candidate, e.g. `-mx-4`. + if (base[0] === '-') { + negative = true + base = base.slice(1) + } + + // Check for an exact match of a static utility first as long as it does not + // look like an arbitrary value. + if (designSystem.utilities.has(base, 'static') && !base.includes('[')) { + return { + kind: 'static', + root: base, + variants: parsedCandidateVariants, + negative, + important, + } + } + // Figure out the new base and the modifier segment if present. // // E.g.: @@ -307,13 +326,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi } } - // Candidates that start with a dash are the negative versions of another - // candidate, e.g. `-mx-4`. - if (baseWithoutModifier[0] === '-') { - negative = true - baseWithoutModifier = baseWithoutModifier.slice(1) - } - // The root of the utility, e.g.: `bg-red-500` // ^^ let root: string | null = null @@ -345,28 +357,16 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi // The root of the utility should exist as-is in the utilities map. If not, // it's an invalid utility and we can skip continue parsing. - if (!designSystem.utilities.has(root)) return null + if (!designSystem.utilities.has(root, 'functional')) return null value = baseWithoutModifier.slice(idx + 1) } // Not an arbitrary value else { - ;[root, value] = findRoot(baseWithoutModifier, designSystem.utilities) - } - - // If the root is null, but it contains a `/`, then it could be that we are - // dealing with a functional utility that contains a modifier but doesn't - // contain a value. - // - // E.g.: `@container/parent` - if (root === null && base.includes('/')) { - let [rootWithoutModifier, rootModifierSegment = null] = segment(base, '/') - - modifierSegment = rootModifierSegment - - // Try to find the root and value, without the modifier present - ;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities) + ;[root, value] = findRoot(baseWithoutModifier, (root: string) => { + return designSystem.utilities.has(root, 'functional') + }) } // If there's no root, the candidate isn't a valid class and can be discarded. @@ -377,24 +377,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi // can skip any further parsing. if (value === '') return null - let kind = designSystem.utilities.kind(root) - - if (kind === 'static') { - // Static utilities do not have a value - if (value !== null) return null - - // Static utilities do not have a modifier - if (modifierSegment !== null) return null - - return { - kind: 'static', - root, - variants: parsedCandidateVariants, - negative, - important, - } - } - let candidate: Candidate = { kind: 'functional', root, @@ -560,7 +542,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia // - `group-hover/foo/bar` if (additionalModifier) return null - let [root, value] = findRoot(variantWithoutModifier, designSystem.variants) + let [root, value] = findRoot(variantWithoutModifier, (root) => { + return designSystem.variants.has(root) + }) // Variant is invalid, therefore the candidate is invalid and we can skip // continue parsing it. @@ -629,10 +613,10 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia function findRoot( input: string, - lookup: { has: (input: string) => boolean }, + exists: (input: string) => boolean, ): [string | null, string | null] { - // If the lookup has an exact match, then that's the root. - if (lookup.has(input)) return [input, null] + // If there is an exact match, then that's the root. + if (exists(input)) return [input, null] // Otherwise test every permutation of the input by iteratively removing // everything after the last dash. @@ -640,15 +624,14 @@ function findRoot( if (idx === -1) { // Variants starting with `@` are special because they don't need a `-` // after the `@` (E.g.: `@-lg` should be written as `@lg`). - if (input[0] === '@' && lookup.has('@')) { + if (input[0] === '@' && exists('@')) { return ['@', input.slice(1)] } return [null, null] } - // Determine the root and value by testing permutations of the incoming input - // against the lookup table. + // Determine the root and value by testing permutations of the incoming input. // // In case of a candidate like `bg-red-500`, this looks like: // @@ -658,7 +641,7 @@ function findRoot( do { let maybeRoot = input.slice(0, idx) - if (lookup.has(maybeRoot)) { + if (exists(maybeRoot)) { return [maybeRoot, input.slice(idx + 1)] } diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index cac07451d..0e7f041a9 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -117,14 +117,20 @@ export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem // Handle named utilities else if (candidate.kind === 'static' || candidate.kind === 'functional') { - // Safety: At this point it is safe to use TypeScript's non-null assertion - // operator because if the `candidate.root` didn't exist, `parseCandidate` - // would have returned `null` and we would have returned early resulting - // in not hitting this code path. - let { compileFn } = designSystem.utilities.get(candidate.root)! + let fns = designSystem.utilities.get(candidate.root) // Build the node - let compiledNodes = compileFn(candidate) + let compiledNodes: AstNode[] | undefined + + for (let i = fns.length - 1; i >= 0; i--) { + let fn = fns[i] + + if (candidate.kind !== fn.kind) continue + + compiledNodes = fn.compileFn(candidate) + if (compiledNodes) break + } + if (compiledNodes === undefined) return null nodes = compiledNodes diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 9d2e90eda..f324dfad8 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -17,9 +17,12 @@ import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme } from './theme' import { segment } from './utils/segment' +const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ + type PluginAPI = { addVariant(name: string, variant: string | string[] | CssInJs): void } + type Plugin = (api: PluginAPI) => void type CompileOptions = { @@ -52,6 +55,7 @@ export function compile( let theme = new Theme() let plugins: Plugin[] = [] let customVariants: ((designSystem: DesignSystem) => void)[] = [] + let customUtilities: ((designSystem: DesignSystem) => void)[] = [] let firstThemeRule: Rule | null = null let keyframesRules: Rule[] = [] @@ -65,6 +69,33 @@ export function compile( return } + // Collect custom `@utility` at-rules + if (node.selector.startsWith('@utility ')) { + let name = node.selector.slice(9).trim() + + if (!IS_VALID_UTILITY_NAME.test(name)) { + throw new Error( + `\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`, + ) + } + + if (node.nodes.length === 0) { + throw new Error( + `\`@utility ${name}\` is empty. Utilities should include at least one property.`, + ) + } + + customUtilities.push((designSystem) => { + designSystem.utilities.static(name, (candidate) => { + if (candidate.negative) return + return structuredClone(node.nodes) + }) + }) + + replaceWith([]) + return + } + // Register custom variants from `@variant` at-rules if (node.selector.startsWith('@variant ')) { if (parent !== null) { @@ -224,6 +255,10 @@ export function compile( customVariant(designSystem) } + for (let customUtility of customUtilities) { + customUtility(designSystem) + } + let api: PluginAPI = { addVariant(name, variant) { // Single selector diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index 4b1d814fb..aaaedd3cf 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -11,18 +11,13 @@ export type ClassEntry = [string, ClassMetadata] export function getClassList(design: DesignSystem): ClassEntry[] { let list: [string, ClassMetadata][] = [] - for (let [utility, fn] of design.utilities.entries()) { - if (typeof utility !== 'string') { - continue - } + // Static utilities only work as-is + for (let utility of design.utilities.keys('static')) { + list.push([utility, { modifiers: [] }]) + } - // Static utilities only work as-is - if (fn.kind === 'static') { - list.push([utility, { modifiers: [] }]) - continue - } - - // Functional utilities have their own list of completions + // Functional utilities have their own list of completions + for (let utility of design.utilities.keys('functional')) { let completions = design.utilities.getCompletions(utility) for (let group of completions) { diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 6274194cb..4135b1cdd 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from 'vitest' -import { compileCss, run } from './test-utils/run' +import { describe, expect, test } from 'vitest' +import { compile } from '.' +import { compileCss, optimizeCss, run } from './test-utils/run' const css = String.raw @@ -14946,3 +14947,206 @@ test('@container', () => { ]), ).toEqual('') }) + +describe('custom utilities', () => { + test('custom static utility', () => { + let compiled = compile(css` + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --breakpoint-lg: 1024px; + } + + @utility text-trim { + text-box-trim: both; + text-box-edge: cap alphabetic; + } + `).build(['text-trim', 'lg:text-trim']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .text-trim { + text-box-trim: both; + text-box-edge: cap alphabetic; + } + + @media (width >= 1024px) { + .lg\\:text-trim { + text-box-trim: both; + text-box-edge: cap alphabetic; + } + } + }" + `) + }) + + test('The later version of a static utility is used', () => { + let compiled = compile(css` + @layer utilities { + @tailwind utilities; + } + + @utility really-round { + --custom-prop: hi; + border-radius: 50rem; + } + + @utility really-round { + border-radius: 30rem; + } + `).build(['really-round']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .really-round { + border-radius: 30rem; + } + }" + `) + }) + + test('custom utilities support some special chracters', () => { + let compiled = compile(css` + @layer utilities { + @tailwind utilities; + } + + @utility push-1/2 { + right: 50%; + } + + @utility push-50% { + right: 50%; + } + `).build(['push-1/2', 'push-50%']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .push-1\\/2, .push-50\\% { + right: 50%; + } + }" + `) + }) + + test('can override specific versions of a functional utility with a static utility', () => { + let compiled = compile(css` + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --font-size-sm: 0.875rem; + --font-size-sm--line-height: 1.25rem; + } + + @utility text-sm { + font-size: var(--font-size-sm, 0.875rem); + line-height: var(--font-size-sm--line-height, 1.25rem); + text-rendering: optimizeLegibility; + } + `).build(['text-sm']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .text-sm { + font-size: var(--font-size-sm, .875rem); + line-height: var(--font-size-sm--line-height, 1.25rem); + text-rendering: optimizelegibility; + } + }" + `) + }) + + test('can override the default value of a functional utility', () => { + let compiled = compile(css` + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --radius-xl: 16px; + } + + @utility rounded { + border-radius: 50rem; + } + `).build(['rounded', 'rounded-xl', 'rounded-[33px]']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .rounded { + border-radius: 50rem; + } + + .rounded-\\[33px\\] { + border-radius: 33px; + } + + .rounded-xl { + border-radius: var(--radius-xl, 16px); + } + }" + `) + }) + + test('custom utilities are sorted by used properties', () => { + let compiled = compile(css` + @layer utilities { + @tailwind utilities; + } + + @utility push-left { + right: 100%; + } + `).build(['top-[100px]', 'push-left', 'right-[100px]', 'bottom-[100px]']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .top-\\[100px\\] { + top: 100px; + } + + .push-left { + right: 100%; + } + + .right-\\[100px\\] { + right: 100px; + } + + .bottom-\\[100px\\] { + bottom: 100px; + } + }" + `) + }) + + test('custom utilities must use a valid name definitions ', () => { + expect(() => + compile(css` + @utility push-* { + right: 100%; + } + `), + ).toThrowError(/should be alphanumeric/) + + expect(() => + compile(css` + @utility ~push { + right: 100%; + } + `), + ).toThrowError(/should be alphanumeric/) + + expect(() => + compile(css` + @utility @push { + right: 100%; + } + `), + ).toThrowError(/should be alphanumeric/) + }) +}) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 58de98200..5a61869a0 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1,12 +1,11 @@ import { decl, rule, type AstNode, type Rule } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' import type { ColorThemeKey, Theme, ThemeKey } from './theme' +import { DefaultMap } from './utils/default-map' import { inferDataType } from './utils/infer-data-type' import { replaceShadowColors } from './utils/replace-shadow-colors' import { segment } from './utils/segment' -const ARBITRARY_VARIANT = Symbol('ARBITRARY_VARIANT') - type CompileFn = ( value: Extract, ) => AstNode[] | undefined @@ -29,38 +28,35 @@ type SuggestionDefinition = } export class Utilities { - private utilities = new Map< - string | symbol, + private arbitraryFn!: CompileFn<'arbitrary'> + private utilities = new DefaultMap< + string, { - kind: Candidate['kind'] + kind: 'static' | 'functional' compileFn: CompileFn - } - >() + }[] + >(() => []) private completions = new Map SuggestionGroup[]>() static(name: string, compileFn: CompileFn<'static'>) { - this.set(name, { kind: 'static', compileFn: compileFn }) + this.utilities.get(name).push({ kind: 'static', compileFn }) } functional(name: string, compileFn: CompileFn<'functional'>) { - this.set(name, { kind: 'functional', compileFn: compileFn }) + this.utilities.get(name).push({ kind: 'functional', compileFn }) } arbitrary(compileFn: CompileFn<'arbitrary'>) { - this.set(ARBITRARY_VARIANT, { kind: 'arbitrary', compileFn: compileFn }) + this.arbitraryFn = compileFn } - has(name: string) { - return this.utilities.has(name) + has(name: string, kind: 'static' | 'functional') { + return this.utilities.has(name) && this.utilities.get(name).some((fn) => fn.kind === kind) } - get(name: string | symbol) { - return this.utilities.get(name) - } - - kind(name: string) { - return this.utilities.get(name)!.kind + get(name: string) { + return this.utilities.has(name) ? this.utilities.get(name) : [] } getCompletions(name: string): SuggestionGroup[] { @@ -73,35 +69,23 @@ export class Utilities { this.completions.set(name, groups) } - keys() { - return this.utilities.keys() - } + keys(kind: 'static' | 'functional') { + let keys: string[] = [] - entries() { - return this.utilities.entries() - } - - getArbitrary() { - return this.get(ARBITRARY_VARIANT)!.compileFn - } - - private set( - name: string | symbol, - { kind, compileFn }: { kind: T; compileFn: CompileFn }, - ) { - // In test mode, throw an error if we accidentally override another utility - // by mistake when implementing a new utility that shares the same root - // without realizing the definitions need to be merged. - if (process.env.NODE_ENV === 'test') { - if (this.utilities.has(name)) { - throw new Error(`Duplicate utility prefix [${name.toString()}]`) + for (let [key, fns] of this.utilities.entries()) { + for (let fn of fns) { + if (fn.kind === kind) { + keys.push(key) + break + } } } - this.utilities.set(name, { - kind, - compileFn: compileFn, - }) + return keys + } + + getArbitrary() { + return this.arbitraryFn } }