diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 0581e9734faa..9eac5cb92d40 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 727f3ca25a57..3977ba2ecd1e 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 +} + +enum Features { + /** + * No features enabled (default) + */ + None = 0, + + /** + * Collapse multiple utilities into a single utility if possible. + */ + CollapseUtilities = 1 << 0, } -const optionsCache = new DefaultMap((designSystem: DesignSystem) => { +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 result.size <= 1 || !(canonicalizeOptions.features & Features.CollapseUtilities) + ? Array.from(result) + : collapseCandidates(canonicalizeOptions, Array.from(result)) +} + +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: SignatureOptions) => { +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 000000000000..44456f239025 --- /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 000000000000..1dc5df6f2e23 --- /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 0fcc76fc9871..3e6a6605d381 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,178 +82,246 @@ 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([]) - } + canonicalizeAst(ast, options) - // 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))}%`, - ) - } + // Compute the final signature, by generating the CSS for the utility + let signature = toCss(ast) + return signature + } catch { + // A unique symbol is returned to ensure that 2 signatures resulting in + // `null` are not considered equal. + return Symbol() + } + }) +}) + +// 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([]) } - // Replace special nodes with its children - else if (node.kind === 'context' || node.kind === 'at-root') { - return WalkAction.Replace(node.nodes) + if (options.features & SignatureFeatures.ExpandProperties) { + let replacement = expandDeclaration(node, options.features) + if (replacement) return WalkAction.Replace(replacement) } - // Remove comments - else if (node.kind === 'comment') { - return WalkAction.Replace([]) + // 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))}%`, + ) } - // 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. + if (node.value.includes('var(')) { + node.value = resolveVariablesInValue(node.value, designSystem) } - }) - // 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 - } + // 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) + } - let variable = valueNode.nodes[0].value + // Replace special nodes with its children + else if (node.kind === 'context' || node.kind === 'at-root') { + return WalkAction.Replace(node.nodes) + } - // 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}`)) - } - } + // Remove comments + else if (node.kind === 'comment') { + return WalkAction.Replace([]) + } - // 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)) - } - } - } - }) + // 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) + }) + } + }, + }) - // Replace the value with the new value - if (changed) node.value = ValueParser.toCss(valueAst) - } + 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}`)) + } + } - // 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 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)) } - }) + } + } + }) - // Compute the final signature, by generating the CSS for the utility - let signature = toCss(ast) - return signature - } catch { - // A unique symbol is returned to ensure that 2 signatures resulting in - // `null` are not considered equal. - return Symbol() + // 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 }) }) @@ -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) } }