import { isWeb } from '@tamagui/constants' import { stylePropsUnitless } from '@tamagui/helpers' import { mergeFrontendCondition, plainValueToPayload, type FrontendClassPlan, type FrontendClassPlanEntry, type FrontendClassSink, type StyleFrontendConfig, } from '@tamagui/core/internal-runtime' import { noteBoxShadow, noteTailwindTransform, tryCompose } from './compose' import { composedResolver } from './composedResolver' import { borderSideSuffix, configRevisionSymbol, createGrammarConfigView, getTokenCategory, getSafeAreaEdge, insetAxisProps, percentUtilityProps, radiusCornerProps, sizeUtilityProps, splitColorOpacitySuffix, } from '@tamagui/style-grammar/runtime' import { classifyCandidate, decodeArbitrary, hasTokenName, type GrammarConfigView, type ParsedCandidate, resolveTokenName, tailwindLeadingRatios, tailwindTrackingEms, } from '@tamagui/style-grammar/tooling/candidate' /** * Class-string tokenization and candidate adaptation. This is the Tailwind * frontend's private half of the pipeline: everything here is about how a * candidate is SPELLED (brackets, slashes, fractions, keywords, negatives). * * The semantic parser itself stays in `@tamagui/style-grammar`, shared with the * converter and build tooling, and everything downstream of the props this module * produces — value programs, merging, lowering, evaluation — is core's shared * renderer. */ const styleGrammarConfigCache = new WeakMap< StyleFrontendConfig, { revision: number; value: GrammarConfigView } >() export function getStyleGrammarConfig(config: StyleFrontendConfig): GrammarConfigView { const cached = styleGrammarConfigCache.get(config) const revision = (config as any)[configRevisionSymbol]?.revision || 0 if (cached && cached.revision === revision) return cached.value const view = createGrammarConfigView(config) styleGrammarConfigCache.set(config, { revision, value: view }) return view } export function isTokenValueProp(prop: string): boolean { return getTokenCategory(prop) !== null } // CANONICAL arbitrary-value coercion. an arbitrary `[..]` whose inner is a UNITLESS number // (z-[400], aspect-[1.5]) or a PX length (p-[18px], text-[14px], border-[0.5px]) // resolves to a NUMBER: React Native REQUIRES numbers for dimensional + typography props and // silently DROPS "Npx"/"400" strings (Yoga parseCSSProperty rejects px; // StyleSheetTypes types fontSize/lineHeight/letterSpacing as number). web accepts the number and // re-adds px via CSS. anything carrying a real unit/function (%, rem, vh, deg, calc(), var(), // #hex, colors) stays a STRING. function arbitraryValue(inner: string, prop?: string): number | string { const px = /^(-?\d*\.?\d+)px$/.exec(inner) // a unitless prop (grid-cols-[77px]) never gets px re-added, so the length stays a string if (px) return prop && stylePropsUnitless[prop] ? inner : Number(px[1]) if (/^-?\d*\.?\d+$/.test(inner)) return Number(inner) return inner } /** the same coercion for a raw value that may still be wrapped in `[]` */ function unwrapArbitraryValue(raw: string): number | string { return arbitraryValue( raw.length > 1 && raw[0] === '[' && raw[raw.length - 1] === ']' ? decodeArbitrary(raw.slice(1, -1)) : raw ) } // tailwind sizing keywords / fractions for width/height/min/max props. // returns a CSS value, or null if `value` isn't a sizing keyword (falls through to normal parse). function tailwindSizingValue(prop: string, value: string): string | null { if (value === 'full') return '100%' if (value === 'auto') return 'auto' if (value === 'screen') return /[Hh]eight|[Bb]lock/.test(prop) ? '100vh' : '100vw' if (/^[dls]v[hw]$/.test(value)) return `100${value}` if (value === 'min') return 'min-content' if (value === 'max') return 'max-content' if (value === 'fit') return 'fit-content' const frac = /^(\d+)\/(\d+)$/.exec(value) if (frac && Number(frac[2]) !== 0) { return `${(Number(frac[1]) / Number(frac[2])) * 100}%` } return null } /** * Prefixes that write more than one longhand from a single candidate. The registry * already selected the token category and width-vs-color meaning; this only names * the affected props. Values are converted per longhand so size-screen can be * 100vw by 100vh. */ function expansionProps(parsed: ParsedCandidate): readonly string[] | null { const prefix = parsed.prefix if (!prefix) return null if (prefix === 'size') return sizeUtilityProps if (prefix === 'translate') return ['x', 'y'] if (prefix === 'skew') return ['skewX', 'skewY'] if (prefix === 'inset-x' || prefix === 'inset-y') { return insetAxisProps[prefix.slice('inset-'.length)] } if (prefix.startsWith('rounded-')) { const props = radiusCornerProps[prefix.slice('rounded-'.length)] if (!props || props.length === 1) return null return props } const m = /^border-([trblxy])$/.exec(prefix) if (!m) return null const sides = borderSideSuffix[m[1]] const suffix = parsed.entry?.prop.endsWith('Width') ? 'Width' : 'Color' return sides.map((side) => `border${side}${suffix}`) } /** * Adapt a registry-parsed candidate into the ordinary props shared rendering consumes. * Examples: * "hover:bg-blue-5" → backgroundColor + a `hover:blue-5` program * "sm:p-4" → padding + an `sm:4` program * "bg-[red]" → { key: "backgroundColor", value: "red" } * "w-100" → { key: "width", value: 100 } * "opacity-50" → { key: "opacity", value: 0.5 } */ function tailwindClassToFlatProp( parsed: ParsedCandidate, grammarConfig?: GrammarConfigView ): { key: string; value: any } | null { if (parsed.kind !== 'dynamic' || !parsed.entry || parsed.rawValue === undefined) { return null } const prop = parsed.entry.prop const category = parsed.entry.tokenCategory let value: any = parsed.rawValue if (parsed.negative && value === '0' && parsed.convenience !== 'angle') { return { key: prop, value: 0 } } if (parsed.convenience === 'zero') { return { key: prop, value: 0 } } if (parsed.convenience === 'angle') { return { key: prop, value: `${parsed.negative ? '-' : ''}${value}deg`, } } // Tailwind's translate fractions and `full` are percentages, not configured // spacing tokens. RN accepts percentage translate values, so preserve the // exact CSS geometry for both axes and for negative candidates. if ((prop === 'x' || prop === 'y') && parsed.valueKind !== 'token') { const sized = tailwindSizingValue(prop, value) if (sized != null) { return { key: prop, value: parsed.negative && sized[0] !== '-' ? `-${sized}` : sized, } } } // named leading (leading-tight) is a unitless ratio, which v3 line height reads as such if (parsed.convenience === 'leading-keyword') { return { key: prop, value: tailwindLeadingRatios[value] } } // named tracking (tracking-wide) is em of the font size, which core lowers per host if (parsed.convenience === 'tracking-keyword') { const em = tailwindTrackingEms[value] * (parsed.negative ? -1 : 1) return { key: prop, value: `${em}em` } } // Tailwind's numbered leading scale is the spacing scale (N * 0.25rem), // represented as native points at the default 16px root size. Resolve it // here instead of letting an application's same-named font token change the // meaning of a Tailwind classname. Keep the unit until core lowers it for the // target host, because a bare number is a V3 line-height ratio. if (prop === 'lineHeight' && /^\d+(?:\.\d+)?$/.test(value)) { return { key: prop, value: `${Number(value) * 4}px` } } // grid-cols-N → repeat(N, minmax(0, 1fr)), col-span-N → span N / span N if (prop === 'gridTemplateColumns' && /^\d+$/.test(value)) { return { key: prop, value: `repeat(${value}, minmax(0, 1fr))` } } if (prop === 'gridColumn' && /^\d+$/.test(value)) { return { key: prop, value: `span ${value} / span ${value}` } } if (prop === 'gridRow' && /^\d+$/.test(value)) { return { key: prop, value: `span ${value} / span ${value}` } } if ( (prop === 'gridColumnStart' || prop === 'gridColumnEnd' || prop === 'gridRowStart' || prop === 'gridRowEnd') && /^\d+$/.test(value) ) { return { key: prop, value: Number(value) } } if (prop.endsWith('Width') && parsed.prefix?.startsWith('border')) { if (parsed.valueKind === 'token') { value = `${parsed.negative ? '-' : ''}${value}` if (grammarConfig && parsed.entry?.tokenCategory) { value = resolveTokenName(grammarConfig, parsed.entry.tokenCategory, value) ?? value } } else if (parsed.valueKind === 'arbitrary') { value = unwrapArbitraryValue(value) } else { return null } return { key: prop, value } } if (prop === 'fontFamily') { let famValue: string if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') { famValue = decodeArbitrary(value.slice(1, -1)) } else { const generic: Record = { sans: 'sans-serif', serif: 'serif', mono: 'monospace', } famValue = parsed.valueKind === 'token' ? value : generic[value] || value } return { key: 'fontFamily', value: famValue, } } if (prop === 'fontSize') { let fsValue: any if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') { // fontSize is number-only on native: text-[14px] → 14 (arbitraryValue drops px) fsValue = arbitraryValue(decodeArbitrary(value.slice(1, -1))) } else { fsValue = value } return { key: 'fontSize', value: fsValue, } } if (prop === 'lineHeight') { let lhValue: any if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') { const inner = decodeArbitrary(value.slice(1, -1)) // preserve both forms until core's target lowering: px is absolute and a // unitless numeric string is a ratio on web and native. lhValue = inner } else { lhValue = value } return { key: 'lineHeight', value: lhValue, } } if (prop === 'letterSpacing') { let lsValue: any if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') { lsValue = arbitraryValue(decodeArbitrary(value.slice(1, -1))) } else { lsValue = value } return { key: 'letterSpacing', value: lsValue, } } if (prop === 'boxShadow' && value[0] === '[' && value[value.length - 1] === ']') { return { key: 'boxShadow', value: decodeArbitrary(value.slice(1, -1)), } } // arbitrary values: p-[4px], w-[100px], rounded-[8px], min-h-[100vh], rotate-[-8deg], // h-[calc(100%-2px)], bg-[var(--color-5)], bg-[#fff]. use the bracketed value directly as // CSS — no scaling/token resolution. tailwind encodes spaces inside [] as underscores. if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') { const inner = decodeArbitrary(value.slice(1, -1)) if (inner === '') return null // px-length + unitless arbitraries become NUMBERS (native requires numbers, drops "Npx" // strings); unit/function values stay strings. one canonical rule (arbitraryValue). let resolved = arbitraryValue(inner, prop) // rotate requires a unit-bearing string on native — a bare number from rotate-[45] // triggers a redbox. append deg when the arbitrary resolved to a unitless number. if ( (prop === 'rotate' || prop === 'rotateX' || prop === 'rotateY' || prop === 'rotateZ' || prop === 'skewX' || prop === 'skewY') && typeof resolved === 'number' ) { resolved = `${resolved}deg` } if (parsed.negative) { if (typeof resolved === 'number') resolved = -resolved else if (resolved[0] !== '-') resolved = `-${resolved}` } return { key: prop, value: resolved } } // tailwind sizing keywords / fractions (w-full → 100%, w-1/2 → 50%, w-auto, w-screen). // handled before isValidTailwindValue since fractions/keywords aren't plain CSS values. An // exact configured size token with the same spelling stays a token. if ( parsed.valueKind !== 'token' && (category === 'size' || parsed.convenience === 'sizing-keyword') ) { const sized = tailwindSizingValue(prop, value) if (sized != null) { return { key: prop, value: parsed.negative && sized[0] !== '-' ? `-${sized}` : sized, } } } // color opacity modifier: bg-blue-500/50 → split value into base + /N suffix, // re-attached after token resolution so the shared color resolver applies it via color-mix // (web) / rgba (native). only for color props; for non-color props a "/" in the // value is left intact (e.g. fraction sizing handled above). let opacitySuffix = '' if (category === 'color' && typeof value === 'string') { const suffix = splitColorOpacitySuffix(value) if (suffix.kind === 'invalid') { return { key: prop, value } } if (suffix.kind === 'valid') { opacitySuffix = value.slice(suffix.name.length) value = suffix.name } } // handle special value patterns if (percentUtilityProps.has(prop) && /^\d+$/.test(value)) { // tailwind percentage utilities: opacity-50 → 0.5, scale-95 → 0.95, scale-100 → 1 value = Number(value) / 100 } else if (/^\d+(\.\d+)?$/.test(value)) { if (category === 'zIndex' && parsed.valueKind === 'convenience') { value = Number(value) * (parsed.negative ? -1 : 1) } else if (category) { value = `${parsed.negative ? '-' : ''}${value}` if (grammarConfig) { value = resolveTokenName(grammarConfig, category, value) ?? value } } else { value = Number(value) } } else if (typeof value === 'string') { if ( category && !( (category === 'space' || category === 'size' || category === 'radius') && getSafeAreaEdge(value) ) ) { value = `${parsed.negative ? '-' : ''}${value}` } } // re-attach the color opacity suffix (bg-blue-500/50). the shared flat-value // resolver parses the trailing /N after its property-scoped name lookup. if (opacitySuffix && typeof value === 'string') { value = `${value}${opacitySuffix}` } if (parsed.negative && !category) { if (typeof value === 'number') value = -value else if (typeof value === 'string' && value[0] !== '-') value = `-${value}` } return { key: prop, value } } // Parsing a Tailwind candidate depends only on the class string and the grammar // config, never on the surrounding props, so the decision is cached per class. // Without this the full parse (split + classify + resolve + border expansion) ran // on every render of every component carrying a className — measured at ~18µs per // call for a 4-class string, a 1.53x tax over writing the same styles as props. // The plan is a flat [key, value][] applied in order, so the authored ordering // between classes and ordinary props is unchanged. // // null = web-only candidate dropped on native. 'raw' = not claimed by the // grammar, caller preserves the class string. An array (which may legitimately be // empty) = claimed, apply these entries. type TailwindPlanEntry = FrontendClassPlanEntry type TailwindClassPlan = TailwindPlanEntry[] | null | 'raw' type TailwindParentPlan = { entries: TailwindPlanEntry[] preserveRawClass: boolean } type CachedTailwindClassPlan = TailwindClassPlan | TailwindParentPlan const classPlanCache = new WeakMap>() const nativeTarget = !isWeb || process.env.TAMAGUI_TARGET === 'native' const cssGridProps = new Set([ 'gridTemplateColumns', 'gridColumn', 'gridColumnStart', 'gridColumnEnd', 'gridRow', 'gridRowStart', 'gridRowEnd', ]) const nativeUnsupportedProps = new Set([ 'objectFit', 'order', 'overflowX', 'overflowY', 'textOverflow', 'whiteSpace', 'visibility', ]) const nativeUnsupportedSizingValues = new Set(['min', 'max', 'fit']) // values outside react native's style unions, which native would drop const nativeUnsupportedValues: Record> = { display: new Set(['block', 'inline', 'inline-flex']), position: new Set(['fixed', 'sticky']), } function shouldGateNative(parsed: ParsedCandidate): boolean { if (!nativeTarget) return false const properties = parsed.properties || {} if ( process.env.TAMAGUI_CSS_GRID !== '1' && ((parsed.entry && cssGridProps.has(parsed.entry.prop)) || properties.display === 'grid' || Object.keys(properties).some((prop) => cssGridProps.has(prop))) ) { return true } if ( (parsed.entry && nativeUnsupportedProps.has(parsed.entry.prop)) || Object.keys(properties).some((prop) => nativeUnsupportedProps.has(prop)) ) { return true } if ( parsed.convenience === 'sizing-keyword' && nativeUnsupportedSizingValues.has(parsed.rawValue || '') ) { return true } const entryProp = parsed.entry?.prop if ( (entryProp && nativeUnsupportedValues[entryProp]?.has(parsed.rawValue || '')) || Object.keys(properties).some((prop) => nativeUnsupportedValues[prop]?.has(String(properties[prop])) ) ) { return true } return ( properties.overflow === 'auto' || (properties.cursor !== undefined && properties.cursor !== 'auto' && properties.cursor !== 'pointer') ) } function getClassPlanCache(grammarConfig: object) { let cache = classPlanCache.get(grammarConfig) if (!cache) { cache = new Map() classPlanCache.set(grammarConfig, cache) } return cache } function createPlanEntry( property: string, value: unknown, modifiers: readonly string[] ): TailwindPlanEntry | null { if (modifiers.length === 0) return [property, value] const payload = plainValueToPayload(value, property) if (payload === null) return null let condition = '' for (let index = 0; index < modifiers.length; index++) { if (index) condition += ':' condition += modifiers[index] } return [property, payload, condition, modifiers] } // CSS shorthands reset every longhand, including logical ones. RN stores those // under different keys (`paddingStart` vs `paddingLeft`, `columnGap` vs `gap`), // so `ps-2 p-4` would otherwise keep the earlier logical value. Unmodified // shorthand plans prepend `unset` entries so both the tokenizer and the // renderer sink clear them. Conditional classes (`hover:p-4`) skip this. const shorthandResets: Record = { padding: [ 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'paddingStart', 'paddingEnd', 'paddingInlineStart', 'paddingInlineEnd', 'paddingBlockStart', 'paddingBlockEnd', 'paddingHorizontal', 'paddingVertical', 'paddingInline', 'paddingBlock', ], margin: [ 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'marginStart', 'marginEnd', 'marginInlineStart', 'marginInlineEnd', 'marginBlockStart', 'marginBlockEnd', 'marginHorizontal', 'marginVertical', 'marginInline', 'marginBlock', ], gap: ['rowGap', 'columnGap'], borderRadius: [ 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius', 'borderTopStartRadius', 'borderTopEndRadius', 'borderBottomStartRadius', 'borderBottomEndRadius', 'borderStartStartRadius', 'borderStartEndRadius', 'borderEndStartRadius', 'borderEndEndRadius', ], borderWidth: [ 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStartWidth', 'borderEndWidth', 'borderInlineStartWidth', 'borderInlineEndWidth', 'borderBlockStartWidth', 'borderBlockEndWidth', 'borderInlineWidth', 'borderBlockWidth', ], borderColor: [ 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderStartColor', 'borderEndColor', 'borderInlineStartColor', 'borderInlineEndColor', 'borderBlockStartColor', 'borderBlockEndColor', 'borderInlineColor', 'borderBlockColor', ], } function computeClassPlan( cls: string, grammarConfig: GrammarConfigView ): CachedTailwindClassPlan { const groupMarker = /^group(?:\/([A-Za-z0-9_-]+))?$/.exec(cls) if (groupMarker) { return { entries: [['group', groupMarker[1] || true]], preserveRawClass: isWeb, } } const containerMarker = /^@container(-size)?(?:\/([A-Za-z0-9_-]+))?$/.exec(cls) if (containerMarker) { const isSize = containerMarker[1] !== undefined const name = containerMarker[2] const entries: TailwindPlanEntry[] = [] entries.push(['container', name || true]) if (isSize) { entries.push(['containerType', 'size']) } return { entries, preserveRawClass: isWeb } } const classification = classifyCandidate(cls, grammarConfig) if (classification.kind === 'passthrough') { return isWeb ? 'raw' : null } const parsed = classification.parsed if (!nativeTarget && parsed.entry?.prop === 'perspective') return 'raw' if (shouldGateNative(parsed)) { return null } if (parsed.entry?.prop === 'flex' && parsed.rawValue) { const fraction = /^(\d+)\/(\d+)$/.exec(parsed.rawValue) if (fraction && Number(fraction[2]) !== 0) { const basis = `${(Number(fraction[1]) / Number(fraction[2])) * 100}%` const entries = [ createPlanEntry('flexGrow', 1, parsed.modifiers), createPlanEntry('flexShrink', 1, parsed.modifiers), createPlanEntry('flexBasis', basis, parsed.modifiers), ] return entries.every(Boolean) ? (entries as TailwindPlanEntry[]) : 'raw' } } // named utilities first (flex-row, flex-1, hidden, …) — whole class → fixed prop(s). // these may emit multiple props and may have no dash, so handle before the generic parse. const util = parsed.kind === 'utility' ? parsed.properties : null if (util) { const entries: TailwindPlanEntry[] = [] for (const p in util) { const value = nativeTarget && p === 'transform' && util[p] === 'none' ? [] : util[p] const entry = createPlanEntry(p, value, parsed.modifiers) if (!entry) return 'raw' entries.push(entry) } return entries } // Resolve only after the registry has claimed the candidate. Multi-prop expansion // below consumes that parsed decision instead of re-parsing width vs color. const expandedProps = expansionProps(parsed) if (expandedProps) { const entries: TailwindPlanEntry[] = [] for (const prop of expandedProps) { const flatProp = tailwindClassToFlatProp( { ...parsed, entry: parsed.entry ? { ...parsed.entry, prop } : parsed.entry, }, grammarConfig ) if (!flatProp) return 'raw' const entry = createPlanEntry(flatProp.key, flatProp.value, parsed.modifiers) if (!entry) return 'raw' entries.push(entry) } return entries } const flatProp = tailwindClassToFlatProp(parsed, grammarConfig) if (flatProp) { const entry = createPlanEntry(flatProp.key, flatProp.value, parsed.modifiers) return entry ? [entry] : 'raw' } // not claimed: caller preserves the raw class return 'raw' } export function getTailwindClassPlan( candidate: string, config: StyleFrontendConfig ): FrontendClassPlan { const grammarConfig = getStyleGrammarConfig(config) const plans = getClassPlanCache(grammarConfig) let plan = plans.get(candidate) if (plan === undefined) { plan = computeClassPlan(candidate, grammarConfig) plans.set(candidate, plan) } return plan } export function resolveTailwindCandidate( candidate: string, config: StyleFrontendConfig, sink: FrontendClassSink ): boolean | null { const composed = tryCompose(candidate, getStyleGrammarConfig(config), sink) if (composed !== undefined) return composed const plan = getTailwindClassPlan(candidate, config) if (plan === 'raw') return true if (plan === null) return null const parent = Array.isArray(plan) ? null : plan const entries = parent ? parent.entries : plan for (let index = 0; index < entries.length; index++) { const entry = entries[index] const transformTokenCategory = entry[0] === 'x' || entry[0] === 'y' ? 'space' : entry[0] === 'perspective' ? 'perspective' : null const transformValue = transformTokenCategory && typeof entry[1] === 'string' ? ((config as any).tokensParsed?.[transformTokenCategory]?.[entry[1]]?.val ?? entry[1]) : entry[1] if (noteTailwindTransform(sink, [entry[0], transformValue, entry[2], entry[3]])) { continue } // a text size carries its paired line height as a default that any leading // utility overrides whatever the class order, as tailwind's --tw-leading does if (entry[0] === 'lineHeight') { sink(['__leading', entry[1], entry[2], entry[3]]) continue } if ( entry[0] === 'fontSize' && typeof entry[1] === 'string' && hasTokenName(getStyleGrammarConfig(config), 'lineHeight', entry[1]) ) { sink(['__textLeading', entry[1], entry[2], entry[3]]) } if (entry[0] === 'boxShadow' && typeof entry[1] === 'string') { noteBoxShadow( sink, (config as any).tokensParsed?.boxShadow?.[entry[1]]?.val ?? entry[1], entry[3] || [] ) } sink(entry) } return parent?.preserveRawClass || false } export function resolveTailwindClassName( className: string, config: StyleFrontendConfig ): Record { const result: Record = {} // composed utilities collect under `__`-prefixed keys and are folded once at the // end, the same shape and the same order the runtime walk uses let composedProps: Record | null = null let rawClassName = '' const sink: FrontendClassSink = (entry) => { const [key, value, condition] = entry if (key.charCodeAt(0) === 95 && key.charCodeAt(1) === 95) { composedProps ||= {} composedProps[key] = mergeFrontendCondition(composedProps[key], value, condition) return } if (condition === undefined) { const resets = shorthandResets[key] if (resets) { for (let i = 0; i < resets.length; i++) { delete result[resets[i]] } } } setInAuthoredOrder(result, key, mergeFrontendCondition(result[key], value, condition)) } let start = 0 for (let index = 0; index <= className.length; index++) { if (index !== className.length && className.charCodeAt(index) > 32) continue if (start === index) { start = index + 1 continue } const candidate = className.slice(start, index) const preserveRaw = resolveTailwindCandidate(candidate, config, sink) if (preserveRaw) { rawClassName = rawClassName ? `${rawClassName} ${candidate}` : candidate } start = index + 1 } if (composedProps) { const composed = composedResolver(composedProps) for (const key in composed) { setInAuthoredOrder(result, key, composed[key]) } } if (rawClassName) result.className = rawClassName return result } /** * Append a contribution at the end of the forward pass. * * Plain re-assignment keeps a key's FIRST insertion position, so a restated * shorthand would stay behind a longhand authored between the two occurrences: * `p-4 px-2 p-6` has to resolve `paddingLeft` from `p-6`, and `pt-2 p-4 pt-8` has to * resolve `paddingTop` from `pt-8`. Deleting the key first moves it to the end, * which is the authored order the shared per-longhand merge reads. */ export function setInAuthoredOrder( target: Record, key: string, value: any ): void { if (value === 'unset') { delete target[key] return } if (key in target) delete target[key] target[key] = value }