import { DEV } from 'distilt/env' import type { Context, ColorValue, ColorFunction, BaseTheme, MatchResult, RuleResolver, CSSProperties, CSSObject, RuleResult, ThemeValue, KebabCase, MatchConverter, Rule, MaybeArray, } from './types' import { toColorValue } from './colors' import { resolveThemeFunction } from './internal/serialize' import { type AutocompleteProvider, type AutocompleteItem, withAutocomplete, type AutocompleteModifier, } from './autocomplete' export type ThemeMatchResult = MatchResult & { /** The found theme value */ _: Value } export type ThemeRuleResolver = RuleResolver< Theme, ThemeMatchResult > export type ThemeMatchConverter = MatchConverter< Theme, ThemeMatchResult > // indirection wrapper to remove autocomplete functions from production bundles function withAutocomplete$( resolver: RuleResolver, autocomplete: AutocompleteProvider | false, ): RuleResolver { if (DEV) { return withAutocomplete(resolver, autocomplete) } return resolver } /** * @group Configuration * @param pattern */ export function match( pattern: MaybeArray, ): Rule /** * @group Configuration * @param pattern * @param resolver */ export function match( pattern: MaybeArray, resolver: RuleResolver, ): Rule /** * @group Configuration * @param pattern * @param resolve */ export function match( pattern: MaybeArray, // eslint-disable-next-line @typescript-eslint/ban-types resolve: (string & {}) | CSSObject, ): Rule /** * @group Configuration * @param pattern * @param resolve * @param convert */ export function match( pattern: MaybeArray, resolve: keyof CSSProperties, convert?: MatchConverter, ): Rule export function match( pattern: MaybeArray, // eslint-disable-next-line @typescript-eslint/ban-types resolve?: RuleResolver | (string & {}) | CSSObject | keyof CSSProperties, convert?: MatchConverter, ): Rule { return [pattern, fromMatch(resolve as keyof CSSProperties, convert)] } /** * @group Configuration * @internal * @deprecated Use {@link match} instead. */ export function fromMatch(): RuleResolver /** * @group Configuration * @internal * @deprecated Use {@link match} instead. */ export function fromMatch( resolver: RuleResolver, ): RuleResolver /** * @group Configuration * @internal * @deprecated Use {@link match} instead. */ export function fromMatch( resolve: keyof CSSProperties, convert?: MatchConverter, ): RuleResolver /** * @group Configuration * @internal * @deprecated Use {@link match} instead. */ export function fromMatch( resolve: string | CSSObject, ): RuleResolver export function fromMatch( resolve?: RuleResolver | keyof CSSProperties | string | CSSObject, convert?: MatchConverter, ): RuleResolver { return typeof resolve == 'function' ? resolve : typeof resolve == 'string' && /^[\w-]+$/.test(resolve) // a CSS property alias ? (match, context) => ({ [resolve]: convert ? convert(match, context) : maybeNegate(match, 1), } as CSSObject) : (match) => // CSSObject, shortcut or apply resolve || ({ [match[1]]: maybeNegate(match, 2), } as CSSObject) } function maybeNegate( match: MatchResult, offset: number, value: T | string = match.slice(offset).find(Boolean) || match.$$ || match.input, ): T | string { return match.input[0] == '-' ? `calc(${value} * -1)` : value } /** * @group Configuration * @param pattern * @param section * @param resolve * @param convert * @returns */ export function matchTheme< Theme extends BaseTheme = BaseTheme, Section extends keyof Theme & string = keyof Theme & string, >( pattern: MaybeArray, /** Theme section to use (default: `$1` — The first matched group) */ section?: '' | Section | KebabCase
, /** The css property (default: value of {@link section}) */ resolve?: keyof CSSProperties | ThemeRuleResolver, Theme>, convert?: ThemeMatchConverter, Theme>, ): Rule { return [pattern, fromTheme(section, resolve, convert)] } /** * @group Configuration * @internal * @deprecated Use {@link matchTheme} instead. * @param section * @param resolve * @param convert * @returns */ export function fromTheme< Theme extends BaseTheme = BaseTheme, Section extends keyof Theme & string = keyof Theme & string, >( /** Theme section to use (default: `$1` — The first matched group) */ section?: '' | Section | KebabCase
, /** The css property (default: value of {@link section}) */ resolve?: keyof CSSProperties | ThemeRuleResolver, Theme>, convert?: ThemeMatchConverter, Theme>, ): RuleResolver { const factory: ( match: ThemeMatchResult>, context: Context, section: Section, ) => RuleResult = typeof resolve == 'string' ? (match, context) => ({ [resolve]: convert ? convert(match, context) : match._ } as CSSObject) : resolve || (({ 1: $1, _ }, context, section) => ({ [$1 || section]: _ } as CSSObject)) return withAutocomplete$( (match, context) => { const themeSection = camelize(section || match[1]) as Section const value = context.theme(themeSection, match.$$) ?? (arbitrary(match.$$, themeSection, context) as ThemeValue) if (value != null) { ;(match as ThemeMatchResult>)._ = maybeNegate( match, 0, value, ) as ThemeValue return factory(match as ThemeMatchResult>, context, themeSection) } }, DEV && ((match, context) => { const themeSection = camelize(section || match[1]) as Section const isKeyLookup = match.input.endsWith('-') if (isKeyLookup) { return Object.entries(context.theme(themeSection) || {}) .filter( ([key, value]) => key && key != 'DEFAULT' && (!/color|fill|stroke/i.test(themeSection) || ['string', 'function'].includes(typeof value)), ) .map( ([key, value]): AutocompleteItem => ({ suffix: key.replace(/-DEFAULT/g, ''), theme: { section: themeSection, key }, color: /color|fill|stroke/i.test(themeSection) && toColorValue(value as ColorValue, { opacityValue: '1' }), }), ) .concat([{ suffix: '[' }]) } const value = context.theme(themeSection, 'DEFAULT') if (value) { return [ { suffix: '', theme: { section: themeSection, key: 'DEFAULT' }, color: /color|fill|stroke/i.test(themeSection) && toColorValue(value as ColorValue, { opacityValue: '1' }), }, ] } return [] }), ) } export type FilterByThemeValue = { [key in keyof Theme & string]: ThemeValue extends Value ? Theme[key] : never } export interface ColorFromThemeValue { value: string color: ColorFunction opacityVariable: string | undefined opacityValue: string | undefined } export interface ColorFromThemeOptions< Theme extends BaseTheme = BaseTheme, Section extends keyof FilterByThemeValue = keyof FilterByThemeValue< Theme, ColorValue >, OpacitySection extends keyof FilterByThemeValue = keyof FilterByThemeValue< Theme, string >, > { /** Theme section to use (default: `$0.replace('-', 'Color')` — The matched string with `Color` appended) */ section?: Section | KebabCase
/** The css property (default: value of {@link section}) */ property?: keyof CSSProperties /** `--tw-${$0}opacity` -> '--tw-text-opacity' */ opacityVariable?: string | false /** `section.replace('Color', 'Opacity')` -> 'textOpacity' */ opacitySection?: OpacitySection selector?: string } /** * @group Configuration * @param pattern * @param options * @param resolve * @returns */ export function matchColor< Theme extends BaseTheme = BaseTheme, Section extends keyof FilterByThemeValue = keyof FilterByThemeValue< Theme, ColorValue >, OpacitySection extends keyof FilterByThemeValue = keyof FilterByThemeValue< Theme, string >, >( pattern: MaybeArray, options: ColorFromThemeOptions = {}, resolve?: ThemeRuleResolver, ): Rule { return [pattern, colorFromTheme(options, resolve)] } /** * @group Configuration * @internal * @deprecated Use {@link matchColor} instead. * @param options * @param resolve * @returns */ export function colorFromTheme< Theme extends BaseTheme = BaseTheme, Section extends keyof FilterByThemeValue = keyof FilterByThemeValue< Theme, ColorValue >, OpacitySection extends keyof FilterByThemeValue = keyof FilterByThemeValue< Theme, string >, >( options: ColorFromThemeOptions = {}, resolve?: ThemeRuleResolver, ): RuleResolver { return withAutocomplete$( (match, context) => { // text- -> textColor // ring-offset(?:-|$) -> ringOffsetColor const { section = (camelize(match[0]).replace('-', '') + 'Color') as Section } = options // extract color and opacity // rose-500 -> ['rose-500'] // [hsl(0_100%_/_50%)] -> ['[hsl(0_100%_/_50%)]'] // indigo-500/100 -> ['indigo-500', '100'] // [hsl(0_100%_/_50%)]/[.25] -> ['[hsl(0_100%_/_50%)]', '[.25]'] if (!/^(\[[^\]]+]|[^/]+?)(?:\/(.+))?$/.test(match.$$)) return const { $1: colorMatch, $2: opacityMatch } = RegExp const colorValue = (context.theme(section, colorMatch) as ColorValue) || arbitrary(colorMatch, section, context) if (!colorValue || typeof colorValue == 'object') return const { // text- -> --tw-text-opacity // ring-offset(?:-|$) -> --tw-ring-offset-opacity // TODO move this default into preset-tailwind? opacityVariable = `--tw-${match[0].replace(/-$/, '')}-opacity`, opacitySection = section.replace('Color', 'Opacity') as OpacitySection, property = section, selector, } = options const opacityValue = (context.theme(opacitySection, opacityMatch || 'DEFAULT') as string | undefined) || (opacityMatch && arbitrary(opacityMatch, opacitySection, context)) // if (typeof color != 'string') { // console.warn(`Invalid color ${colorMatch} (from ${match.input}):`, color) // return // } const create = resolve || (({ _ }) => { const properties = toCSS(property, _) return selector ? { [selector]: properties } : properties }) ;(match as ThemeMatchResult)._ = { value: toColorValue(colorValue, { opacityVariable: opacityVariable || undefined, opacityValue: opacityValue || undefined, }), color: (options) => toColorValue(colorValue, options), opacityVariable: opacityVariable || undefined, opacityValue: opacityValue || undefined, } let properties = create(match as ThemeMatchResult, context) // auto support dark mode colors if (!match.dark) { const darkColorValue = context.d(section, colorMatch, colorValue) if (darkColorValue && darkColorValue !== colorValue) { ;(match as ThemeMatchResult)._ = { value: toColorValue(darkColorValue, { opacityVariable: opacityVariable || undefined, opacityValue: opacityValue || '1', }), color: (options) => toColorValue(darkColorValue, options), opacityVariable: opacityVariable || undefined, opacityValue: opacityValue || undefined, } properties = { '&': properties, [context.v('dark') as string]: create( match as ThemeMatchResult, context, ), } as CSSObject } } return properties }, DEV && ((match, context) => { const { section = (camelize(match[0]).replace('-', '') + 'Color') as Section, opacitySection = section.replace('Color', 'Opacity') as OpacitySection, } = options const isKeyLookup = match.input.endsWith('-') const opacities = Object.entries(context.theme(opacitySection) || {}).filter( ([key, value]) => key != 'DEFAULT' && /^[\w-]+$/.test(key) && typeof value == 'string', ) if (isKeyLookup) { // ['gray-50', ['/0', '/10', ...]], // ['gray-100', ['/0', '/10', ...]], return Object.entries(context.theme(section) || {}) .filter( ([key, value]) => key && key != 'DEFAULT' && ['string', 'function'].includes(typeof value), ) .map( ([key, value]): AutocompleteItem => ({ suffix: key.replace(/-DEFAULT/g, ''), theme: { section, key }, color: toColorValue(value as ColorValue, { opacityValue: (context.theme(opacitySection, 'DEFAULT') as string) || '1', }), modifiers: (typeof value == 'function' || (typeof value == 'string' && (value.includes('') || (value[0] == '#' && (value.length == 4 || value.length == 7))))) && opacities .map( ([key, opacityValue]): AutocompleteModifier => ({ modifier: key, theme: { section: opacitySection, key }, color: toColorValue(value as ColorValue, { opacityValue: opacityValue as string, }), }), ) .concat([ { modifier: '[', color: toColorValue(value as ColorValue, { opacityValue: '1' }), }, ]), }), ) .concat([{ suffix: '[' }]) } const value = context.theme(section, 'DEFAULT') if (value) { return [ { suffix: '', theme: { section, key: 'DEFAULT' }, color: toColorValue(value as ColorValue, { opacityValue: (context.theme(opacitySection, 'DEFAULT') as string) || '1', }), modifiers: (typeof value == 'function' || (typeof value == 'string' && (value.includes('') || (value[0] == '#' && (value.length == 4 || value.length == 7))))) && opacities .map( ([key, opacityValue]): AutocompleteModifier => ({ modifier: key, theme: { section: opacitySection, key }, color: toColorValue(value as ColorValue, { opacityValue: opacityValue as string, }), }), ) .concat([ { modifier: '[', color: toColorValue(value as ColorValue, { opacityValue: '1' }), }, ]), }, ] } return [] }), ) } /** * @internal * @param property * @param value * @returns */ export function toCSS(property: string, value: string | ColorFromThemeValue): CSSObject { const properties: CSSObject = {} if (typeof value === 'string') { properties[property] = value } else { if (value.opacityVariable && value.value.includes(value.opacityVariable)) { properties[value.opacityVariable] = value.opacityValue || '1' } properties[property] = value.value } return properties } /** * @internal * @param value * @param section * @param context * @returns */ export function arbitrary( value: string, section: string, context: Context, ): string | undefined { if (value[0] == '[' && value.slice(-1) == ']') { value = normalize(resolveThemeFunction(value.slice(1, -1), context.theme)) if ( // Respect type hints from the user on ambiguous arbitrary values - https://tailwindcss.com/docs/adding-custom-styles#resolving-ambiguities !( // If this is a color section and the value is a hex color, color function or color name ( (/color|fill|stroke/i.test(section) && !( /^color:/.test(value) || /^(#|((hsl|rgb)a?|hwb|lab|lch|color)\(|[a-z]+$)/.test(value) )) || // url(, [a-z]-gradient(, image(, cross-fade(, image-set( (/image/i.test(section) && !(/^image:/.test(value) || /^[a-z-]+\(/.test(value))) || // font-* // - fontWeight (type: ['lookup', 'number', 'any']) // - fontFamily (type: ['lookup', 'generic-name', 'family-name']) (/weight/i.test(section) && !(/^(number|any):/.test(value) || /^\d+$/.test(value))) || // bg-* // - backgroundPosition (type: ['lookup', ['position', { preferOnConflict: true }]]) // - backgroundSize (type: ['lookup', 'length', 'percentage', 'size']) (/position/i.test(section) && /^(length|size):/.test(value)) ) ) ) { // remove arbitrary type prefix — we do not need it but user may use it // https://github.com/tailwindlabs/tailwindcss/blob/master/src/util/dataTypes.js // url, number, percentage, length, line-width, shadow, color, image, gradient, position, family-name, lookup, any, generic-name, absolute-size, relative-size return value.replace(/^[a-z-]+:/, '') } } } function camelize(value: string): string { return value.replace(/-./g, (x) => x[1].toUpperCase()) } /** * @internal * @param value * @returns */ export function normalize(value: string): string { // Keep raw strings if it starts with `url(` if (value.includes('url(')) { return value.replace( /(.*?)(url\(.*?\))(.*?)/g, (_, before = '', url, after = '') => normalize(before) + url + normalize(after), ) } return ( value // Convert `_` to ` `, except for escaped underscores `\_` .replace( /(^|[^\\])_+/g, (fullMatch, characterBefore: string) => characterBefore + ' '.repeat(fullMatch.length - characterBefore.length), ) .replace(/\\_/g, '_') // Add spaces around operators inside math functions like calc() that do not follow an operator // or '('. .replace(/(calc|min|max|clamp)\(.+\)/g, (match) => match.replace( /(-?\d*\.?\d(?!\b-.+[,)](?![^+\-/*])\D)(?:%|[a-z]+)?|\))([+\-/*])/g, '$1 $2 ', ), ) ) }