Skip to content

Commit

Permalink
feat(core): introduce special symbols for applying custom variants (u…
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu authored and Simon-He95 committed Jun 12, 2024
1 parent 7830938 commit 7203d44
Show file tree
Hide file tree
Showing 14 changed files with 421 additions and 90 deletions.
112 changes: 89 additions & 23 deletions docs/config/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,95 @@ the corresponding CSS will be generated:

Congratulations! Now you've got your own powerful atomic CSS utilities. Enjoy!

## Ordering

UnoCSS respects the order of the rules you defined in the generated CSS. Latter ones come with higher priority.

When using dynamic rules, it may match multiple tokens. By default, the output of those matched under a single dynamic rule will be sorted alphabetically within the group.

## Rules merging

By default, UnoCSS will merge CSS rules with the same body to minimize the CSS size.

For example, `<div class="m-2 hover:m2">` will generate:

```css
.hover\:m2:hover, .m-2 { margin: 0.5rem; }
```

Instead of two separate rules:

```css
.hover\:m2:hover { margin: 0.5rem; }
.m-2 { margin: 0.5rem; }
```

## Special symbols

Since v0.61, UnoCSS supports special symbols to define additional meta information for your generated CSS. You can access symbols from the second argument of the dynamic rule matcher function.

For example:

```ts
rules: [
[/^grid$/, ([, d], { symbols }) => {
return {
[symbols.parent]: '@supports (display: grid)',
display: 'grid',
}
}],
]
```

Will generate:

```css
@supports (display: grid) {
.grid {
display: grid;
}
}
```

### Available symbols

- `symbols.parent`: The parent wrapper of the generated CSS rule (eg. `@supports`, `@media`, etc.)
- `symbols.selector`: A function to modify the selector of the generated CSS rule (see the example below)
- `symbols.variants`: An array of variant handler that are applied to the current CSS object
- `symbols.shortcutsNoMerge`: A boolean to disable the merging of the current rule in shortcuts

## Multi-selector rules

Since v0.61, UnoCSS supports multi-selector via [JavaScript Generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator).

For example:

```ts
rules: [
[/^button-(.*)$/, function* ([, color], { symbols }) {
yield {
background: color
}
yield {
[symbols.selector]: selector => `${selector}:hover`,
// https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix
background: `color-mix(in srgb, ${color} 90%, black)`
}
}],
]
```

Will generate multiple CSS rules:

```css
.button-red {
background: red;
}
.button-red:hover {
background: color-mix(in srgb, red 90%, black);
}
```

## Fully controlled rules

::: tip
Expand Down Expand Up @@ -113,26 +202,3 @@ ${selector}::after {
],
})
```

## Ordering

UnoCSS respects the order of the rules you defined in the generated CSS. Latter ones come with higher priority.

When using dynamic rules, it may match multiple tokens. By default, the output of those matched under a single dynamic rule will be sorted alphabetically within the group.

## Rules merging

By default, UnoCSS will merge CSS rules with the same body to minimize the CSS size.

For example, `<div class="m-2 hover:m2">` will generate:

```css
.hover\:m2:hover, .m-2 { margin: 0.5rem; }
```

Instead of two separate rules:

```css
.hover\:m2:hover { margin: 0.5rem; }
.m-2 { margin: 0.5rem; }
```
69 changes: 55 additions & 14 deletions packages/core/src/generator/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { createNanoEvents } from '../utils/events'
import type { BlocklistMeta, BlocklistValue, CSSEntries, CSSObject, CSSValue, DynamicRule, ExtendedTokenInfo, ExtractorContext, GenerateOptions, GenerateResult, ParsedUtil, PreflightContext, PreparedRule, RawUtil, ResolvedConfig, RuleContext, RuleMeta, SafeListContext, Shortcut, ShortcutValue, StringifiedUtil, UserConfig, UserConfigDefaults, UtilObject, Variant, VariantContext, VariantHandler, VariantHandlerContext, VariantMatchedResult } from '../types'
import type { BlocklistMeta, BlocklistValue, CSSEntries, CSSEntriesInput, CSSObject, CSSValueInput, ControlSymbols, ControlSymbolsEntry, DynamicRule, ExtendedTokenInfo, ExtractorContext, GenerateOptions, GenerateResult, ParsedUtil, PreflightContext, PreparedRule, RawUtil, ResolvedConfig, RuleContext, RuleMeta, SafeListContext, Shortcut, ShortcutValue, StringifiedUtil, UserConfig, UserConfigDefaults, UtilObject, Variant, VariantContext, VariantHandler, VariantHandlerContext, VariantMatchedResult } from '../types'
import { resolveConfig } from '../config'
import { BetterMap, CONTROL_SHORTCUT_NO_MERGE, CountableSet, TwoKeyMap, e, entriesToCss, expandVariantGroup, isCountableSet, isRawUtil, isStaticShortcut, isString, noop, normalizeCSSEntries, normalizeCSSValues, notNull, toArray, uniq, warnOnce } from '../utils'
import { BetterMap, CountableSet, TwoKeyMap, e, entriesToCss, expandVariantGroup, isCountableSet, isRawUtil, isStaticShortcut, isString, noop, normalizeCSSEntries, normalizeCSSValues, notNull, toArray, uniq, warnOnce } from '../utils'
import { version } from '../../package.json'
import { LAYER_DEFAULT, LAYER_PREFLIGHTS } from '../constants'

export const symbols: ControlSymbols = {
shortcutsNoMerge: '$$symbol-shortcut-no-merge' as unknown as ControlSymbols['shortcutsNoMerge'],
variants: '$$symbol-variants' as unknown as ControlSymbols['variants'],
parent: '$$symbol-parent' as unknown as ControlSymbols['parent'],
selector: '$$symbol-selector' as unknown as ControlSymbols['selector'],
}

export class UnoGenerator<Theme extends object = object> {
public version = version
private _cache = new Map<string, StringifiedUtil<Theme>[] | null>()
Expand Down Expand Up @@ -87,6 +94,7 @@ export class UnoGenerator<Theme extends object = object> {
currentSelector: applied[1],
theme: this.config.theme,
generator: this,
symbols,
variantHandlers: applied[2],
constructCSS: (...args) => this.constructCustomCSS(context, ...args),
variantMatch: applied,
Expand Down Expand Up @@ -409,7 +417,7 @@ export class UnoGenerator<Theme extends object = object> {
continue
handler = { matcher: handler }
}
processed = handler.matcher
processed = handler.matcher ?? processed
handlers.unshift(handler)
variants.add(v)
applied = true
Expand All @@ -435,7 +443,9 @@ export class UnoGenerator<Theme extends object = object> {
.reduceRight(
(previous, v) => (input: VariantHandlerContext) => {
const entries = v.body?.(input.entries) || input.entries
const parents: [string | undefined, number | undefined] = Array.isArray(v.parent) ? v.parent : [v.parent, undefined]
const parents: [string | undefined, number | undefined] = Array.isArray(v.parent)
? v.parent
: [v.parent, undefined]
return (v.handle ?? defaultVariantHandler)({
...input,
entries,
Expand Down Expand Up @@ -567,7 +577,7 @@ export class UnoGenerator<Theme extends object = object> {
// Handle generator result
if (typeof result !== 'string') {
if (Symbol.asyncIterator in result) {
const entries: (CSSValue | string)[] = []
const entries: (CSSValueInput | string)[] = []
for await (const r of result) {
if (r)
entries.push(r)
Expand All @@ -580,13 +590,37 @@ export class UnoGenerator<Theme extends object = object> {
}
}

const entries = normalizeCSSValues(result).filter(i => i.length)
const entries = normalizeCSSValues(result).filter(i => i.length) as (string | CSSEntriesInput)[]
if (entries.length) {
return entries.map((e) => {
if (isString(e))
return [i, e, meta]
else
return [i, raw, e, meta, variantHandlers]
return entries.map((css): ParsedUtil | RawUtil => {
if (isString(css)) {
return [i, css, meta]
}

// Extract variants from special symbols
let variants = variantHandlers
for (const entry of css) {
if (entry[0] === symbols.variants) {
variants = [
...toArray(entry[1]),
...variants,
]
}
else if (entry[0] === symbols.parent) {
variants = [
{ parent: entry[1] },
...variants,
]
}
else if (entry[0] === symbols.selector) {
variants = [
{ selector: entry[1] },
...variants,
]
}
}

return [i, raw, css as CSSEntries, meta, variants]
})
}
}
Expand All @@ -601,7 +635,14 @@ export class UnoGenerator<Theme extends object = object> {
if (isRawUtil(parsed))
return [parsed[0], undefined, parsed[1], undefined, parsed[2], this.config.details ? context : undefined, undefined]

const { selector, entries, parent, layer: variantLayer, sort: variantSort, noMerge } = this.applyVariants(parsed)
const {
selector,
entries,
parent,
layer: variantLayer,
sort: variantSort,
noMerge,
} = this.applyVariants(parsed)
const body = entriesToCss(entries)

if (!body)
Expand Down Expand Up @@ -753,8 +794,8 @@ export class UnoGenerator<Theme extends object = object> {
] as [[CSSEntries, number][], boolean][]

return merges.map(([e, noMerge]) => [
...stringify(false, noMerge, e.filter(([entries]) => entries.some(entry => entry[0] === CONTROL_SHORTCUT_NO_MERGE))),
...stringify(true, noMerge, e.filter(([entries]) => entries.every(entry => entry[0] !== CONTROL_SHORTCUT_NO_MERGE))),
...stringify(false, noMerge, e.filter(([entries]) => entries.some(entry => (entry as unknown as ControlSymbolsEntry)[0] === symbols.shortcutsNoMerge))),
...stringify(true, noMerge, e.filter(([entries]) => entries.every(entry => (entry as unknown as ControlSymbolsEntry)[0] !== symbols.shortcutsNoMerge))),
])
})
.flat(2)
Expand Down
86 changes: 49 additions & 37 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,40 +15,12 @@ export type PartialByKeys<T, K extends keyof T = keyof T> = FlatObjectTuple<Part
export type RequiredByKey<T, K extends keyof T = keyof T> = FlatObjectTuple<Required<Pick<T, Extract<keyof T, K>>> & Omit<T, K>>

export type CSSObject = Record<string, string | number | undefined>
export type CSSEntries = [string, string | number | undefined][]
export interface CSSColorValue {
type: string
components: (string | number)[]
alpha: string | number | undefined
}
export type CSSEntry = [string, string | number | undefined]
export type CSSEntries = CSSEntry[]

export type RGBAColorValue = [number, number, number, number] | [number, number, number]
export interface ParsedColorValue {
/**
* Parsed color value.
*/
color?: string
/**
* Parsed opacity value.
*/
opacity: string
/**
* Color name.
*/
name: string
/**
* Color scale, preferably 000 - 999.
*/
no: string
/**
* {@link CSSColorValue}
*/
cssColor: CSSColorValue | undefined
/**
* Parsed alpha value from opacity
*/
alpha: string | number | undefined
}
export type CSSObjectInput = CSSObject | Partial<ControlSymbolsValue>
export type CSSEntriesInput = (CSSEntry | ControlSymbolsEntry)[]
export type CSSValueInput = CSSObjectInput | CSSEntriesInput | CSSValue

export type PresetOptions = Record<string, any>

Expand All @@ -66,6 +38,10 @@ export interface RuleContext<Theme extends object = object> {
* UnoCSS generator instance
*/
generator: UnoGenerator<Theme>
/**
* Symbols for special handling
*/
symbols: ControlSymbols
/**
* The theme object
*/
Expand Down Expand Up @@ -97,6 +73,41 @@ export interface RuleContext<Theme extends object = object> {
variants?: Variant<Theme>[]
}

declare const SymbolShortcutsNoMerge: unique symbol
declare const SymbolVariants: unique symbol
declare const SymbolParent: unique symbol
declare const SymbolSelector: unique symbol

export interface ControlSymbols {
/**
* Prevent merging in shortcuts
*/
shortcutsNoMerge: typeof SymbolShortcutsNoMerge
/**
* Additional variants applied to this rule
*/
variants: typeof SymbolVariants
/**
* Parent selector (`@media`, `@supports`, etc.)
*/
parent: typeof SymbolParent
/**
* Selector modifier
*/
selector: typeof SymbolSelector
}

export interface ControlSymbolsValue {
[SymbolShortcutsNoMerge]: true
[SymbolVariants]: VariantHandler[]
[SymbolParent]: string
[SymbolSelector]: (selector: string) => string
}

export type ObjectToEntry<T> = { [K in keyof T]: [K, T[K]] }[keyof T]

export type ControlSymbolsEntry = ObjectToEntry<ControlSymbolsValue>

export interface VariantContext<Theme extends object = object> {
/**
* Unprocessed selector from user input.
Expand Down Expand Up @@ -194,9 +205,10 @@ export type DynamicMatcher<Theme extends object = object> =
(
match: RegExpMatchArray,
context: Readonly<RuleContext<Theme>>
) => Awaitable<CSSValue | string | (CSSValue | string)[] | undefined>
| Generator<CSSValue | string | undefined>
| AsyncGenerator<CSSValue | string | undefined>
) =>
| Awaitable<CSSValueInput | string | (CSSValueInput | string)[] | undefined>
| Generator<CSSValueInput | string | undefined>
| AsyncGenerator<CSSValueInput | string | undefined>

export type DynamicRule<Theme extends object = object> = [RegExp, DynamicMatcher<Theme>] | [RegExp, DynamicMatcher<Theme>, RuleMeta]
export type StaticRule = [string, CSSObject | CSSEntries] | [string, CSSObject | CSSEntries, RuleMeta]
Expand Down Expand Up @@ -275,7 +287,7 @@ export interface VariantHandler {
/**
* The result rewritten selector for the next round of matching
*/
matcher: string
matcher?: string
/**
* Order in which the variant is applied to selector.
*/
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ export const cssIdRE = /\.(css|postcss|sass|scss|less|stylus|styl)($|\?)/
// eslint-disable-next-line regexp/no-obscure-range
export const validateFilterRE = /[\w\u00A0-\uFFFF%-?]/

export const CONTROL_SHORTCUT_NO_MERGE = '$$shortcut-no-merge'

export function isAttributifySelector(selector: string) {
return selector.match(attributifyRE)
}
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/utils/object.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { CSSEntries, CSSObject, CSSValue, DeepPartial, Rule, Shortcut, StaticRule, StaticShortcut } from '../types'
import type { CSSEntries, CSSEntriesInput, CSSObjectInput, CSSValue, CSSValueInput, DeepPartial, Rule, Shortcut, StaticRule, StaticShortcut } from '../types'
import { isString } from './basic'

export function normalizeCSSEntries(obj: string | CSSEntries | CSSObject): string | CSSEntries {
export function normalizeCSSEntries(obj: string | CSSEntriesInput | CSSObjectInput): string | CSSEntries {
if (isString(obj))
return obj
return (!Array.isArray(obj) ? Object.entries(obj) : obj).filter(i => i[1] != null)
return (!Array.isArray(obj) ? Object.entries(obj) : obj).filter(i => i[1] != null) as CSSEntries
}

export function normalizeCSSValues(obj: CSSValue | string | (CSSValue | string)[]): (string | CSSEntries)[] {
export function normalizeCSSValues(obj: CSSValueInput | string | (CSSValueInput | string)[]): (string | CSSEntries)[] {
if (Array.isArray(obj)) {
// eslint-disable-next-line ts/prefer-ts-expect-error
// @ts-ignore type cast
Expand Down
Loading

0 comments on commit 7203d44

Please sign in to comment.