diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts index c1b9669ac466..58efb0421253 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -1,23 +1,23 @@ import { cloneCandidate } from '../../../../tailwindcss/src/candidate' -import { createSignatureOptions } from '../../../../tailwindcss/src/canonicalize-candidates' +import { + PRE_COMPUTED_VARIANTS_KEY, + prepareDesignSystemStorage, + VARIANT_SIGNATURE_KEY, +} from '../../../../tailwindcss/src/canonicalize-candidates' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { - computeVariantSignature, - preComputedVariants, -} from '../../../../tailwindcss/src/signatures' import type { Writable } from '../../../../tailwindcss/src/types' import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' import { walkVariants } from '../../utils/walk-variants' export function migrateArbitraryVariants( - designSystem: DesignSystem, + baseDesignSystem: DesignSystem, _userConfig: Config | null, rawCandidate: string, ): string { - let signatureOptions = createSignatureOptions(designSystem) - let signatures = computeVariantSignature.get(signatureOptions) - let variants = preComputedVariants.get(signatureOptions) + let designSystem = prepareDesignSystemStorage(baseDesignSystem) + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { // We are only interested in the variants diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index 6060d8020cd1..2b39094d45ba 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -1,9 +1,12 @@ import fs from 'node:fs/promises' import path, { extname } from 'node:path' -import { createSignatureOptions } from '../../../../tailwindcss/src/canonicalize-candidates' +import { + createSignatureOptions, + prepareDesignSystemStorage, + UTILITY_SIGNATURE_KEY, +} from '../../../../tailwindcss/src/canonicalize-candidates' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' -import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string' import { extractRawCandidates } from './candidates' @@ -40,7 +43,8 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateModernizeArbitraryValues, ] -let migrateCached = new DefaultMap((designSystem: DesignSystem) => { +let migrateCached = new DefaultMap((baseDesignSystem: DesignSystem) => { + let designSystem = prepareDesignSystemStorage(baseDesignSystem) let options = createSignatureOptions(designSystem) return new DefaultMap((userConfig: Config | null) => { @@ -57,7 +61,7 @@ let migrateCached = new DefaultMap((designSystem: DesignSystem) => { // Verify that the candidate actually makes sense at all. E.g.: `duration` // is not a valid candidate, but it will parse because `duration-` // exists. - let signature = computeUtilitySignature.get(options).get(rawCandidate) + let signature = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options).get(rawCandidate) if (typeof signature !== 'string') return original return rawCandidate diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 3977ba2ecd1e..996859c539cd 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,7 +1,10 @@ +import { substituteAtApply } from './apply' +import { atRule, cloneAstNode, styleRule, toCss, type AstNode } from './ast' import * as AttributeSelectorParser from './attribute-selector-parser' import { cloneCandidate, cloneVariant, + printArbitraryValue, printModifier, type Candidate, type CandidateModifier, @@ -9,18 +12,12 @@ import { type Variant, } from './candidate' import { keyPathToCssProperty } from './compat/apply-config-to-theme' -import type { DesignSystem } from './design-system' +import { constantFoldDeclaration } from './constant-fold-declaration' +import type { DesignSystem as BaseDesignSystem } from './design-system' +import { CompileAstFlags } from './design-system' +import { expandDeclaration } from './expand-declaration' import * as SelectorParser from './selector-parser' -import { - computeUtilityProperties, - computeUtilitySignature, - computeVariantSignature, - preComputedUtilities, - preComputedVariants, - SignatureFeatures, - staticUtilitiesByPropertyAndValue, - type SignatureOptions, -} from './signatures' +import { ThemeOptions } from './theme' import type { Writable } from './types' import { DefaultMap } from './utils/default-map' import { dimensions } from './utils/dimensions' @@ -74,61 +71,120 @@ interface InternalCanonicalizeOptions { signatureOptions: SignatureOptions } -const signatureOptionsCache = new DefaultMap((designSystem: DesignSystem) => { - return new DefaultMap((rem: number | null = null) => { +interface DesignSystem extends BaseDesignSystem { + storage: { + [SIGNATURE_OPTIONS_KEY]: DefaultMap< + number | null, // Rem value + DefaultMap + > + [INTERNAL_OPTIONS_KEY]: DefaultMap< + SignatureOptions, + DefaultMap + > + [CANONICALIZE_CANDIDATE_KEY]: DefaultMap< + InternalCanonicalizeOptions, + DefaultMap + > + [CANONICALIZE_VARIANT_KEY]: DefaultMap< + InternalCanonicalizeOptions, + DefaultMap + > + [CANONICALIZE_UTILITY_KEY]: DefaultMap> + [CONVERTER_KEY]: (input: string, options?: Convert) => [string, CandidateModifier | null] + [SPACING_KEY]: DefaultMap | null + [UTILITY_SIGNATURE_KEY]: DefaultMap> + [STATIC_UTILITIES_KEY]: DefaultMap< + SignatureOptions, + DefaultMap>> + > + [UTILITY_PROPERTIES_KEY]: DefaultMap< + SignatureOptions, + DefaultMap>> + > + [PRE_COMPUTED_UTILITIES_KEY]: DefaultMap> + [VARIANT_SIGNATURE_KEY]: DefaultMap + [PRE_COMPUTED_VARIANTS_KEY]: DefaultMap + } +} + +export function prepareDesignSystemStorage(baseDesignSystem: BaseDesignSystem): DesignSystem { + let designSystem = baseDesignSystem as DesignSystem + + designSystem.storage[SIGNATURE_OPTIONS_KEY] ??= createSignatureOptionsCache() + designSystem.storage[INTERNAL_OPTIONS_KEY] ??= createInternalOptionsCache(designSystem) + designSystem.storage[CANONICALIZE_CANDIDATE_KEY] ??= createCanonicalizeCandidateCache() + 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[UTILITY_SIGNATURE_KEY] ??= createUtilitySignatureCache(designSystem) + designSystem.storage[STATIC_UTILITIES_KEY] ??= createStaticUtilitiesCache() + designSystem.storage[UTILITY_PROPERTIES_KEY] ??= createUtilityPropertiesCache(designSystem) + designSystem.storage[PRE_COMPUTED_UTILITIES_KEY] ??= createPreComputedUtilitiesCache(designSystem) + designSystem.storage[VARIANT_SIGNATURE_KEY] ??= createVariantSignatureCache(designSystem) + designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] ??= createPreComputedVariantsCache(designSystem) + + return designSystem +} + +const SIGNATURE_OPTIONS_KEY = Symbol() +function createSignatureOptionsCache(): DesignSystem['storage'][typeof SIGNATURE_OPTIONS_KEY] { + return new DefaultMap((rem: number | null) => { return new DefaultMap((features: SignatureFeatures) => { - return { designSystem, rem, features } satisfies SignatureOptions + return { rem, features } satisfies SignatureOptions }) }) -}) +} export function createSignatureOptions( - designSystem: DesignSystem, + baseDesignSystem: BaseDesignSystem, options?: CanonicalizeOptions, ): SignatureOptions { 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) + let designSystem = prepareDesignSystemStorage(baseDesignSystem) + + return designSystem.storage[SIGNATURE_OPTIONS_KEY].get(options?.rem ?? null).get(features) } -const internalOptionsCache = new DefaultMap((designSystem: DesignSystem) => { +const INTERNAL_OPTIONS_KEY = Symbol() +function createInternalOptionsCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof INTERNAL_OPTIONS_KEY] { return new DefaultMap((signatureOptions: SignatureOptions) => { return new DefaultMap((features: Features) => { - return { - features, - designSystem, - signatureOptions, - } satisfies InternalCanonicalizeOptions + return { features, designSystem, signatureOptions } satisfies InternalCanonicalizeOptions }) }) -}) +} function createCanonicalizeOptions( - designSystem: DesignSystem, + baseDesignSystem: BaseDesignSystem, signatureOptions: SignatureOptions, options?: CanonicalizeOptions, ) { let features = Features.None if (options?.collapse) features |= Features.CollapseUtilities - return internalOptionsCache.get(designSystem).get(signatureOptions).get(features) + let designSystem = prepareDesignSystemStorage(baseDesignSystem) + + return designSystem.storage[INTERNAL_OPTIONS_KEY].get(signatureOptions).get(features) } export function canonicalizeCandidates( - designSystem: DesignSystem, + baseDesignSystem: BaseDesignSystem, candidates: string[], options?: CanonicalizeOptions, ): string[] { - let signatureOptions = createSignatureOptions(designSystem, options) - let canonicalizeOptions = createCanonicalizeOptions(designSystem, signatureOptions, options) + let signatureOptions = createSignatureOptions(baseDesignSystem, options) + let canonicalizeOptions = createCanonicalizeOptions(baseDesignSystem, signatureOptions, options) + + let designSystem = prepareDesignSystemStorage(baseDesignSystem) let result = new Set() - let cache = canonicalizeCandidateCache.get(canonicalizeOptions) + let cache = designSystem.storage[CANONICALIZE_CANDIDATE_KEY].get(canonicalizeOptions) for (let candidate of candidates) { result.add(cache.get(candidate)) } @@ -140,6 +196,7 @@ export function canonicalizeCandidates( function collapseCandidates(options: InternalCanonicalizeOptions, candidates: string[]): string[] { if (candidates.length <= 1) return candidates + let designSystem = options.designSystem // To keep things simple, we group candidates such that we only collapse // candidates with the same variants and important modifier together. @@ -186,8 +243,9 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st function collapseGroup(candidates: string[]) { let signatureOptions = options.signatureOptions - let computeUtilitiesPropertiesLookup = computeUtilityProperties.get(signatureOptions) - let staticUtilities = staticUtilitiesByPropertyAndValue.get(signatureOptions) + let computeUtilitiesPropertiesLookup = + designSystem.storage[UTILITY_PROPERTIES_KEY].get(signatureOptions) + let staticUtilities = designSystem.storage[STATIC_UTILITIES_KEY].get(signatureOptions) // For each candidate, compute the used properties and values. E.g.: `mt-1` → `margin-top` → `0.25rem` // @@ -269,7 +327,9 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st let potentialReplacements = combo.flatMap((idx) => otherUtilities[idx]).reduce(intersection) - let collapsedSignature = computeUtilitySignature.get(signatureOptions).get( + let collapsedSignature = designSystem.storage[UTILITY_SIGNATURE_KEY].get( + signatureOptions, + ).get( combo .map((idx) => candidates[idx]) .sort((a, z) => a.localeCompare(z)) // Sort to increase cache hits @@ -277,7 +337,8 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st ) for (let replacement of potentialReplacements) { - let signature = computeUtilitySignature.get(signatureOptions).get(replacement) + let signature = + designSystem.storage[UTILITY_SIGNATURE_KEY].get(signatureOptions).get(replacement) if (signature !== collapsedSignature) continue // Not a safe replacement // We can replace all items in the combo with the replacement @@ -300,66 +361,69 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st } } -const canonicalizeCandidateCache = new DefaultMap((options: InternalCanonicalizeOptions) => { - let ds = options.designSystem - let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' - let variantCache = canonicalizeVariantCache.get(options) - let utilityCache = canonicalizeUtilityCache.get(options) - - return new DefaultMap((rawCandidate: string, self) => { - for (let candidate of ds.parseCandidate(rawCandidate)) { - let variants = candidate.variants - .slice() - .reverse() - .flatMap((variant) => variantCache.get(variant)) - let important = candidate.important - - // Canonicalize the base candidate (utility), and re-attach the variants - // and important flag afterwards. This way we can maximize cache hits for - // the base candidate and each individual variant. - if (important || variants.length > 0) { - let canonicalizedUtility = self.get( - ds.printCandidate({ ...candidate, variants: [], important: false }), - ) +const CANONICALIZE_CANDIDATE_KEY = Symbol() +function createCanonicalizeCandidateCache(): DesignSystem['storage'][typeof CANONICALIZE_CANDIDATE_KEY] { + return new DefaultMap((options: InternalCanonicalizeOptions) => { + let ds = options.designSystem + let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' + let variantCache = ds.storage[CANONICALIZE_VARIANT_KEY].get(options) + let utilityCache = ds.storage[CANONICALIZE_UTILITY_KEY].get(options) + + return new DefaultMap((rawCandidate: string, self) => { + for (let candidate of ds.parseCandidate(rawCandidate)) { + let variants = candidate.variants + .slice() + .reverse() + .flatMap((variant) => variantCache.get(variant)) + let important = candidate.important + + // Canonicalize the base candidate (utility), and re-attach the variants + // and important flag afterwards. This way we can maximize cache hits for + // the base candidate and each individual variant. + if (important || variants.length > 0) { + let canonicalizedUtility = self.get( + ds.printCandidate({ ...candidate, variants: [], important: false }), + ) + + // Rebuild the final candidate + let result = canonicalizedUtility + + // Remove the prefix if there are variants, because the variants exist + // between the prefix and the base candidate. + if (ds.theme.prefix !== null && variants.length > 0) { + result = result.slice(prefix.length) + } - // Rebuild the final candidate - let result = canonicalizedUtility + // Re-attach the variants + if (variants.length > 0) { + result = `${variants.map((v) => ds.printVariant(v)).join(':')}:${result}` + } - // Remove the prefix if there are variants, because the variants exist - // between the prefix and the base candidate. - if (ds.theme.prefix !== null && variants.length > 0) { - result = result.slice(prefix.length) - } + // Re-attach the important flag + if (important) { + result += '!' + } - // Re-attach the variants - if (variants.length > 0) { - result = `${variants.map((v) => ds.printVariant(v)).join(':')}:${result}` - } + // Re-attach the prefix if there were variants + if (ds.theme.prefix !== null && variants.length > 0) { + result = `${prefix}${result}` + } - // Re-attach the important flag - if (important) { - result += '!' + return result } - // Re-attach the prefix if there were variants - if (ds.theme.prefix !== null && variants.length > 0) { - result = `${prefix}${result}` + // We are guaranteed to have no variants and no important flag, just the + // base candidate left to canonicalize. + let result = utilityCache.get(rawCandidate) + if (result !== rawCandidate) { + return result } - - return result } - // We are guaranteed to have no variants and no important flag, just the - // base candidate left to canonicalize. - let result = utilityCache.get(rawCandidate) - if (result !== rawCandidate) { - return result - } - } - - return rawCandidate + return rawCandidate + }) }) -}) +} type VariantCanonicalizationFunction = ( variant: Variant, @@ -373,25 +437,28 @@ const VARIANT_CANONICALIZATIONS: VariantCanonicalizationFunction[] = [ arbitraryVariants, ] -const canonicalizeVariantCache = new DefaultMap((options: InternalCanonicalizeOptions) => { - return new DefaultMap((variant: Variant): Variant[] => { - let replacement = [variant] - for (let fn of VARIANT_CANONICALIZATIONS) { - for (let current of replacement.splice(0)) { - // A single variant can result in multiple variants, e.g.: - // `[&>[data-selected]]:flex` → `*:data-selected:flex` - let result = fn(cloneVariant(current), options) - if (Array.isArray(result)) { - replacement.push(...result) - continue - } else { - replacement.push(result) +const CANONICALIZE_VARIANT_KEY = Symbol() +function createCanonicalizeVariantCache(): DesignSystem['storage'][typeof CANONICALIZE_VARIANT_KEY] { + return new DefaultMap((options: InternalCanonicalizeOptions) => { + return new DefaultMap((variant: Variant): Variant[] => { + let replacement = [variant] + for (let fn of VARIANT_CANONICALIZATIONS) { + for (let current of replacement.splice(0)) { + // A single variant can result in multiple variants, e.g.: + // `[&>[data-selected]]:flex` → `*:data-selected:flex` + let result = fn(cloneVariant(current), options) + if (Array.isArray(result)) { + replacement.push(...result) + continue + } else { + replacement.push(result) + } } } - } - return replacement + return replacement + }) }) -}) +} type UtilityCanonicalizationFunction = ( candidate: Candidate, @@ -409,25 +476,28 @@ const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [ optimizeModifier, ] -const canonicalizeUtilityCache = new DefaultMap((options: InternalCanonicalizeOptions) => { - let designSystem = options.designSystem - return new DefaultMap((rawCandidate: string): string => { - for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { - let replacement = cloneCandidate(readonlyCandidate) as Writable +const CANONICALIZE_UTILITY_KEY = Symbol() +function createCanonicalizeUtilityCache(): DesignSystem['storage'][typeof CANONICALIZE_UTILITY_KEY] { + return new DefaultMap((options: InternalCanonicalizeOptions) => { + let designSystem = options.designSystem + return new DefaultMap((rawCandidate: string): string => { + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { + let replacement = cloneCandidate(readonlyCandidate) as Writable - for (let fn of UTILITY_CANONICALIZATIONS) { - replacement = fn(replacement, options) - } + for (let fn of UTILITY_CANONICALIZATIONS) { + replacement = fn(replacement, options) + } - let canonicalizedCandidate = designSystem.printCandidate(replacement) - if (rawCandidate !== canonicalizedCandidate) { - return canonicalizedCandidate + let canonicalizedCandidate = designSystem.printCandidate(replacement) + if (rawCandidate !== canonicalizedCandidate) { + return canonicalizedCandidate + } } - } - return rawCandidate + return rawCandidate + }) }) -}) +} // ---- @@ -456,7 +526,7 @@ const enum Convert { } function themeToVarUtility(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { - let convert = converterCache.get(options.designSystem) + let convert = options.designSystem.storage[CONVERTER_KEY] if (candidate.kind === 'arbitrary') { let [newValue, modifier] = convert( @@ -491,7 +561,7 @@ function themeToVarVariant( variant: Variant, options: InternalCanonicalizeOptions, ): Variant | Variant[] { - let convert = converterCache.get(options.designSystem) + let convert = options.designSystem.storage[CONVERTER_KEY] let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -511,8 +581,11 @@ function themeToVarVariant( return variant } -const converterCache = new DefaultMap((ds: DesignSystem) => { - return createConverter(ds) +const CONVERTER_KEY = Symbol() +function createConverterCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof CONVERTER_KEY] { + return createConverter(designSystem) function createConverter(designSystem: DesignSystem) { function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { @@ -668,7 +741,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => { return convert } -}) +} function substituteFunctionsInValue( ast: ValueParser.ValueAstNode[], @@ -805,8 +878,11 @@ function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candida // ---- -const spacing = new DefaultMap | null>((ds) => { - let spacingMultiplier = ds.resolveThemeValue('--spacing') +const SPACING_KEY = Symbol() +function createSpacingCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof SPACING_KEY] { + let spacingMultiplier = designSystem.resolveThemeValue('--spacing') if (spacingMultiplier === undefined) return null let parsed = dimensions.get(spacingMultiplier) @@ -823,7 +899,7 @@ const spacing = new DefaultMap | return myValue / value }) -}) +} function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeOptions): Candidate { // We are only interested in arbitrary properties and arbitrary values @@ -837,8 +913,8 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO } let designSystem = options.designSystem - let utilities = preComputedUtilities.get(options.signatureOptions) - let signatures = computeUtilitySignature.get(options.signatureOptions) + let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetCandidateString = designSystem.printCandidate(candidate) @@ -914,7 +990,7 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) if (value === null) return - let spacingMultiplier = spacing.get(designSystem)?.get(value) ?? null + let spacingMultiplier = designSystem.storage[SPACING_KEY]?.get(value) ?? null let rootPrefix = '' if (spacingMultiplier !== null && spacingMultiplier < 0) { rootPrefix = '-' @@ -1049,8 +1125,8 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO } let designSystem = options.designSystem - let utilities = preComputedUtilities.get(options.signatureOptions) - let signatures = computeUtilitySignature.get(options.signatureOptions) + let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetCandidateString = designSystem.printCandidate(candidate) @@ -1127,7 +1203,7 @@ function deprecatedUtilities( options: InternalCanonicalizeOptions, ): Candidate { let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetCandidateString = printUnprefixedCandidate(designSystem, candidate) @@ -1154,8 +1230,8 @@ function arbitraryVariants( options: InternalCanonicalizeOptions, ): Variant | Variant[] { let designSystem = options.designSystem - let signatures = computeVariantSignature.get(options.signatureOptions) - let variants = preComputedVariants.get(options.signatureOptions) + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -1185,7 +1261,7 @@ function dropUnnecessaryDataTypes( options: InternalCanonicalizeOptions, ): Candidate { let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) if ( candidate.kind === 'functional' && @@ -1217,7 +1293,7 @@ function arbitraryValueToBareValueUtility( } let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let expectedSignature = signatures.get(designSystem.printCandidate(candidate)) if (expectedSignature === null) return candidate @@ -1386,7 +1462,7 @@ function modernizeArbitraryValuesVariant( ): Variant | Variant[] { let result = [variant] let designSystem = options.designSystem - let signatures = computeVariantSignature.get(options.signatureOptions) + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] let iterator = walkVariants(variant) for (let [variant, parent] of iterator) { @@ -1751,7 +1827,7 @@ function optimizeModifier(candidate: Candidate, options: InternalCanonicalizeOpt } let designSystem = options.designSystem - let signatures = computeUtilitySignature.get(options.signatureOptions) + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions) let targetSignature = signatures.get(designSystem.printCandidate(candidate)) let modifier = candidate.modifier @@ -1806,6 +1882,565 @@ function optimizeModifier(candidate: Candidate, options: InternalCanonicalizeOpt return candidate } +export enum SignatureFeatures { + None = 0, + ExpandProperties = 1 << 0, + LogicalToPhysical = 1 << 1, +} + +interface SignatureOptions { + /** + * The root font size in pixels. If provided, `rem` values will be normalized + * to `px` values. + * + * E.g.: `mt-[16px]` with `rem: 16` will become `mt-4` (assuming `--spacing: 0.25rem`). + */ + rem: number | null + + /** + * Features that influence how signatures are computed. + */ + features: SignatureFeatures +} + +// Given a utility, compute a signature that represents the utility. The +// signature will be a normalised form of the generated CSS for the utility, or +// a unique symbol if the utility is not valid. The class in the selector will +// be replaced with the `.x` selector. +// +// This function should only be passed the base utility so `flex`, `hover:flex` +// and `focus:flex` will all use just `flex`. Variants are handled separately. +// +// E.g.: +// +// | UTILITY | GENERATED SIGNATURE | +// | ---------------- | ----------------------- | +// | `[display:flex]` | `.x { display: flex; }` | +// | `flex` | `.x { display: flex; }` | +// +// These produce the same signature, therefore they represent the same utility. +export const UTILITY_SIGNATURE_KEY = Symbol() +function createUtilitySignatureCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof UTILITY_SIGNATURE_KEY] { + return new DefaultMap((options: SignatureOptions) => { + return new DefaultMap((utility) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + utility = + designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${utility}` + : utility + + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])] + + temporarilyDisableThemeInline(designSystem, () => { + // There's separate utility caches for respect important vs not + // so we want to compile them both with `@theme inline` disabled + for (let candidate of designSystem.parseCandidate(utility)) { + designSystem.compileAstNodes(candidate, CompileAstFlags.RespectImportant) + } + + substituteAtApply(ast, designSystem) + }) + + // 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. + canonicalizeAst(designSystem, ast, options) + + // 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(designSystem: DesignSystem, ast: AstNode[], options: SignatureOptions) { + let { rem } = options + + walk(ast, { + enter(node, ctx) { + // Optimize declarations + if (node.kind === 'declaration') { + if (node.value === undefined || node.property === '--tw-sort') { + return WalkAction.Replace([]) + } + + // Ignore `--tw-{property}` if `{property}` exists with the same value + if (node.property.startsWith('--tw-')) { + if ( + (ctx.parent?.nodes ?? []).some( + (sibling) => + sibling.kind === 'declaration' && + node.value === sibling.value && + node.important === sibling.important && + !sibling.property.startsWith('--tw-'), + ) + ) { + return WalkAction.Replace([]) + } + } + + if (options.features & SignatureFeatures.ExpandProperties) { + let replacement = expandDeclaration(node, options.features) + if (replacement) return WalkAction.Replace(replacement) + } + + // Resolve theme values to their inlined value. + if (node.value.includes('var(')) { + node.value = resolveVariablesInValue(node.value, designSystem) + } + + // Very basic `calc(…)` constant folding to handle the spacing scale + // multiplier: + // + // Input: `--spacing(4)` + // → `calc(var(--spacing, 0.25rem) * 4)` + // → `calc(0.25rem * 4)` ← this is the case we will see + // after inlining the variable + // → `1rem` + node.value = constantFoldDeclaration(node.value, rem) + + // We will normalize the `node.value`, this is the same kind of logic + // we use when printing arbitrary values. It will remove unnecessary + // whitespace. + // + // Essentially normalizing the `node.value` to a canonical form. + node.value = printArbitraryValue(node.value) + } + + // Replace special nodes with its children + else if (node.kind === 'context' || node.kind === 'at-root') { + return WalkAction.Replace(node.nodes) + } + + // Remove comments + else if (node.kind === 'comment') { + return WalkAction.Replace([]) + } + + // Remove at-rules that are not needed for the signature + else if (node.kind === 'at-rule' && node.name === '@property') { + return WalkAction.Replace([]) + } + }, + exit(node) { + if (node.kind === 'rule' || node.kind === 'at-rule') { + node.nodes.sort((a, b) => { + if (a.kind !== 'declaration') return 0 + if (b.kind !== 'declaration') return 0 + return a.property.localeCompare(b.property) + }) + } + }, + }) + + return ast +} + +// Resolve theme values to their inlined value. +// +// E.g.: +// +// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` +// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` +// +// Due to the `@apply` from above, this will become: +// +// ```css +// .example { +// color: oklch(63.7% 0.237 25.331); +// } +// ``` +// +// Which conveniently will be equivalent to: `text-red-500` when we inline +// the value. +// +// Without inlining: +// ```css +// .example { +// color: var(--color-red-500, oklch(63.7% 0.237 25.331)); +// } +// ``` +// +// Inlined: +// ```css +// .example { +// color: oklch(63.7% 0.237 25.331); +// } +// ``` +// +// Recently we made sure that utilities like `text-red-500` also generate +// the fallback value for usage in `@reference` mode. +// +// The second assumption is that if you use `var(--key, fallback)` that +// happens to match a known variable _and_ its inlined value. Then we can +// replace it with the inlined variable. This allows us to handle custom +// `@theme` and `@theme inline` definitions. +function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { + let changed = false + let valueAst = ValueParser.parse(value) + + let seen = new Set() + walk(valueAst, (valueNode) => { + if (valueNode.kind !== 'function') return + if (valueNode.value !== 'var') return + + // Resolve the underlying value of the variable + if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { + return + } + + let variable = valueNode.nodes[0].value + + // Drop the prefix from the variable name if it is present. The + // internal variable doesn't have the prefix. + if (designSystem.theme.prefix && variable.startsWith(`--${designSystem.theme.prefix}-`)) { + variable = variable.slice(`--${designSystem.theme.prefix}-`.length) + } + let variableValue = designSystem.resolveThemeValue(variable) + // Prevent infinite recursion when the variable value contains the + // variable itself. + if (seen.has(variable)) return + seen.add(variable) + if (variableValue === undefined) return // Couldn't resolve the variable + + // Inject variable fallbacks when no fallback is present yet. + // + // A fallback could consist of multiple values. + // + // E.g.: + // + // ``` + // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) + // ``` + { + // More than 1 argument means that a fallback is already present + if (valueNode.nodes.length === 1) { + // Inject the fallback value into the variable lookup + changed = true + valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) + } + } + + // Replace known variable + inlined fallback value with the value + // itself again + { + // We need at least 3 arguments. The variable, the separator and a fallback value. + if (valueNode.nodes.length >= 3) { + let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable + let constructedValue = `${valueNode.nodes[0].value},${variableValue}` + if (nodeAsString === constructedValue) { + changed = true + return WalkAction.Replace(ValueParser.parse(variableValue)) + } + } + } + }) + + // Replace the value with the new value + if (changed) return ValueParser.toCss(valueAst) + return value +} + +// Index all static utilities by property and value +const STATIC_UTILITIES_KEY = Symbol() +function createStaticUtilitiesCache(): DesignSystem['storage'][typeof STATIC_UTILITIES_KEY] { + return new DefaultMap((_optiones: SignatureOptions) => { + return new DefaultMap((_property: string) => { + return new DefaultMap((_value: string) => { + return new Set() + }) + }) + }) +} + +const UTILITY_PROPERTIES_KEY = Symbol() +function createUtilityPropertiesCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof UTILITY_PROPERTIES_KEY] { + return new DefaultMap((options: SignatureOptions) => { + return new DefaultMap((className) => { + let localPropertyValueLookup = new DefaultMap((_property) => new Set()) + + if (designSystem.theme.prefix && !className.startsWith(designSystem.theme.prefix)) { + className = `${designSystem.theme.prefix}:${className}` + } + let parsed = designSystem.parseCandidate(className) + if (parsed.length === 0) return localPropertyValueLookup + + walk( + canonicalizeAst( + designSystem, + designSystem.compileAstNodes(parsed[0]).map((x) => cloneAstNode(x.node)), + options, + ), + (node) => { + if (node.kind === 'declaration') { + localPropertyValueLookup.get(node.property).add(node.value!) + designSystem.storage[STATIC_UTILITIES_KEY].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. +// +// For all functional utilities, we can compute static-like utilities by +// essentially pre-computing the values and modifiers. This is a bit slow, but +// also only has to happen once per design system. +const PRE_COMPUTED_UTILITIES_KEY = Symbol() +function createPreComputedUtilitiesCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof PRE_COMPUTED_UTILITIES_KEY] { + return new DefaultMap((options: SignatureOptions) => { + let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].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 + + // Skip the utility if `-{utility}-0` has the same signature as + // `{utility}-0` (its positive version). This will prefer positive values + // over negative values. + if (className[0] === '-' && className.endsWith('-0')) { + let positiveSignature = signatures.get(className.slice(1)) + if (typeof positiveSignature === 'string' && signature === positiveSignature) { + continue + } + } + + lookup.get(signature).push(className) + designSystem.storage[UTILITY_PROPERTIES_KEY].get(options).get(className) + + for (let modifier of meta.modifiers) { + // Modifiers representing numbers can be computed and don't need to be + // pre-computed. Doing the math and at the time of writing this, this + // would save you 250k additionally pre-computed utilities... + if (isValidSpacingMultiplier(modifier)) { + continue + } + + let classNameWithModifier = `${className}/${modifier}` + let signature = signatures.get(classNameWithModifier) + if (typeof signature !== 'string') continue + lookup.get(signature).push(classNameWithModifier) + designSystem.storage[UTILITY_PROPERTIES_KEY].get(options).get(classNameWithModifier) + } + } + + return lookup + }) +} + +// Given a variant, compute a signature that represents the variant. The +// signature will be a normalised form of the generated CSS for the variant, or +// a unique symbol if the variant is not valid. The class in the selector will +// be replaced with `.x`. +// +// E.g.: +// +// | VARIANT | GENERATED SIGNATURE | +// | ---------------- | ----------------------------- | +// | `[&:focus]:flex` | `.x:focus { display: flex; }` | +// | `focus:flex` | `.x:focus { display: flex; }` | +// +// These produce the same signature, therefore they represent the same variant. +export const VARIANT_SIGNATURE_KEY = Symbol() +function createVariantSignatureCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof VARIANT_SIGNATURE_KEY] { + return new DefaultMap((variant) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + variant = + designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${variant}` + : variant + + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] + substituteAtApply(ast, designSystem) + + // Canonicalize selectors to their minimal form + walk(ast, (node) => { + // At-rules + if (node.kind === 'at-rule' && node.params.includes(' ')) { + node.params = node.params.replaceAll(' ', '') + } + + // Style rules + else if (node.kind === 'rule') { + let selectorAst = SelectorParser.parse(node.selector) + let changed = false + walk(selectorAst, (node) => { + if (node.kind === 'separator' && node.value !== ' ') { + node.value = node.value.trim() + changed = true + } + + // Remove unnecessary `:is(…)` selectors + else if (node.kind === 'function' && node.value === ':is') { + // A single selector inside of `:is(…)` can be replaced with the + // selector itself. + // + // E.g.: `:is(.foo)` → `.foo` + if (node.nodes.length === 1) { + changed = true + return WalkAction.Replace(node.nodes) + } + + // A selector with the universal selector `*` followed by a pseudo + // class, can be replaced with the pseudo class itself. + else if ( + node.nodes.length === 2 && + node.nodes[0].kind === 'selector' && + node.nodes[0].value === '*' && + node.nodes[1].kind === 'selector' && + node.nodes[1].value[0] === ':' + ) { + changed = true + return WalkAction.Replace(node.nodes[1]) + } + } + + // Ensure `*` exists before pseudo selectors inside of `:not(…)`, + // `:where(…)`, … + // + // E.g.: + // + // `:not(:first-child)` → `:not(*:first-child)` + // + else if ( + node.kind === 'function' && + node.value[0] === ':' && + node.nodes[0]?.kind === 'selector' && + node.nodes[0]?.value[0] === ':' + ) { + changed = true + node.nodes.unshift({ kind: 'selector', value: '*' }) + } + }) + + if (changed) { + node.selector = SelectorParser.toCss(selectorAst) + } + } + }) + + // Compute the final signature, by generating the CSS for the variant + 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() + } + }) +} + +export const PRE_COMPUTED_VARIANTS_KEY = Symbol() +function createPreComputedVariantsCache( + designSystem: DesignSystem, +): DesignSystem['storage'][typeof PRE_COMPUTED_VARIANTS_KEY] { + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let lookup = new DefaultMap(() => []) + + // Actual static variants + for (let [root, variant] of designSystem.variants.entries()) { + if (variant.kind === 'static') { + let signature = signatures.get(root) + if (typeof signature !== 'string') continue + lookup.get(signature).push(root) + } + } + + return lookup +} + +function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => T): T { + // Turn off `@theme inline` feature such that `@theme` and `@theme inline` are + // considered the same. The biggest motivation for this is referencing + // variables in another namespace that happen to contain the same value as the + // utility's own namespaces it is reading from. + // + // E.g.: + // + // The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace. + // But it does read from the `--container-*` namespace. It also happens to + // be the case that `--breakpoint-md` and `--container-3xl` are the exact + // same value. + // + // If you then use the `max-w-(--breakpoint-md)` utility, inlining the + // variable would mean: + // - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl` + // - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl` + // + // Not inlining the variable would mean: + // - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)` + // - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl` + + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + let originalGet = designSystem.theme.values.get + + // Track all values with the inline option set, so we can restore them later. + let restorableInlineOptions = new Set<{ options: ThemeOptions }>() + + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + designSystem.theme.values.get = (key: string) => { + // @ts-expect-error We are monkey-patching a method that's considered private + // in TypeScript + let value = originalGet.call(designSystem.theme.values, key) + if (value === undefined) return value + + // Remove `inline` if it was set + if (value.options & ThemeOptions.INLINE) { + restorableInlineOptions.add(value) + value.options &= ~ThemeOptions.INLINE + } + + return value + } + + try { + // Run the callback with the `@theme inline` feature disabled + return cb() + } finally { + // Restore the `@theme inline` to the original value + // @ts-expect-error We are monkey-patching a method that's private + designSystem.theme.values.get = originalGet + + // Re-add the `inline` option, in case future lookups are done + for (let value of restorableInlineOptions) { + value.options |= ThemeOptions.INLINE + } + } +} + // Generator that generates all combinations of the given set. Using a generator // so we can stop early when we found a suitable combination. // diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 2dfa24128ae4..d533743c172b 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -59,6 +59,9 @@ export type DesignSystem = { // Used by IntelliSense candidatesToCss(classes: string[]): (string | null)[] + + // General purpose storage + storage: Record } export function buildDesignSystem(theme: Theme): DesignSystem { @@ -214,6 +217,10 @@ export function buildDesignSystem(theme: Theme): DesignSystem { canonicalizeCandidates(candidates: string[], options?: CanonicalizeOptions) { return canonicalizeCandidates(this, candidates, options) }, + + // General purpose storage, each key has to be a unique symbol to avoid + // collisions. + storage: {}, } return designSystem diff --git a/packages/tailwindcss/src/expand-declaration.test.ts b/packages/tailwindcss/src/expand-declaration.test.ts index 44456f239025..1f9e71561a6b 100644 --- a/packages/tailwindcss/src/expand-declaration.test.ts +++ b/packages/tailwindcss/src/expand-declaration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'vitest' import { toCss } from './ast' +import { SignatureFeatures } from './canonicalize-candidates' import { parse } from './css-parser' import { expandDeclaration } from './expand-declaration' -import { SignatureFeatures } from './signatures' import { walk, WalkAction } from './walk' const css = String.raw diff --git a/packages/tailwindcss/src/expand-declaration.ts b/packages/tailwindcss/src/expand-declaration.ts index 1dc5df6f2e23..628848ab4aa0 100644 --- a/packages/tailwindcss/src/expand-declaration.ts +++ b/packages/tailwindcss/src/expand-declaration.ts @@ -1,5 +1,5 @@ import { decl, type AstNode } from './ast' -import { SignatureFeatures } from './signatures' +import { SignatureFeatures } from './canonicalize-candidates' import { segment } from './utils/segment' function createPrefixedQuad( diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts deleted file mode 100644 index 7ad608f22cb4..000000000000 --- a/packages/tailwindcss/src/signatures.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { substituteAtApply } from './apply' -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' -import { isValidSpacingMultiplier } from './utils/infer-data-type' -import * as ValueParser from './value-parser' -import { walk, WalkAction } from './walk' - -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 - * to `px` values. - * - * E.g.: `mt-[16px]` with `rem: 16` will become `mt-4` (assuming `--spacing: 0.25rem`). - */ - rem: number | null - - /** - * Features that influence how signatures are computed. - */ - features: SignatureFeatures - - /** - * The design system to use for computing the signature of candidates. - */ - designSystem: DesignSystem -} - -// Given a utility, compute a signature that represents the utility. The -// signature will be a normalised form of the generated CSS for the utility, or -// a unique symbol if the utility is not valid. The class in the selector will -// be replaced with the `.x` selector. -// -// This function should only be passed the base utility so `flex`, `hover:flex` -// and `focus:flex` will all use just `flex`. Variants are handled separately. -// -// E.g.: -// -// | UTILITY | GENERATED SIGNATURE | -// | ---------------- | ----------------------- | -// | `[display:flex]` | `.x { display: flex; }` | -// | `flex` | `.x { display: flex; }` | -// -// These produce the same signature, therefore they represent the same utility. -export const computeUtilitySignature = new DefaultMap((options: SignatureOptions) => { - let designSystem = options.designSystem - - return new DefaultMap((utility) => { - try { - // Ensure the prefix is added to the utility if it is not already present. - utility = - designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix) - ? `${designSystem.theme.prefix}:${utility}` - : utility - - // Use `@apply` to normalize the selector to `.x` - let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])] - - temporarilyDisableThemeInline(designSystem, () => { - // There's separate utility caches for respect important vs not - // so we want to compile them both with `@theme inline` disabled - for (let candidate of designSystem.parseCandidate(utility)) { - designSystem.compileAstNodes(candidate, CompileAstFlags.RespectImportant) - } - - substituteAtApply(ast, designSystem) - }) - - // 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. - canonicalizeAst(ast, options) - - // 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, ctx) { - // Optimize declarations - if (node.kind === 'declaration') { - if (node.value === undefined || node.property === '--tw-sort') { - return WalkAction.Replace([]) - } - - // Ignore `--tw-{property}` if `{property}` exists with the same value - if (node.property.startsWith('--tw-')) { - if ( - (ctx.parent?.nodes ?? []).some( - (sibling) => - sibling.kind === 'declaration' && - node.value === sibling.value && - node.important === sibling.important && - !sibling.property.startsWith('--tw-'), - ) - ) { - return WalkAction.Replace([]) - } - } - - if (options.features & SignatureFeatures.ExpandProperties) { - let replacement = expandDeclaration(node, options.features) - if (replacement) return WalkAction.Replace(replacement) - } - - // Resolve theme values to their inlined value. - if (node.value.includes('var(')) { - node.value = resolveVariablesInValue(node.value, designSystem) - } - - // Very basic `calc(…)` constant folding to handle the spacing scale - // multiplier: - // - // Input: `--spacing(4)` - // → `calc(var(--spacing, 0.25rem) * 4)` - // → `calc(0.25rem * 4)` ← this is the case we will see - // after inlining the variable - // → `1rem` - node.value = constantFoldDeclaration(node.value, rem) - - // We will normalize the `node.value`, this is the same kind of logic - // we use when printing arbitrary values. It will remove unnecessary - // whitespace. - // - // Essentially normalizing the `node.value` to a canonical form. - node.value = printArbitraryValue(node.value) - } - - // Replace special nodes with its children - else if (node.kind === 'context' || node.kind === 'at-root') { - return WalkAction.Replace(node.nodes) - } - - // Remove comments - else if (node.kind === 'comment') { - return WalkAction.Replace([]) - } - - // Remove at-rules that are not needed for the signature - else if (node.kind === 'at-rule' && node.name === '@property') { - return WalkAction.Replace([]) - } - }, - exit(node) { - if (node.kind === 'rule' || node.kind === 'at-rule') { - node.nodes.sort((a, b) => { - if (a.kind !== 'declaration') return 0 - if (b.kind !== 'declaration') return 0 - return a.property.localeCompare(b.property) - }) - } - }, - }) - - return ast -} - -// Resolve theme values to their inlined value. -// -// E.g.: -// -// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]` -// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]` -// -// Due to the `@apply` from above, this will become: -// -// ```css -// .example { -// color: oklch(63.7% 0.237 25.331); -// } -// ``` -// -// Which conveniently will be equivalent to: `text-red-500` when we inline -// the value. -// -// Without inlining: -// ```css -// .example { -// color: var(--color-red-500, oklch(63.7% 0.237 25.331)); -// } -// ``` -// -// Inlined: -// ```css -// .example { -// color: oklch(63.7% 0.237 25.331); -// } -// ``` -// -// Recently we made sure that utilities like `text-red-500` also generate -// the fallback value for usage in `@reference` mode. -// -// The second assumption is that if you use `var(--key, fallback)` that -// happens to match a known variable _and_ its inlined value. Then we can -// replace it with the inlined variable. This allows us to handle custom -// `@theme` and `@theme inline` definitions. -function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { - let changed = false - let valueAst = ValueParser.parse(value) - - let seen = new Set() - walk(valueAst, (valueNode) => { - if (valueNode.kind !== 'function') return - if (valueNode.value !== 'var') return - - // Resolve the underlying value of the variable - if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) { - return - } - - let variable = valueNode.nodes[0].value - - // Drop the prefix from the variable name if it is present. The - // internal variable doesn't have the prefix. - if (designSystem.theme.prefix && variable.startsWith(`--${designSystem.theme.prefix}-`)) { - variable = variable.slice(`--${designSystem.theme.prefix}-`.length) - } - let variableValue = designSystem.resolveThemeValue(variable) - // Prevent infinite recursion when the variable value contains the - // variable itself. - if (seen.has(variable)) return - seen.add(variable) - if (variableValue === undefined) return // Couldn't resolve the variable - - // Inject variable fallbacks when no fallback is present yet. - // - // A fallback could consist of multiple values. - // - // E.g.: - // - // ``` - // var(--font-sans, ui-sans-serif, system-ui, sans-serif, …) - // ``` - { - // More than 1 argument means that a fallback is already present - if (valueNode.nodes.length === 1) { - // Inject the fallback value into the variable lookup - changed = true - valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) - } - } - - // Replace known variable + inlined fallback value with the value - // itself again - { - // We need at least 3 arguments. The variable, the separator and a fallback value. - if (valueNode.nodes.length >= 3) { - let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable - let constructedValue = `${valueNode.nodes[0].value},${variableValue}` - if (nodeAsString === constructedValue) { - changed = true - return WalkAction.Replace(ValueParser.parse(variableValue)) - } - } - } - }) - - // Replace the value with the new value - if (changed) return ValueParser.toCss(valueAst) - return value -} - -// Index all static utilities by property and value -export const staticUtilitiesByPropertyAndValue = new DefaultMap((_optiones: SignatureOptions) => { - return new DefaultMap((_property: string) => { - return new DefaultMap((_value: string) => { - return new Set() - }) - }) -}) - -export const computeUtilityProperties = new DefaultMap((options: SignatureOptions) => { - return new DefaultMap((className) => { - let localPropertyValueLookup = new DefaultMap((_property) => new Set()) - let designSystem = options.designSystem - - if ( - options.designSystem.theme.prefix && - !className.startsWith(options.designSystem.theme.prefix) - ) { - className = `${options.designSystem.theme.prefix}:${className}` - } - let parsed = designSystem.parseCandidate(className) - if (parsed.length === 0) return localPropertyValueLookup - - walk( - canonicalizeAst( - designSystem.compileAstNodes(parsed[0]).map((x) => cloneAstNode(x.node)), - options, - ), - (node) => { - if (node.kind === 'declaration') { - localPropertyValueLookup.get(node.property).add(node.value!) - staticUtilitiesByPropertyAndValue - .get(options) - .get(node.property) - .get(node.value!) - .add(className) - } - }, - ) - - return localPropertyValueLookup - }) -}) - -// For all static utilities in the system, compute a lookup table that maps the -// utility signature to the utility name. This is used to find the utility name -// for a given utility signature. -// -// For all functional utilities, we can compute static-like utilities by -// essentially pre-computing the values and modifiers. This is a bit slow, but -// also only has to happen once per design system. -export const preComputedUtilities = new DefaultMap((options: SignatureOptions) => { - let { designSystem } = options - 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 - - // Skip the utility if `-{utility}-0` has the same signature as - // `{utility}-0` (its positive version). This will prefer positive values - // over negative values. - if (className[0] === '-' && className.endsWith('-0')) { - let positiveSignature = signatures.get(className.slice(1)) - if (typeof positiveSignature === 'string' && signature === positiveSignature) { - continue - } - } - - 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 - // pre-computed. Doing the math and at the time of writing this, this - // would save you 250k additionally pre-computed utilities... - if (isValidSpacingMultiplier(modifier)) { - continue - } - - let classNameWithModifier = `${className}/${modifier}` - let signature = signatures.get(classNameWithModifier) - if (typeof signature !== 'string') continue - lookup.get(signature).push(classNameWithModifier) - computeUtilityProperties.get(options).get(classNameWithModifier) - } - } - - return lookup -}) - -// Given a variant, compute a signature that represents the variant. The -// signature will be a normalised form of the generated CSS for the variant, or -// a unique symbol if the variant is not valid. The class in the selector will -// be replaced with `.x`. -// -// E.g.: -// -// | VARIANT | GENERATED SIGNATURE | -// | ---------------- | ----------------------------- | -// | `[&:focus]:flex` | `.x:focus { display: flex; }` | -// | `focus:flex` | `.x:focus { display: flex; }` | -// -// These produce the same signature, therefore they represent the same variant. -export const computeVariantSignature = new DefaultMap((options: SignatureOptions) => { - let { designSystem } = options - return new DefaultMap((variant) => { - try { - // Ensure the prefix is added to the utility if it is not already present. - variant = - designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) - ? `${designSystem.theme.prefix}:${variant}` - : variant - - // Use `@apply` to normalize the selector to `.x` - let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] - substituteAtApply(ast, designSystem) - - // Canonicalize selectors to their minimal form - walk(ast, (node) => { - // At-rules - if (node.kind === 'at-rule' && node.params.includes(' ')) { - node.params = node.params.replaceAll(' ', '') - } - - // Style rules - else if (node.kind === 'rule') { - let selectorAst = SelectorParser.parse(node.selector) - let changed = false - walk(selectorAst, (node) => { - if (node.kind === 'separator' && node.value !== ' ') { - node.value = node.value.trim() - changed = true - } - - // Remove unnecessary `:is(…)` selectors - else if (node.kind === 'function' && node.value === ':is') { - // A single selector inside of `:is(…)` can be replaced with the - // selector itself. - // - // E.g.: `:is(.foo)` → `.foo` - if (node.nodes.length === 1) { - changed = true - return WalkAction.Replace(node.nodes) - } - - // A selector with the universal selector `*` followed by a pseudo - // class, can be replaced with the pseudo class itself. - else if ( - node.nodes.length === 2 && - node.nodes[0].kind === 'selector' && - node.nodes[0].value === '*' && - node.nodes[1].kind === 'selector' && - node.nodes[1].value[0] === ':' - ) { - changed = true - return WalkAction.Replace(node.nodes[1]) - } - } - - // Ensure `*` exists before pseudo selectors inside of `:not(…)`, - // `:where(…)`, … - // - // E.g.: - // - // `:not(:first-child)` → `:not(*:first-child)` - // - else if ( - node.kind === 'function' && - node.value[0] === ':' && - node.nodes[0]?.kind === 'selector' && - node.nodes[0]?.value[0] === ':' - ) { - changed = true - node.nodes.unshift({ kind: 'selector', value: '*' }) - } - }) - - if (changed) { - node.selector = SelectorParser.toCss(selectorAst) - } - } - }) - - // Compute the final signature, by generating the CSS for the variant - 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() - } - }) -}) - -export const preComputedVariants = new DefaultMap((options: SignatureOptions) => { - let { designSystem } = options - let signatures = computeVariantSignature.get(options) - let lookup = new DefaultMap(() => []) - - // Actual static variants - for (let [root, variant] of designSystem.variants.entries()) { - if (variant.kind === 'static') { - let signature = signatures.get(root) - if (typeof signature !== 'string') continue - lookup.get(signature).push(root) - } - } - - return lookup -}) - -function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => T): T { - // Turn off `@theme inline` feature such that `@theme` and `@theme inline` are - // considered the same. The biggest motivation for this is referencing - // variables in another namespace that happen to contain the same value as the - // utility's own namespaces it is reading from. - // - // E.g.: - // - // The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace. - // But it does read from the `--container-*` namespace. It also happens to - // be the case that `--breakpoint-md` and `--container-3xl` are the exact - // same value. - // - // If you then use the `max-w-(--breakpoint-md)` utility, inlining the - // variable would mean: - // - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl` - // - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl` - // - // Not inlining the variable would mean: - // - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)` - // - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl` - - // @ts-expect-error We are monkey-patching a method that's considered private - // in TypeScript - let originalGet = designSystem.theme.values.get - - // Track all values with the inline option set, so we can restore them later. - let restorableInlineOptions = new Set<{ options: ThemeOptions }>() - - // @ts-expect-error We are monkey-patching a method that's considered private - // in TypeScript - designSystem.theme.values.get = (key: string) => { - // @ts-expect-error We are monkey-patching a method that's considered private - // in TypeScript - let value = originalGet.call(designSystem.theme.values, key) - if (value === undefined) return value - - // Remove `inline` if it was set - if (value.options & ThemeOptions.INLINE) { - restorableInlineOptions.add(value) - value.options &= ~ThemeOptions.INLINE - } - - return value - } - - try { - // Run the callback with the `@theme inline` feature disabled - return cb() - } finally { - // Restore the `@theme inline` to the original value - // @ts-expect-error We are monkey-patching a method that's private - designSystem.theme.values.get = originalGet - - // Re-add the `inline` option, in case future lookups are done - for (let value of restorableInlineOptions) { - value.options |= ThemeOptions.INLINE - } - } -}