aliaset
Version:
twind monorepo
655 lines (583 loc) • 20.4 kB
text/typescript
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<Value> = MatchResult & {
/** The found theme value */
_: Value
}
export type ThemeRuleResolver<Value, Theme extends BaseTheme = BaseTheme> = RuleResolver<
Theme,
ThemeMatchResult<Value>
>
export type ThemeMatchConverter<Value, Theme extends BaseTheme = BaseTheme> = MatchConverter<
Theme,
ThemeMatchResult<Value>
>
// indirection wrapper to remove autocomplete functions from production bundles
function withAutocomplete$<Theme extends BaseTheme = BaseTheme>(
resolver: RuleResolver<Theme>,
autocomplete: AutocompleteProvider<Theme> | false,
): RuleResolver<Theme> {
if (DEV) {
return withAutocomplete(resolver, autocomplete)
}
return resolver
}
/**
* @group Configuration
* @param pattern
*/
export function match<Theme extends BaseTheme = BaseTheme>(
pattern: MaybeArray<string | RegExp>,
): Rule<Theme>
/**
* @group Configuration
* @param pattern
* @param resolver
*/
export function match<Theme extends BaseTheme = BaseTheme>(
pattern: MaybeArray<string | RegExp>,
resolver: RuleResolver<Theme>,
): Rule<Theme>
/**
* @group Configuration
* @param pattern
* @param resolve
*/
export function match<Theme extends BaseTheme = BaseTheme>(
pattern: MaybeArray<string | RegExp>,
// eslint-disable-next-line @typescript-eslint/ban-types
resolve: (string & {}) | CSSObject,
): Rule<Theme>
/**
* @group Configuration
* @param pattern
* @param resolve
* @param convert
*/
export function match<Theme extends BaseTheme = BaseTheme>(
pattern: MaybeArray<string | RegExp>,
resolve: keyof CSSProperties,
convert?: MatchConverter<Theme>,
): Rule<Theme>
export function match<Theme extends BaseTheme = BaseTheme>(
pattern: MaybeArray<string | RegExp>,
// eslint-disable-next-line @typescript-eslint/ban-types
resolve?: RuleResolver<Theme> | (string & {}) | CSSObject | keyof CSSProperties,
convert?: MatchConverter<Theme>,
): Rule<Theme> {
return [pattern, fromMatch(resolve as keyof CSSProperties, convert)]
}
/**
* @group Configuration
* @internal
* @deprecated Use {@link match} instead.
*/
export function fromMatch<Theme extends BaseTheme = BaseTheme>(): RuleResolver<Theme>
/**
* @group Configuration
* @internal
* @deprecated Use {@link match} instead.
*/
export function fromMatch<Theme extends BaseTheme = BaseTheme>(
resolver: RuleResolver<Theme>,
): RuleResolver<Theme>
/**
* @group Configuration
* @internal
* @deprecated Use {@link match} instead.
*/
export function fromMatch<Theme extends BaseTheme = BaseTheme>(
resolve: keyof CSSProperties,
convert?: MatchConverter<Theme>,
): RuleResolver<Theme>
/**
* @group Configuration
* @internal
* @deprecated Use {@link match} instead.
*/
export function fromMatch<Theme extends BaseTheme = BaseTheme>(
resolve: string | CSSObject,
): RuleResolver<Theme>
export function fromMatch<Theme extends BaseTheme = BaseTheme>(
resolve?: RuleResolver<Theme> | keyof CSSProperties | string | CSSObject,
convert?: MatchConverter<Theme>,
): RuleResolver<Theme> {
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<T>(
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<string | RegExp>,
/** Theme section to use (default: `$1` — The first matched group) */
section?: '' | Section | KebabCase<Section>,
/** The css property (default: value of {@link section}) */
resolve?: keyof CSSProperties | ThemeRuleResolver<ThemeValue<Theme[Section]>, Theme>,
convert?: ThemeMatchConverter<ThemeValue<Theme[Section]>, Theme>,
): Rule<Theme> {
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<Section>,
/** The css property (default: value of {@link section}) */
resolve?: keyof CSSProperties | ThemeRuleResolver<ThemeValue<Theme[Section]>, Theme>,
convert?: ThemeMatchConverter<ThemeValue<Theme[Section]>, Theme>,
): RuleResolver<Theme> {
const factory: (
match: ThemeMatchResult<ThemeValue<Theme[Section]>>,
context: Context<Theme>,
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<Theme[Section]>)
if (value != null) {
;(match as ThemeMatchResult<ThemeValue<Theme[Section]>>)._ = maybeNegate(
match,
0,
value,
) as ThemeValue<Theme[Section]>
return factory(match as ThemeMatchResult<ThemeValue<Theme[Section]>>, 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<Theme, Value> = {
[key in keyof Theme & string]: ThemeValue<Theme[key]> 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<Theme, ColorValue> = keyof FilterByThemeValue<
Theme,
ColorValue
>,
OpacitySection extends keyof FilterByThemeValue<Theme, string> = keyof FilterByThemeValue<
Theme,
string
>,
> {
/** Theme section to use (default: `$0.replace('-', 'Color')` — The matched string with `Color` appended) */
section?: Section | KebabCase<Section>
/** 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<Theme, ColorValue> = keyof FilterByThemeValue<
Theme,
ColorValue
>,
OpacitySection extends keyof FilterByThemeValue<Theme, string> = keyof FilterByThemeValue<
Theme,
string
>,
>(
pattern: MaybeArray<string | RegExp>,
options: ColorFromThemeOptions<Theme, Section, OpacitySection> = {},
resolve?: ThemeRuleResolver<ColorFromThemeValue, Theme>,
): Rule<Theme> {
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<Theme, ColorValue> = keyof FilterByThemeValue<
Theme,
ColorValue
>,
OpacitySection extends keyof FilterByThemeValue<Theme, string> = keyof FilterByThemeValue<
Theme,
string
>,
>(
options: ColorFromThemeOptions<Theme, Section, OpacitySection> = {},
resolve?: ThemeRuleResolver<ColorFromThemeValue, Theme>,
): RuleResolver<Theme> {
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<ColorFromThemeValue>)._ = {
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<ColorFromThemeValue>, context)
// auto support dark mode colors
if (!match.dark) {
const darkColorValue = context.d(section, colorMatch, colorValue)
if (darkColorValue && darkColorValue !== colorValue) {
;(match as ThemeMatchResult<ColorFromThemeValue>)._ = {
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<ColorFromThemeValue>,
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('<alpha-value>') ||
(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('<alpha-value>') ||
(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<Theme extends BaseTheme = BaseTheme>(
value: string,
section: string,
context: Context<Theme>,
): 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 ',
),
)
)
}