Collapse multiple utilities (#19147)

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
<div class="border m-0 rounded p-4"></div>
```

Then we can collapse this to:
```html
<div class="example m-0"></div>
```

But even if we used:
```html
<div class="border m-0 rounded pt-4 pb-4 px-4"></div>
```

It would still collapse to:
```html
<div class="example m-0"></div>
```

...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.
This commit is contained in:
Robin Malfait 2025-10-20 17:15:31 +02:00 • committed by GitHub
parent 6c4081421e
commit 66c18ca8a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 902 additions and 198 deletions

View file

@ -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', () => {

View file

@ -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<string>()
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<string>()
})
})
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<string>()
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<string> | 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<number, Set<number>>((key) => new Set<number>([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<string>(candidates)
let drop = new Set<string>()
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<DesignSystem, DefaultMap<string, number | null> |
})
})
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` → <empty>
// - `/100` → <empty>
//
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<T>(arr: T[]): Generator<T[]> {
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<T>(a: Set<T>, b: Set<T>): Set<T> {
// @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<T>()
let result = new Set<T>(a)
for (let item of b) {
if (!result.has(item)) {
result.delete(item)
}
}
return result
}

View file

@ -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;
}
"
`)
})
})

View file

@ -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<number, [prop: string, index: number][]> {
return createBareQuad(`${prefix}-${t}`, `${prefix}-${r}`, `${prefix}-${b}`, `${prefix}-${l}`)
}
// prettier-ignore
function createBareQuad(t = 'top', r = 'right', b = 'bottom', l = 'left'): Record<number, [prop: string, index: number][]> {
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<number, [prop: string, index: number][]> {
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<string, Record<number, [prop: string, index: number][]>> = {
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<number, [prop: string, index: number][]>
> = {
'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<string, string[]> = {
'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<AstNode, { kind: 'declaration' }>,
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
}

View file

@ -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<string, string | Symbol>((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<string>()
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<string>()
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<string>()
})
})
})
export const computeUtilityProperties = new DefaultMap((options: SignatureOptions) => {
return new DefaultMap((className) => {
let localPropertyValueLookup = new DefaultMap((_property) => new Set<string>())
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<string, string[]>(() => [])
// 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)
}
}