diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f52b544ec7..8399fa1c205a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve backwards compatibility for `content` theme key from JS configs ([#19381](https://github.com/tailwindlabs/tailwindcss/pull/19381)) - Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344)) - Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379)) +- Canonicalization: combine `text-*` and `leading-*` classes ([#19396](https://github.com/tailwindlabs/tailwindcss/pull/19396)) ### Added diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index fd5826c4dce6..b8ca59d67880 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { describe, expect, test } from 'vitest' import { __unstable__loadDesignSystem } from '.' +import { cartesian } from './cartesian' import type { CanonicalizeOptions } from './intellisense' import { DefaultMap } from './utils/default-map' @@ -54,7 +55,7 @@ const DEFAULT_CANONICALIZATION_OPTIONS: CanonicalizeOptions = { } describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => { - let testName = '`%s` → `%s` (%#)' + let testName = '%s → %s (%#)' if (strategy === 'with-variant') { testName = testName.replaceAll('%s', 'focus:%s') } else if (strategy === 'important') { @@ -1025,37 +1026,69 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', }) }) - test.each([ - // 4 to 1 - ['mt-1 mr-1 mb-1 ml-1', 'm-1'], + describe('combine to shorthand utilities', () => { + test.each([ + // 4 to 1 + ['mt-1 mr-1 mb-1 ml-1', 'm-1'], - // 2 to 1 - ['mt-1 mb-1', 'my-1'], + // 2 to 1 + ['mt-1 mb-1', 'my-1'], - // Different order as above - ['mb-1 mt-1', 'my-1'], + // Different order as above + ['mb-1 mt-1', 'my-1'], - // To completely different utility - ['w-4 h-4', 'size-4'], + // 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'], + // 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 + ['[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`', - { timeout }, - async (candidates, expected) => { + // Arbitrary properties to combined class with modifier + ['[font-size:14px] [line-height:1.625]', 'text-sm/relaxed'], + ])(testName, { timeout }, async (candidates, expected) => { let input = css` @import 'tailwindcss'; ` await expectCombinedCanonicalization(input, candidates, expected) - }, - ) + }) + }) + + describe('font-size/line-height to text-{x}/{y}', () => { + test.each([ + ...Array.from( + cartesian( + ['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'], + ['[line-height:28px]', 'leading-[28px]', 'leading-7'], + ), + ).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/7']), + ...Array.from( + cartesian( + ['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'], + ['[line-height:28px]', 'leading-[28px]', 'leading-7'], + ), + ).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/7']), + ...Array.from( + cartesian( + ['[font-size:14px]', 'text-[14px]', 'text-[14px]/6', 'text-sm', 'text-sm/6'], + ['[line-height:28.5px]', 'leading-[28.5px]'], + ), + ).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-sm/[28.5px]']), + ...Array.from( + cartesian( + ['[font-size:15px]', 'text-[15px]', 'text-[15px]/6'], + ['[line-height:28.5px]', 'leading-[28.5px]'], + ), + ).map((classes) => [classes.join(' ').padEnd(40, ' '), 'text-[15px]/[28.5px]']), + ])(testName, { timeout }, async (candidates, expected) => { + let input = css` + @import 'tailwindcss'; + ` + await expectCombinedCanonicalization(input, candidates.trim(), expected) + }) + }) }) describe('theme to var', () => { diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 437ec60b442e..61212998545d 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -107,7 +107,10 @@ interface DesignSystem extends BaseDesignSystem { } } -export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem): DesignSystem { +export function prepareDesignSystemStorage( + baseDesignSystem: BaseDesignSystem, + options?: CanonicalizeOptions, +): DesignSystem { let designSystem = baseDesignSystem as DesignSystem designSystem.storage[SIGNATURE_OPTIONS_KEY] ??= createSignatureOptionsCache() @@ -116,7 +119,7 @@ export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem): designSystem.storage[CANONICALIZE_VARIANT_KEY] ??= createCanonicalizeVariantCache() designSystem.storage[CANONICALIZE_UTILITY_KEY] ??= createCanonicalizeUtilityCache() designSystem.storage[CONVERTER_KEY] ??= createConverterCache(designSystem) - designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem) + designSystem.storage[SPACING_KEY] ??= createSpacingCache(designSystem, options) designSystem.storage[UTILITY_SIGNATURE_KEY] ??= createUtilitySignatureCache(designSystem) designSystem.storage[STATIC_UTILITIES_KEY] ??= createStaticUtilitiesCache() designSystem.storage[UTILITY_PROPERTIES_KEY] ??= createUtilityPropertiesCache(designSystem) @@ -144,7 +147,7 @@ export function createSignatureOptions( if (options?.collapse) features |= SignatureFeatures.ExpandProperties if (options?.logicalToPhysical) features |= SignatureFeatures.LogicalToPhysical - let designSystem = prepareDesignSystemStorage(baseDesignSystem) + let designSystem = prepareDesignSystemStorage(baseDesignSystem, options) return designSystem.storage[SIGNATURE_OPTIONS_KEY].get(options?.rem ?? null).get(features) } @@ -255,6 +258,56 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st computeUtilitiesPropertiesLookup.get(candidate), ) + // Hard-coded optimization: if any candidate sets `line-height` and another + // candidate sets `font-size`, we pre-compute the `text-*` utilities with + // this line-height to try and collapse to those combined values. + if (candidatePropertiesValues.some((x) => x.has('line-height'))) { + let fontSizeNames = designSystem.theme.keysInNamespaces(['--text']) + if (fontSizeNames.length > 0) { + let interestingLineHeights = new Set() + let seenLineHeights = new Set() + for (let pairs of candidatePropertiesValues) { + for (let lineHeight of pairs.get('line-height')) { + if (seenLineHeights.has(lineHeight)) continue + seenLineHeights.add(lineHeight) + + let bareValue = designSystem.storage[SPACING_KEY]?.get(lineHeight) ?? null + if (bareValue !== null) { + if (isValidSpacingMultiplier(bareValue)) { + interestingLineHeights.add(bareValue) + + for (let name of fontSizeNames) { + computeUtilitiesPropertiesLookup.get(`text-${name}/${bareValue}`) + } + } else { + interestingLineHeights.add(lineHeight) + + for (let name of fontSizeNames) { + computeUtilitiesPropertiesLookup.get(`text-${name}/[${lineHeight}]`) + } + } + } + } + } + + let seenFontSizes = new Set() + for (let pairs of candidatePropertiesValues) { + for (let fontSize of pairs.get('font-size')) { + if (seenFontSizes.has(fontSize)) continue + seenFontSizes.add(fontSize) + + for (let lineHeight of interestingLineHeights) { + if (isValidSpacingMultiplier(lineHeight)) { + computeUtilitiesPropertiesLookup.get(`text-[${fontSize}]/${lineHeight}`) + } else { + computeUtilitiesPropertiesLookup.get(`text-[${fontSize}]/[${lineHeight}]`) + } + } + } + } + } + } + // 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. @@ -262,17 +315,20 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st // 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) + for (let property of propertyValues.keys()) { + let otherUtilities = new Set() + for (let group of staticUtilities.get(property).values()) { + for (let candidate of group) { + otherUtilities.add(candidate) + } + } - if (result === null) result = new Set(otherUtilities) - else result = intersection(result, otherUtilities) + if (result === null) result = 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! - } + // 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! }) @@ -286,11 +342,10 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st // 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 i = 0; i < otherUtilities.length; i++) { + let current = otherUtilities[i] + for (let j = i + 1; j < otherUtilities.length; j++) { + let other = otherUtilities[j] for (let property of current) { if (other.has(property)) { @@ -881,17 +936,25 @@ function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candida const SPACING_KEY = Symbol() function createSpacingCache( designSystem: DesignSystem, + options?: CanonicalizeOptions, ): DesignSystem['storage'][typeof SPACING_KEY] { let spacingMultiplier = designSystem.resolveThemeValue('--spacing') if (spacingMultiplier === undefined) return null + spacingMultiplier = constantFoldDeclaration(spacingMultiplier, options?.rem ?? null) + let parsed = dimensions.get(spacingMultiplier) if (!parsed) return null let [value, unit] = parsed return new DefaultMap((input) => { - let parsed = dimensions.get(input) + // If we already know that the spacing multiplier is 0, all spacing + // multipliers will also be 0. No need to even try and parse/canonicalize + // the input value. + if (value === 0) return null + + let parsed = dimensions.get(constantFoldDeclaration(input, options?.rem ?? null)) if (!parsed) return null let [myValue, myUnit] = parsed @@ -998,30 +1061,12 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO candidate.kind === 'functional' && candidate.value?.kind === 'arbitrary' ) { - let spacingMultiplier = designSystem.resolveThemeValue('--spacing') - if (spacingMultiplier !== undefined) { - // Canonicalizing the spacing multiplier allows us to handle both - // `--spacing: 0.25rem` and `--spacing: 4px` values correctly. - let canonicalizedSpacingMultiplier = constantFoldDeclaration( - spacingMultiplier, - options.signatureOptions.rem, - ) - - let canonicalizedValue = constantFoldDeclaration(value, options.signatureOptions.rem) - let valueDimension = dimensions.get(canonicalizedValue) - let spacingMultiplierDimension = dimensions.get(canonicalizedSpacingMultiplier) - if ( - valueDimension && - spacingMultiplierDimension && - valueDimension[1] === spacingMultiplierDimension[1] && // Ensure the units match - spacingMultiplierDimension[0] !== 0 - ) { - let bareValue = `${valueDimension[0] / spacingMultiplierDimension[0]}` - if (isValidSpacingMultiplier(bareValue)) { - yield Object.assign({}, candidate, { - value: { kind: 'named', value: bareValue, fraction: null }, - }) - } + let bareValue = designSystem.storage[SPACING_KEY]?.get(value) ?? null + if (bareValue !== null) { + if (isValidSpacingMultiplier(bareValue)) { + yield Object.assign({}, candidate, { + value: { kind: 'named', value: bareValue, fraction: null }, + }) } } } @@ -2093,6 +2138,26 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si }, exit(node) { if (node.kind === 'rule' || node.kind === 'at-rule') { + // Remove declarations that are re-defined again later. + // + // This could maybe result in unwanted behavior (because similar + // properties typically exist for backwards compatibility), but for + // signature purposes we can assume that the last declaration wins. + if (node.nodes.length > 1) { + let seen = new Set() + for (let i = node.nodes.length - 1; i >= 0; i--) { + let child = node.nodes[i] + if (child.kind !== 'declaration') continue + if (child.value === undefined) continue + + if (seen.has(child.property)) { + node.nodes.splice(i, 1) + } + seen.add(child.property) + } + } + + // Sort declarations alphabetically by property name node.nodes.sort((a, b) => { if (a.kind !== 'declaration') return 0 if (b.kind !== 'declaration') return 0 diff --git a/packages/tailwindcss/src/cartesian.ts b/packages/tailwindcss/src/cartesian.ts new file mode 100644 index 000000000000..f0f7bbba4d1e --- /dev/null +++ b/packages/tailwindcss/src/cartesian.ts @@ -0,0 +1,45 @@ +type CartesianInput = readonly unknown[][] + +type CartesianResult = T extends [ + infer Head extends unknown[], + ...infer Tail extends CartesianInput, +] + ? [Head[number], ...CartesianResult] + : [] + +export function* cartesian(...sets: T): Generator> { + let n = sets.length + if (n === 0) return + + // If any input set is empty, the Cartesian product is empty. + if (sets.some((set) => set.length === 0)) { + return + } + + // Index lookup + let idx = Array(n).fill(0) + + while (true) { + // Compute current combination + let result = [] as CartesianResult + for (let i = 0; i < n; i++) { + result[i] = sets[i][idx[i]] + } + yield result + + // Update index vector + let k = n - 1 + while (k >= 0) { + idx[k]++ + if (idx[k] < sets[k].length) { + break + } + idx[k] = 0 + k-- + } + + if (k < 0) { + return + } + } +}