From 66c18ca8a48dc263b238ab40c7b2b9278fc14d25 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 20 Oct 2025 17:15:31 +0200 Subject: [PATCH] Collapse multiple utilities (#19147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some commits look like a lot of changes, but they just move things around, so best to use `?w=1` when viewing commit by commit. This PR adds a new feature to the `designSystem.canonicalizeCandidates` to collapse multiple utilities to fewer utilities. To make this possible, we also have to convert some logical properties to physical properties (controllable via an option). ```ts ds.canonicalizeCandidates(['w-4', 'h-4'], { collapse: true // Default `true` logicalToPhysical: true // Default `true` }) // → ['size-4'] ``` We can already compute the signature of each utility, where if two utilities have the same signature they are considered the same. However it's kind of impossible to generate all combinations of all utilities ever to figure out if there is a potential collapse possible. Even if we just focus on the incoming list of candidates, there could still be a lot of classes. So instead we have to be a bit more clever. First, we group candidates together by the used variants and if the important `!` flag was used. We can improve this in the future, but for now we won't even try to combine `hover:w-4 h-4`. Next, for each candidate, we figure out which property and value it uses. We can build up a lookup table for this. We already did this process for all utilities in the system as well. The lookup table for `w-4 h-4 p-4` might look something like this. ```json { "width": { "16px": ["w-4", "size-4"], }, "height": { "16px": ["h-4", "size-4"], }, "padding": { "16px": ["p-4"], } } ``` Next, we can build groups of candidates where an intersection exists in the lookup table. In the example above, we can see that `w-4` and `h-4` both map to `size-4` for the value `16px`. So we can group these two candidates. The `p-4`d doesn't intersect with anything else, so it remains alone. This also means that we only have to generate combinations for two candidates (2^2 = 4) instead of three (2^3 = 8). In practice, your class list might have many classes, so keeping this number low is important. When we generate combinations, we will generate the most amount of candidates first so we have the largest collapse possible. We also stop when we reach <= 1 combinations because we need at least two candidates to collapse. Since this uses the internal design system, if you have custom `@utility`s, this will work as expected. ```css @utility example { @apply border rounded p-4; } ``` If you then use: ```html
``` Then we can collapse this to: ```html
``` But even if we used: ```html
``` It would still collapse to: ```html
``` ...because the `pt-4` and `pb-4` can collapse to `py-4`, and `py-4` and `px-4` can collapse to `p-4`. This is also where that logical to physical conversion comes into play, because while `pt-4` and `pb-4` set the physical `padding-top` and `padding-bottom` properties. The `py-4` utility sets the logical `padding-block` property. So we internally transform `padding-block` to the `padding-top` and `padding-bottom` physical properties. The funny thing is that this logical to physical conversion actually means that we will convert `pt-4` and `pb-4` from _physical_ to _logical_ properties, because we converted to `py-4`... In _most_ cases it's fine, and even preferred to use `py-1` over `pt-1 pb-1`, but in case it's not, then you can disable the `logicalToPhysical` option. ## Test plan Added some dedicated tests for this new functionality. --- .../src/canonicalize-candidates.test.ts | 65 ++- .../src/canonicalize-candidates.ts | 362 +++++++++++++-- .../src/expand-declaration.test.ts | 165 +++++++ .../tailwindcss/src/expand-declaration.ts | 93 ++++ packages/tailwindcss/src/signatures.ts | 415 +++++++++++------- 5 files changed, 902 insertions(+), 198 deletions(-) create mode 100644 packages/tailwindcss/src/expand-declaration.test.ts create mode 100644 packages/tailwindcss/src/expand-declaration.ts diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 0581e9734..9eac5cb92 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -27,6 +27,12 @@ const designSystems = new DefaultMap((base: string) => { }) }) +const DEFAULT_CANONICALIZATION_OPTIONS: CanonicalizeOptions = { + rem: 16, + collapse: true, + logicalToPhysical: true, +} + describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => { let testName = '`%s` → `%s` (%#)' if (strategy === 'with-variant') { @@ -68,7 +74,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', input: string, candidate: string, expected: string, - options?: CanonicalizeOptions, + options: CanonicalizeOptions = DEFAULT_CANONICALIZATION_OPTIONS, ) { candidate = prepare(candidate) expected = prepare(expected) @@ -88,6 +94,30 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', } } + async function expectCombinedCanonicalization( + input: string, + candidates: string, + expected: string, + options: CanonicalizeOptions = DEFAULT_CANONICALIZATION_OPTIONS, + ) { + let preparedCandidates = candidates.split(/\s+/g).map(prepare) + let preparedExpected = expected.split(/\s+/g).map(prepare) + + if (strategy === 'prefix') { + input = input.replace("@import 'tailwindcss';", "@import 'tailwindcss' prefix(tw);") + } + + let designSystem = await designSystems.get(__dirname).get(input) + let actual = designSystem.canonicalizeCandidates(preparedCandidates, options) + + try { + expect(actual).toEqual(preparedExpected) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, expectCombinedCanonicalization) + throw err + } + } + /// ---------------------------------- test.each([ @@ -269,7 +299,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'], // Arbitrary property to arbitrary value - ['[max-height:20px]', 'max-h-[20px]'], + ['[max-height:20%]', 'max-h-[20%]'], // Arbitrary property to bare value ['[grid-column:2]', 'col-2'], @@ -914,6 +944,37 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', await expectCanonicalization(input, candidate, expected) }) }) + + test.each([ + // 4 to 1 + ['mt-1 mr-1 mb-1 ml-1', 'm-1'], + + // 2 to 1 + ['mt-1 mb-1', 'my-1'], + + // Different order as above + ['mb-1 mt-1', 'my-1'], + + // To completely different utility + ['w-4 h-4', 'size-4'], + + // Do not touch if not operating on the same variants + ['hover:w-4 h-4', 'hover:w-4 h-4'], + + // Arbitrary properties to combined class + ['[width:_16px_] [height:16px]', 'size-4'], + + // Arbitrary properties to combined class with modifier + ['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'], + ])( + 'should canonicalize multiple classes `%s` into a shorthand `%s`', + async (candidates, expected) => { + let input = css` + @import 'tailwindcss'; + ` + await expectCombinedCanonicalization(input, candidates, expected) + }, + ) }) describe('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 727f3ca25..3977ba2ec 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -12,10 +12,13 @@ import { keyPathToCssProperty } from './compat/apply-config-to-theme' import type { DesignSystem } from './design-system' import * as SelectorParser from './selector-parser' import { + computeUtilityProperties, computeUtilitySignature, computeVariantSignature, preComputedUtilities, preComputedVariants, + SignatureFeatures, + staticUtilitiesByPropertyAndValue, type SignatureOptions, } from './signatures' import type { Writable } from './types' @@ -36,11 +39,46 @@ export interface CanonicalizeOptions { * E.g.: `mt-[16px]` with `rem: 16` will become `mt-4` (assuming `--spacing: 0.25rem`). */ rem?: number + + /** + * Whether to collapse multiple utilities into a single utility if possible. + * + * E.g.: `mt-2 mr-2 mb-2 ml-2` → `m-2` + */ + collapse?: boolean + + /** + * Whether to convert between logical and physical properties when collapsing + * utilities. + * + * E.g.: `mr-2 ml-2` → `mx-2` + */ + logicalToPhysical?: boolean } -const optionsCache = new DefaultMap((designSystem: DesignSystem) => { +enum Features { + /** + * No features enabled (default) + */ + None = 0, + + /** + * Collapse multiple utilities into a single utility if possible. + */ + CollapseUtilities = 1 << 0, +} + +interface InternalCanonicalizeOptions { + features: Features + designSystem: DesignSystem + signatureOptions: SignatureOptions +} + +const signatureOptionsCache = new DefaultMap((designSystem: DesignSystem) => { return new DefaultMap((rem: number | null = null) => { - return { designSystem, rem } satisfies SignatureOptions + return new DefaultMap((features: SignatureFeatures) => { + return { designSystem, rem, features } satisfies SignatureOptions + }) }) }) @@ -48,7 +86,37 @@ export function createSignatureOptions( designSystem: DesignSystem, options?: CanonicalizeOptions, ): SignatureOptions { - return optionsCache.get(designSystem).get(options?.rem ?? null) + let features = SignatureFeatures.None + if (options?.collapse) features |= SignatureFeatures.ExpandProperties + if (options?.logicalToPhysical) features |= SignatureFeatures.LogicalToPhysical + + return signatureOptionsCache + .get(designSystem) + .get(options?.rem ?? null) + .get(features) +} + +const internalOptionsCache = new DefaultMap((designSystem: DesignSystem) => { + return new DefaultMap((signatureOptions: SignatureOptions) => { + return new DefaultMap((features: Features) => { + return { + features, + designSystem, + signatureOptions, + } satisfies InternalCanonicalizeOptions + }) + }) +}) + +function createCanonicalizeOptions( + designSystem: DesignSystem, + signatureOptions: SignatureOptions, + options?: CanonicalizeOptions, +) { + let features = Features.None + if (options?.collapse) features |= Features.CollapseUtilities + + return internalOptionsCache.get(designSystem).get(signatureOptions).get(features) } export function canonicalizeCandidates( @@ -56,15 +124,183 @@ export function canonicalizeCandidates( candidates: string[], options?: CanonicalizeOptions, ): string[] { + let signatureOptions = createSignatureOptions(designSystem, options) + let canonicalizeOptions = createCanonicalizeOptions(designSystem, signatureOptions, options) + let result = new Set() - let cache = canonicalizeCandidateCache.get(createSignatureOptions(designSystem, options)) + let cache = canonicalizeCandidateCache.get(canonicalizeOptions) for (let candidate of candidates) { result.add(cache.get(candidate)) } - return Array.from(result) + + return result.size <= 1 || !(canonicalizeOptions.features & Features.CollapseUtilities) + ? Array.from(result) + : collapseCandidates(canonicalizeOptions, Array.from(result)) } -const canonicalizeCandidateCache = new DefaultMap((options: SignatureOptions) => { +function collapseCandidates(options: InternalCanonicalizeOptions, candidates: string[]): string[] { + if (candidates.length <= 1) return candidates + + // To keep things simple, we group candidates such that we only collapse + // candidates with the same variants and important modifier together. + let groups = new DefaultMap((_before: string) => { + return new DefaultMap((_after: string) => { + return new Set() + }) + }) + + let prefix = options.designSystem.theme.prefix ? `${options.designSystem.theme.prefix}:` : '' + + for (let candidate of candidates) { + let variants = segment(candidate, ':') + let utility = variants.pop()! + + let important = utility.endsWith('!') + if (important) { + utility = utility.slice(0, -1) + } + + let before = variants.length > 0 ? `${variants.join(':')}:` : '' + let after = important ? '!' : '' + + // Group by variants and important flag + groups.get(before).get(after).add(`${prefix}${utility}`) + } + + let result = new Set() + for (let [before, group] of groups.entries()) { + for (let [after, candidates] of group.entries()) { + for (let candidate of collapseGroup(Array.from(candidates))) { + // Drop the prefix if we had one, because the prefix is already there as + // part of the variants. + if (prefix && candidate.startsWith(prefix)) { + candidate = candidate.slice(prefix.length) + } + + result.add(`${before}${candidate}${after}`) + } + } + } + + return Array.from(result) + + function collapseGroup(candidates: string[]) { + let signatureOptions = options.signatureOptions + let computeUtilitiesPropertiesLookup = computeUtilityProperties.get(signatureOptions) + let staticUtilities = staticUtilitiesByPropertyAndValue.get(signatureOptions) + + // For each candidate, compute the used properties and values. E.g.: `mt-1` → `margin-top` → `0.25rem` + // + // NOTE: Currently assuming we are dealing with static utilities only. This + // will change the moment we have `@utility` for most built-ins. + let candidatePropertiesValues = candidates.map((candidate) => + computeUtilitiesPropertiesLookup.get(candidate), + ) + + // For each property, lookup other utilities that also set this property and + // this exact value. If multiple properties are used, use the intersection of + // each property. + // + // E.g.: `margin-top` → `mt-1`, `my-1`, `m-1` + let otherUtilities = candidatePropertiesValues.map((propertyValues) => { + let result: Set | null = null + for (let [property, values] of propertyValues) { + for (let value of values) { + let otherUtilities = staticUtilities.get(property).get(value) + + if (result === null) result = new Set(otherUtilities) + else result = intersection(result, otherUtilities) + + // The moment no other utilities match, we can stop searching because + // all intersections with an empty set will remain empty. + if (result!.size === 0) return result! + } + } + return result! + }) + + // Link each candidate that could be linked via another utility + // (intersection). This way we can reduce the amount of required combinations. + // + // E.g.: `mt-1` and `mb-1` can be linked via `my-1`. + // + // Candidates that cannot be linked won't be able to be collapsed. + // E.g.: `mt-1` and `text-red-500` cannot be collapsed because there is no 3rd + // utility with overlapping property/value combinations. + let linked = new DefaultMap>((key) => new Set([key])) + let otherUtilitiesArray = Array.from(otherUtilities) + for (let i = 0; i < otherUtilitiesArray.length; i++) { + let current = otherUtilitiesArray[i] + for (let j = i + 1; j < otherUtilitiesArray.length; j++) { + let other = otherUtilitiesArray[j] + + for (let property of current) { + if (other.has(property)) { + linked.get(i).add(j) + linked.get(j).add(i) + + // The moment we find a link, we can stop comparing and move on to the + // next candidate. This will safe us some time + break + } + } + } + } + + // Not a single candidate can be linked to another one, nothing left to do + if (linked.size === 0) return candidates + + // Each candidate index will now have a set of other candidate indexes as + // its value. Let's make the lists unique combinations so that we can + // iterate over them. + let uniqueCombinations = new DefaultMap((key: string) => key.split(',').map(Number)) + for (let group of linked.values()) { + let sorted = Array.from(group).sort((a, b) => a - b) + uniqueCombinations.get(sorted.join(',')) + } + + // Let's try to actually collapse them now. + let result = new Set(candidates) + let drop = new Set() + + for (let idxs of uniqueCombinations.values()) { + for (let combo of combinations(idxs)) { + if (combo.some((idx) => drop.has(candidates[idx]))) continue // Skip already dropped items + + let potentialReplacements = combo.flatMap((idx) => otherUtilities[idx]).reduce(intersection) + + let collapsedSignature = computeUtilitySignature.get(signatureOptions).get( + combo + .map((idx) => candidates[idx]) + .sort((a, z) => a.localeCompare(z)) // Sort to increase cache hits + .join(' '), + ) + + for (let replacement of potentialReplacements) { + let signature = computeUtilitySignature.get(signatureOptions).get(replacement) + if (signature !== collapsedSignature) continue // Not a safe replacement + + // We can replace all items in the combo with the replacement + for (let item of combo) { + drop.add(candidates[item]) + } + + // Use the replacement + result.add(replacement) + break + } + } + } + + for (let item of drop) { + result.delete(item) + } + + return Array.from(result) + } +} + +const canonicalizeCandidateCache = new DefaultMap((options: InternalCanonicalizeOptions) => { let ds = options.designSystem let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' let variantCache = canonicalizeVariantCache.get(options) @@ -127,7 +363,7 @@ const canonicalizeCandidateCache = new DefaultMap((options: SignatureOptions) => type VariantCanonicalizationFunction = ( variant: Variant, - options: SignatureOptions, + options: InternalCanonicalizeOptions, ) => Variant | Variant[] const VARIANT_CANONICALIZATIONS: VariantCanonicalizationFunction[] = [ @@ -137,7 +373,7 @@ const VARIANT_CANONICALIZATIONS: VariantCanonicalizationFunction[] = [ arbitraryVariants, ] -const canonicalizeVariantCache = new DefaultMap((options: SignatureOptions) => { +const canonicalizeVariantCache = new DefaultMap((options: InternalCanonicalizeOptions) => { return new DefaultMap((variant: Variant): Variant[] => { let replacement = [variant] for (let fn of VARIANT_CANONICALIZATIONS) { @@ -159,7 +395,7 @@ const canonicalizeVariantCache = new DefaultMap((options: SignatureOptions) => { type UtilityCanonicalizationFunction = ( candidate: Candidate, - options: SignatureOptions, + options: InternalCanonicalizeOptions, ) => Candidate const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [ @@ -173,7 +409,7 @@ const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [ optimizeModifier, ] -const canonicalizeUtilityCache = new DefaultMap((options: SignatureOptions) => { +const canonicalizeUtilityCache = new DefaultMap((options: InternalCanonicalizeOptions) => { let designSystem = options.designSystem return new DefaultMap((rawCandidate: string): string => { for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { @@ -219,7 +455,7 @@ const enum Convert { MigrateThemeOnly = 1 << 1, } -function themeToVarUtility(candidate: Candidate, options: SignatureOptions): Candidate { +function themeToVarUtility(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { let convert = converterCache.get(options.designSystem) if (candidate.kind === 'arbitrary') { @@ -251,7 +487,10 @@ function themeToVarUtility(candidate: Candidate, options: SignatureOptions): Can return candidate } -function themeToVarVariant(variant: Variant, options: SignatureOptions): Variant | Variant[] { +function themeToVarVariant( + variant: Variant, + options: InternalCanonicalizeOptions, +): Variant | Variant[] { let convert = converterCache.get(options.designSystem) let iterator = walkVariants(variant) @@ -586,7 +825,7 @@ const spacing = new DefaultMap | }) }) -function arbitraryUtilities(candidate: Candidate, options: SignatureOptions): Candidate { +function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { // We are only interested in arbitrary properties and arbitrary values if ( // Arbitrary property @@ -598,8 +837,8 @@ function arbitraryUtilities(candidate: Candidate, options: SignatureOptions): Ca } let designSystem = options.designSystem - let utilities = preComputedUtilities.get(options) - let signatures = computeUtilitySignature.get(options) + let utilities = preComputedUtilities.get(options.signatureOptions) + let signatures = computeUtilitySignature.get(options.signatureOptions) let targetCandidateString = designSystem.printCandidate(candidate) @@ -803,15 +1042,15 @@ function allVariablesAreUsed( // ---- -function bareValueUtilities(candidate: Candidate, options: SignatureOptions): Candidate { +function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { // We are only interested in bare value utilities if (candidate.kind !== 'functional' || candidate.value?.kind !== 'named') { return candidate } let designSystem = options.designSystem - let utilities = preComputedUtilities.get(options) - let signatures = computeUtilitySignature.get(options) + let utilities = preComputedUtilities.get(options.signatureOptions) + let signatures = computeUtilitySignature.get(options.signatureOptions) let targetCandidateString = designSystem.printCandidate(candidate) @@ -883,9 +1122,12 @@ const DEPRECATION_MAP = new Map([ ['break-words', 'wrap-break-word'], ]) -function deprecatedUtilities(candidate: Candidate, options: SignatureOptions): Candidate { +function deprecatedUtilities( + candidate: Candidate, + options: InternalCanonicalizeOptions, +): Candidate { let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options) + let signatures = computeUtilitySignature.get(options.signatureOptions) let targetCandidateString = printUnprefixedCandidate(designSystem, candidate) @@ -907,10 +1149,13 @@ function deprecatedUtilities(candidate: Candidate, options: SignatureOptions): C // ---- -function arbitraryVariants(variant: Variant, options: SignatureOptions): Variant | Variant[] { +function arbitraryVariants( + variant: Variant, + options: InternalCanonicalizeOptions, +): Variant | Variant[] { let designSystem = options.designSystem - let signatures = computeVariantSignature.get(options) - let variants = preComputedVariants.get(options) + let signatures = computeVariantSignature.get(options.signatureOptions) + let variants = preComputedVariants.get(options.signatureOptions) let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -935,9 +1180,12 @@ function arbitraryVariants(variant: Variant, options: SignatureOptions): Variant // ---- -function dropUnnecessaryDataTypes(candidate: Candidate, options: SignatureOptions): Candidate { +function dropUnnecessaryDataTypes( + candidate: Candidate, + options: InternalCanonicalizeOptions, +): Candidate { let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options) + let signatures = computeUtilitySignature.get(options.signatureOptions) if ( candidate.kind === 'functional' && @@ -961,7 +1209,7 @@ function dropUnnecessaryDataTypes(candidate: Candidate, options: SignatureOption function arbitraryValueToBareValueUtility( candidate: Candidate, - options: SignatureOptions, + options: InternalCanonicalizeOptions, ): Candidate { // We are only interested in functional utilities with arbitrary values if (candidate.kind !== 'functional' || candidate.value?.kind !== 'arbitrary') { @@ -969,7 +1217,7 @@ function arbitraryValueToBareValueUtility( } let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options) + let signatures = computeUtilitySignature.get(options.signatureOptions) let expectedSignature = signatures.get(designSystem.printCandidate(candidate)) if (expectedSignature === null) return candidate @@ -1134,11 +1382,11 @@ function isAttributeSelector(node: SelectorParser.SelectorAstNode): boolean { function modernizeArbitraryValuesVariant( variant: Variant, - options: SignatureOptions, + options: InternalCanonicalizeOptions, ): Variant | Variant[] { let result = [variant] let designSystem = options.designSystem - let signatures = computeVariantSignature.get(options) + let signatures = computeVariantSignature.get(options.signatureOptions) let iterator = walkVariants(variant) for (let [variant, parent] of iterator) { @@ -1493,7 +1741,7 @@ function modernizeArbitraryValuesVariant( // - `/[100%]` → `/100` → // - `/100` → // -function optimizeModifier(candidate: Candidate, options: SignatureOptions): Candidate { +function optimizeModifier(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { // We are only interested in functional or arbitrary utilities with a modifier if ( (candidate.kind !== 'functional' && candidate.kind !== 'arbitrary') || @@ -1503,7 +1751,7 @@ function optimizeModifier(candidate: Candidate, options: SignatureOptions): Cand } let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options) + let signatures = computeUtilitySignature.get(options.signatureOptions) let targetSignature = signatures.get(designSystem.printCandidate(candidate)) let modifier = candidate.modifier @@ -1557,3 +1805,55 @@ function optimizeModifier(candidate: Candidate, options: SignatureOptions): Cand return candidate } + +// Generator that generates all combinations of the given set. Using a generator +// so we can stop early when we found a suitable combination. +// +// NOTE: +// +// 1. Yield biggest combinations first +// 2. Sets of size 1 and 0 are not yielded +function* combinations(arr: T[]): Generator { + let n = arr.length + let limit = 1n << BigInt(n) + + for (let k = n; k >= 2; k--) { + let mask = (1n << BigInt(k)) - 1n + + while (mask < limit) { + let out = [] + for (let i = 0; i < n; i++) { + if ((mask >> BigInt(i)) & 1n) { + out.push(arr[i]) + } + } + yield out + + // Gosper's hack: + // - https://programmingforinsomniacs.blogspot.com/2018/03/gospers-hack-explained.html + // - https://rosettacode.org/wiki/Gosper%27s_hack + // + // We need to generate the next mask in lexicographical order. + let carry = mask & -mask + let ripple = mask + carry + mask = (((ripple ^ mask) >> 2n) / carry) | ripple + } + } +} + +function intersection(a: Set, b: Set): Set { + // @ts-expect-error Set.prototype.intersection is only available in Node.js v22+ + if (typeof a.intersection === 'function') return a.intersection(b) + + // Polyfill for environments that do not support Set.prototype.intersection yet + if (a.size === 0 || b.size === 0) return new Set() + + let result = new Set(a) + for (let item of b) { + if (!result.has(item)) { + result.delete(item) + } + } + + return result +} diff --git a/packages/tailwindcss/src/expand-declaration.test.ts b/packages/tailwindcss/src/expand-declaration.test.ts new file mode 100644 index 000000000..44456f239 --- /dev/null +++ b/packages/tailwindcss/src/expand-declaration.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'vitest' +import { toCss } from './ast' +import { parse } from './css-parser' +import { expandDeclaration } from './expand-declaration' +import { SignatureFeatures } from './signatures' +import { walk, WalkAction } from './walk' + +const css = String.raw + +function expand(input: string, options: SignatureFeatures): string { + let ast = parse(input) + + walk(ast, (node) => { + if (node.kind === 'declaration') { + let result = expandDeclaration(node, options) + if (result) return WalkAction.ReplaceSkip(result) + } + }) + + return toCss(ast) +} + +describe('expand declarations', () => { + let options = SignatureFeatures.ExpandProperties + + test('expand to 4 properties', () => { + let input = css` + .one { + inset: 10px; + } + + .two { + inset: 10px 20px; + } + + .three { + inset: 10px 20px 30px; + } + + .four { + inset: 10px 20px 30px 40px; + } + ` + + expect(expand(input, options)).toMatchInlineSnapshot(` + ".one { + top: 10px; + right: 10px; + bottom: 10px; + left: 10px; + } + .two { + top: 10px; + right: 20px; + bottom: 10px; + left: 20px; + } + .three { + top: 10px; + right: 20px; + bottom: 30px; + left: 20px; + } + .four { + top: 10px; + right: 20px; + bottom: 30px; + left: 40px; + } + " + `) + }) + + test('expand to 2 properties', () => { + let input = css` + .one { + gap: 10px; + } + + .two { + gap: 10px 20px; + } + ` + + expect(expand(input, options)).toMatchInlineSnapshot(` + ".one { + row-gap: 10px; + column-gap: 10px; + } + .two { + row-gap: 10px; + column-gap: 20px; + } + " + `) + }) + + test('expansion with `!important`', () => { + let input = css` + .one { + inset: 10px; + } + + .two { + inset: 10px 20px; + } + + .three { + inset: 10px 20px 30px !important; + } + + .four { + inset: 10px 20px 30px 40px; + } + ` + + expect(expand(input, options)).toMatchInlineSnapshot(` + ".one { + top: 10px; + right: 10px; + bottom: 10px; + left: 10px; + } + .two { + top: 10px; + right: 20px; + bottom: 10px; + left: 20px; + } + .three { + top: 10px !important; + right: 20px !important; + bottom: 30px !important; + left: 20px !important; + } + .four { + top: 10px; + right: 20px; + bottom: 30px; + left: 40px; + } + " + `) + }) +}) + +describe('expand logical properties', () => { + let options = SignatureFeatures.ExpandProperties | SignatureFeatures.LogicalToPhysical + + test('margin-block', () => { + let input = css` + .example { + margin-block: 10px 20px; + } + ` + + expect(expand(input, options)).toMatchInlineSnapshot(` + ".example { + margin-top: 10px; + margin-bottom: 20px; + } + " + `) + }) +}) diff --git a/packages/tailwindcss/src/expand-declaration.ts b/packages/tailwindcss/src/expand-declaration.ts new file mode 100644 index 000000000..1dc5df6f2 --- /dev/null +++ b/packages/tailwindcss/src/expand-declaration.ts @@ -0,0 +1,93 @@ +import { decl, type AstNode } from './ast' +import { SignatureFeatures } from './signatures' +import { segment } from './utils/segment' + +function createPrefixedQuad( + prefix: string, + t = 'top', + r = 'right', + b = 'bottom', + l = 'left', +): Record { + return createBareQuad(`${prefix}-${t}`, `${prefix}-${r}`, `${prefix}-${b}`, `${prefix}-${l}`) +} + +// prettier-ignore +function createBareQuad(t = 'top', r = 'right', b = 'bottom', l = 'left'): Record { + return { + 1: [[t, 0], [r, 0], [b, 0], [l, 0]], + 2: [[t, 0], [r, 1], [b, 0], [l, 1]], + 3: [[t, 0], [r, 1], [b, 2], [l, 1]], + 4: [[t, 0], [r, 1], [b, 2], [l, 3]], + } as const; +} + +// prettier-ignore +function createPair(lhs: string, rhs: string): Record { + return { + 1: [[lhs, 0], [rhs, 0]], + 2: [[lhs, 0], [rhs, 1]], + } as const; +} + +// Depending on the length of the value, map to different properties +let VARIADIC_EXPANSION_MAP: Record> = { + inset: createBareQuad(), + margin: createPrefixedQuad('margin'), + padding: createPrefixedQuad('padding'), + gap: createPair('row-gap', 'column-gap'), +} + +// Depending on the length of the value, map to different properties +let VARIADIC_LOGICAL_EXPANSION_MAP: Record< + string, + Record +> = { + 'inset-block': createPair('top', 'bottom'), + 'inset-inline': createPair('left', 'right'), + 'margin-block': createPair('margin-top', 'margin-bottom'), + 'margin-inline': createPair('margin-left', 'margin-right'), + 'padding-block': createPair('padding-top', 'padding-bottom'), + 'padding-inline': createPair('padding-left', 'padding-right'), +} + +// The entire value is mapped to each property +let LOGICAL_EXPANSION_MAP: Record = { + 'border-block': ['border-bottom', 'border-top'], + 'border-block-color': ['border-bottom-color', 'border-top-color'], + 'border-block-style': ['border-bottom-style', 'border-top-style'], + 'border-block-width': ['border-bottom-width', 'border-top-width'], + 'border-inline': ['border-left', 'border-right'], + 'border-inline-color': ['border-left-color', 'border-right-color'], + 'border-inline-style': ['border-left-style', 'border-right-style'], + 'border-inline-width': ['border-left-width', 'border-right-width'], +} + +export function expandDeclaration( + node: Extract, + options: SignatureFeatures, +): AstNode[] | null { + if (options & SignatureFeatures.LogicalToPhysical) { + if (node.property in VARIADIC_LOGICAL_EXPANSION_MAP) { + let args = segment(node.value!, ' ') + return VARIADIC_LOGICAL_EXPANSION_MAP[node.property][args.length]?.map(([prop, index]) => { + return decl(prop, args[index], node.important) + }) + } + + if (node.property in LOGICAL_EXPANSION_MAP) { + return LOGICAL_EXPANSION_MAP[node.property]?.map((prop) => { + return decl(prop, node.value!, node.important) + }) + } + } + + if (node.property in VARIADIC_EXPANSION_MAP) { + let args = segment(node.value!, ' ') + return VARIADIC_EXPANSION_MAP[node.property][args.length]?.map(([prop, index]) => { + return decl(prop, args[index], node.important) + }) + } + + return null +} diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 0fcc76fc9..3e6a6605d 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -1,8 +1,9 @@ import { substituteAtApply } from './apply' -import { atRule, styleRule, toCss, type AstNode } from './ast' +import { atRule, cloneAstNode, styleRule, toCss, type AstNode } from './ast' import { printArbitraryValue } from './candidate' import { constantFoldDeclaration } from './constant-fold-declaration' import { CompileAstFlags, type DesignSystem } from './design-system' +import { expandDeclaration } from './expand-declaration' import * as SelectorParser from './selector-parser' import { ThemeOptions } from './theme' import { DefaultMap } from './utils/default-map' @@ -12,6 +13,12 @@ import { walk, WalkAction } from './walk' const FLOATING_POINT_PERCENTAGE = /\d*\.\d+(?:[eE][+-]?\d+)?%/g +export enum SignatureFeatures { + None = 0, + ExpandProperties = 1 << 0, + LogicalToPhysical = 1 << 1, +} + export interface SignatureOptions { /** * The root font size in pixels. If provided, `rem` values will be normalized @@ -21,6 +28,11 @@ export interface SignatureOptions { */ rem: number | null + /** + * Features that influence how signatures are computed. + */ + features: SignatureFeatures + /** * The design system to use for computing the signature of candidates. */ @@ -44,7 +56,7 @@ export interface SignatureOptions { // // These produce the same signature, therefore they represent the same utility. export const computeUtilitySignature = new DefaultMap((options: SignatureOptions) => { - let { rem, designSystem } = options + let designSystem = options.designSystem return new DefaultMap((utility) => { try { @@ -70,169 +82,7 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions // Optimize the AST. This is needed such that any internal intermediate // nodes are gone. This will also cleanup declaration nodes with undefined // values or `--tw-sort` declarations. - walk(ast, (node) => { - // Optimize declarations - if (node.kind === 'declaration') { - if (node.value === undefined || node.property === '--tw-sort') { - return WalkAction.Replace([]) - } - - // Normalize percentages by removing unnecessary dots and zeros. - // - // E.g.: `50.0%` → `50%` - else if (node.value.includes('%')) { - FLOATING_POINT_PERCENTAGE.lastIndex = 0 - node.value = node.value.replaceAll( - FLOATING_POINT_PERCENTAGE, - (match) => `${Number(match.slice(0, -1))}%`, - ) - } - } - - // Replace special nodes with its children - else if (node.kind === 'context' || node.kind === 'at-root') { - return WalkAction.Replace(node.nodes) - } - - // Remove comments - else if (node.kind === 'comment') { - return WalkAction.Replace([]) - } - - // Remove at-rules that are not needed for the signature - else if (node.kind === 'at-rule' && node.name === '@property') { - return WalkAction.Replace([]) - } - }) - - // Resolve theme values to their inlined value. - // - // E.g.: - // - // `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` - // `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` - // - // Due to the `@apply` from above, this will become: - // - // ```css - // .example { - // color: oklch(63.7% 0.237 25.331); - // } - // ``` - // - // Which conveniently will be equivalent to: `text-red-500` when we inline - // the value. - // - // Without inlining: - // ```css - // .example { - // color: var(--color-red-500, oklch(63.7% 0.237 25.331)); - // } - // ``` - // - // Inlined: - // ```css - // .example { - // color: oklch(63.7% 0.237 25.331); - // } - // ``` - // - // Recently we made sure that utilities like `text-red-500` also generate - // the fallback value for usage in `@reference` mode. - // - // The second assumption is that if you use `var(--key, fallback)` that - // happens to match a known variable _and_ its inlined value. Then we can - // replace it with the inlined variable. This allows us to handle custom - // `@theme` and `@theme inline` definitions. - walk(ast, (node) => { - // Handle declarations - if (node.kind === 'declaration' && node.value !== undefined) { - if (node.value.includes('var(')) { - let changed = false - let valueAst = ValueParser.parse(node.value) - - let seen = new Set() - walk(valueAst, (valueNode) => { - if (valueNode.kind !== 'function') return - if (valueNode.value !== 'var') return - - // Resolve the underlying value of the variable - if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { - return - } - - let variable = valueNode.nodes[0].value - - // Drop the prefix from the variable name if it is present. The - // internal variable doesn't have the prefix. - if ( - designSystem.theme.prefix && - variable.startsWith(`--${designSystem.theme.prefix}-`) - ) { - variable = variable.slice(`--${designSystem.theme.prefix}-`.length) - } - let variableValue = designSystem.resolveThemeValue(variable) - // Prevent infinite recursion when the variable value contains the - // variable itself. - if (seen.has(variable)) return - seen.add(variable) - if (variableValue === undefined) return // Couldn't resolve the variable - - // Inject variable fallbacks when no fallback is present yet. - // - // A fallback could consist of multiple values. - // - // E.g.: - // - // ``` - // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) - // ``` - { - // More than 1 argument means that a fallback is already present - if (valueNode.nodes.length === 1) { - // Inject the fallback value into the variable lookup - changed = true - valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) - } - } - - // Replace known variable + inlined fallback value with the value - // itself again - { - // We need at least 3 arguments. The variable, the separator and a fallback value. - if (valueNode.nodes.length >= 3) { - let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable - let constructedValue = `${valueNode.nodes[0].value},${variableValue}` - if (nodeAsString === constructedValue) { - changed = true - return WalkAction.Replace(ValueParser.parse(variableValue)) - } - } - } - }) - - // Replace the value with the new value - if (changed) node.value = ValueParser.toCss(valueAst) - } - - // Very basic `calc(…)` constant folding to handle the spacing scale - // multiplier: - // - // Input: `--spacing(4)` - // → `calc(var(--spacing, 0.25rem) * 4)` - // → `calc(0.25rem * 4)` ← this is the case we will see - // after inlining the variable - // → `1rem` - node.value = constantFoldDeclaration(node.value, rem) - - // We will normalize the `node.value`, this is the same kind of logic - // we use when printing arbitrary values. It will remove unnecessary - // whitespace. - // - // Essentially normalizing the `node.value` to a canonical form. - node.value = printArbitraryValue(node.value) - } - }) + canonicalizeAst(ast, options) // Compute the final signature, by generating the CSS for the utility let signature = toCss(ast) @@ -245,6 +95,236 @@ export const computeUtilitySignature = new DefaultMap((options: SignatureOptions }) }) +// Optimize the CSS AST to make it suitable for signature comparison. We want to +// expand declarations, ignore comments, sort declarations etc... +function canonicalizeAst(ast: AstNode[], options: SignatureOptions) { + let { rem, designSystem } = options + + walk(ast, { + enter(node) { + // Optimize declarations + if (node.kind === 'declaration') { + if (node.value === undefined || node.property === '--tw-sort') { + return WalkAction.Replace([]) + } + + if (options.features & SignatureFeatures.ExpandProperties) { + let replacement = expandDeclaration(node, options.features) + if (replacement) return WalkAction.Replace(replacement) + } + + // Normalize percentages by removing unnecessary dots and zeros. + // + // E.g.: `50.0%` → `50%` + if (node.value.includes('%')) { + FLOATING_POINT_PERCENTAGE.lastIndex = 0 + node.value = node.value.replaceAll( + FLOATING_POINT_PERCENTAGE, + (match) => `${Number(match.slice(0, -1))}%`, + ) + } + + // Resolve theme values to their inlined value. + if (node.value.includes('var(')) { + node.value = resolveVariablesInValue(node.value, designSystem) + } + + // Very basic `calc(…)` constant folding to handle the spacing scale + // multiplier: + // + // Input: `--spacing(4)` + // → `calc(var(--spacing, 0.25rem) * 4)` + // → `calc(0.25rem * 4)` ← this is the case we will see + // after inlining the variable + // → `1rem` + node.value = constantFoldDeclaration(node.value, rem) + + // We will normalize the `node.value`, this is the same kind of logic + // we use when printing arbitrary values. It will remove unnecessary + // whitespace. + // + // Essentially normalizing the `node.value` to a canonical form. + node.value = printArbitraryValue(node.value) + } + + // Replace special nodes with its children + else if (node.kind === 'context' || node.kind === 'at-root') { + return WalkAction.Replace(node.nodes) + } + + // Remove comments + else if (node.kind === 'comment') { + return WalkAction.Replace([]) + } + + // Remove at-rules that are not needed for the signature + else if (node.kind === 'at-rule' && node.name === '@property') { + return WalkAction.Replace([]) + } + }, + exit(node) { + if (node.kind === 'rule' || node.kind === 'at-rule') { + node.nodes.sort((a, b) => { + if (a.kind !== 'declaration') return 0 + if (b.kind !== 'declaration') return 0 + return a.property.localeCompare(b.property) + }) + } + }, + }) + + return ast +} + +// Resolve theme values to their inlined value. +// +// E.g.: +// +// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` +// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` +// +// Due to the `@apply` from above, this will become: +// +// ```css +// .example { +// color: oklch(63.7% 0.237 25.331); +// } +// ``` +// +// Which conveniently will be equivalent to: `text-red-500` when we inline +// the value. +// +// Without inlining: +// ```css +// .example { +// color: var(--color-red-500, oklch(63.7% 0.237 25.331)); +// } +// ``` +// +// Inlined: +// ```css +// .example { +// color: oklch(63.7% 0.237 25.331); +// } +// ``` +// +// Recently we made sure that utilities like `text-red-500` also generate +// the fallback value for usage in `@reference` mode. +// +// The second assumption is that if you use `var(--key, fallback)` that +// happens to match a known variable _and_ its inlined value. Then we can +// replace it with the inlined variable. This allows us to handle custom +// `@theme` and `@theme inline` definitions. +function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { + let changed = false + let valueAst = ValueParser.parse(value) + + let seen = new Set() + walk(valueAst, (valueNode) => { + if (valueNode.kind !== 'function') return + if (valueNode.value !== 'var') return + + // Resolve the underlying value of the variable + if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { + return + } + + let variable = valueNode.nodes[0].value + + // Drop the prefix from the variable name if it is present. The + // internal variable doesn't have the prefix. + if (designSystem.theme.prefix && variable.startsWith(`--${designSystem.theme.prefix}-`)) { + variable = variable.slice(`--${designSystem.theme.prefix}-`.length) + } + let variableValue = designSystem.resolveThemeValue(variable) + // Prevent infinite recursion when the variable value contains the + // variable itself. + if (seen.has(variable)) return + seen.add(variable) + if (variableValue === undefined) return // Couldn't resolve the variable + + // Inject variable fallbacks when no fallback is present yet. + // + // A fallback could consist of multiple values. + // + // E.g.: + // + // ``` + // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) + // ``` + { + // More than 1 argument means that a fallback is already present + if (valueNode.nodes.length === 1) { + // Inject the fallback value into the variable lookup + changed = true + valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) + } + } + + // Replace known variable + inlined fallback value with the value + // itself again + { + // We need at least 3 arguments. The variable, the separator and a fallback value. + if (valueNode.nodes.length >= 3) { + let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable + let constructedValue = `${valueNode.nodes[0].value},${variableValue}` + if (nodeAsString === constructedValue) { + changed = true + return WalkAction.Replace(ValueParser.parse(variableValue)) + } + } + } + }) + + // Replace the value with the new value + if (changed) return ValueParser.toCss(valueAst) + return value +} + +// Index all static utilities by property and value +export const staticUtilitiesByPropertyAndValue = new DefaultMap((_optiones: SignatureOptions) => { + return new DefaultMap((_property: string) => { + return new DefaultMap((_value: string) => { + return new Set() + }) + }) +}) + +export const computeUtilityProperties = new DefaultMap((options: SignatureOptions) => { + return new DefaultMap((className) => { + let localPropertyValueLookup = new DefaultMap((_property) => new Set()) + let designSystem = options.designSystem + + if ( + options.designSystem.theme.prefix && + !className.startsWith(options.designSystem.theme.prefix) + ) { + className = `${options.designSystem.theme.prefix}:${className}` + } + let parsed = designSystem.parseCandidate(className) + if (parsed.length === 0) return localPropertyValueLookup + + walk( + canonicalizeAst( + designSystem.compileAstNodes(parsed[0]).map((x) => cloneAstNode(x.node)), + options, + ), + (node) => { + if (node.kind === 'declaration') { + localPropertyValueLookup.get(node.property).add(node.value!) + staticUtilitiesByPropertyAndValue + .get(options) + .get(node.property) + .get(node.value!) + .add(className) + } + }, + ) + + return localPropertyValueLookup + }) +}) + // For all static utilities in the system, compute a lookup table that maps the // utility signature to the utility name. This is used to find the utility name // for a given utility signature. @@ -257,6 +337,9 @@ export const preComputedUtilities = new DefaultMap((options: SignatureOptions) = let signatures = computeUtilitySignature.get(options) let lookup = new DefaultMap(() => []) + // Right now all plugins are implemented using functions so they are a black + // box. Let's use the `getClassList` and consider every known suggestion as a + // static utility for now. for (let [className, meta] of designSystem.getClassList()) { let signature = signatures.get(className) if (typeof signature !== 'string') continue @@ -272,6 +355,7 @@ export const preComputedUtilities = new DefaultMap((options: SignatureOptions) = } lookup.get(signature).push(className) + computeUtilityProperties.get(options).get(className) for (let modifier of meta.modifiers) { // Modifiers representing numbers can be computed and don't need to be @@ -285,6 +369,7 @@ export const preComputedUtilities = new DefaultMap((options: SignatureOptions) = let signature = signatures.get(classNameWithModifier) if (typeof signature !== 'string') continue lookup.get(signature).push(classNameWithModifier) + computeUtilityProperties.get(options).get(classNameWithModifier) } }