diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 952376f1ec..79809ffb3e 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -1,5 +1,5 @@ import { createNanoEvents } from '../utils/events' -import type { CSSEntries, CSSObject, ExtractorContext, GenerateOptions, GenerateResult, ParsedUtil, PreflightContext, PreparedRule, RawUtil, ResolvedConfig, Rule, RuleContext, RuleMeta, Shortcut, StringifiedUtil, UserConfig, UserConfigDefaults, UtilObject, Variant, VariantContext, VariantHandler, VariantMatchedResult } from '../types' +import type { CSSEntries, CSSObject, ExtractorContext, GenerateOptions, GenerateResult, ParsedUtil, PreflightContext, PreparedRule, RawUtil, ResolvedConfig, Rule, RuleContext, RuleMeta, Shortcut, StringifiedUtil, UserConfig, UserConfigDefaults, UtilObject, Variant, VariantContext, VariantHandler, VariantHandlerContext, VariantMatchedResult } from '../types' import { resolveConfig } from '../config' import { CONTROL_SHORTCUT_NO_MERGE, TwoKeyMap, e, entriesToCss, expandVariantGroup, isRawUtil, isStaticShortcut, noop, normalizeCSSEntries, normalizeCSSValues, notNull, uniq, warnOnce } from '../utils' import { version } from '../../package.json' @@ -322,8 +322,6 @@ export class UnoGenerator { if (typeof handler === 'string') handler = { matcher: handler } processed = handler.matcher - if (Array.isArray(handler.parent)) - this.parentOrders.set(handler.parent[0], handler.parent[1]) handlers.unshift(handler) variants.add(v) applied = true @@ -340,27 +338,41 @@ export class UnoGenerator { } applyVariants(parsed: ParsedUtil, variantHandlers = parsed[4], raw = parsed[1]): UtilObject { - const handlers = [...variantHandlers].sort((a, b) => (a.order || 0) - (b.order || 0)) - - let entries: CSSEntries = parsed[2] - let selector = toEscapedSelector(raw) - let parent: string | undefined - let layer: string | undefined - let sort: number | undefined - handlers.forEach((v) => { - entries = v.body?.(entries) || entries - selector = v.selector?.(selector, entries) || selector - parent = Array.isArray(v.parent) ? v.parent[0] : v.parent || parent - layer = v.layer || layer - sort = v.sort || sort + const handler = [...variantHandlers] + .sort((a, b) => (a.order || 0) - (b.order || 0)) + .reverse() + .reduce( + (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] + return (v.handle ?? defaultVariantHandler)({ + entries, + selector: v.selector?.(input.selector, entries) || input.selector, + parent: parents[0] || input.parent, + parentOrder: parents[1] || input.parentOrder, + layer: v.layer || input.layer, + sort: v.sort || input.sort, + }, previous) + }, + (input: VariantHandlerContext) => input, + ) + + const variantContextResult = handler({ + entries: parsed[2], + selector: toEscapedSelector(raw), }) + const { parent, parentOrder, selector } = variantContextResult + if (parent != null && parentOrder != null) + this.parentOrders.set(parent, parentOrder) + const obj: UtilObject = { selector: movePseudoElementsEnd(selector), - entries, + entries: variantContextResult.entries, parent, - layer, - sort, + layer: variantContextResult.layer, + sort: variantContextResult.sort, } for (const p of this.config.postprocess) @@ -550,14 +562,14 @@ export class UnoGenerator { mapItem[0].push([entries, !!item[3]?.noMerge, sort ?? 0]) } return rawStringfieldUtil.concat(selectorMap - .map(([e, index], selector, mediaQuery) => { + .map(([e, index], selector, joinedParents) => { const stringify = (flatten: boolean, noMerge: boolean, entrySortPair: [CSSEntries, number][]): (StringifiedUtil | undefined)[] => { const maxSort = Math.max(...entrySortPair.map(e => e[1])) const entriesList = entrySortPair.map(e => e[0]) return (flatten ? [entriesList.flat(1)] : entriesList).map((entries: CSSEntries): StringifiedUtil | undefined => { const body = entriesToCss(entries) if (body) - return [index, selector, body, mediaQuery, { ...meta, noMerge, sort: maxSort }, context] + return [index, selector, body, joinedParents, { ...meta, noMerge, sort: maxSort }, context] return undefined }) } @@ -610,3 +622,7 @@ export function toEscapedSelector(raw: string) { return raw.replace(attributifyRe, (_, n, s, i) => `[${e(n)}${s}"${e(i)}"]`) return `.${e(raw)}` } + +function defaultVariantHandler(input: VariantHandlerContext, next: (input: VariantHandlerContext) => VariantHandlerContext) { + return next(input) +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f487028db4..bfc5673082 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -189,11 +189,46 @@ export interface Preflight { export type BlocklistRule = string | RegExp +export interface VariantHandlerContext { + /** + * Rewrite the output selector. Often be used to append pesudo classes or parents. + */ + selector: string + /** + * Rewrite the output css body. The input come in [key,value][] pairs. + */ + entries: CSSEntries + /** + * Provide a parent selector(e.g. media query) to the output css. + */ + parent?: string + /** + * Provide order to the `parent` parent selector within layer. + */ + parentOrder?: number + /** + * Override layer to the output css. + */ + layer?: string + /** + * Order in which the variant is sorted within single rule. + */ + sort?: number +} + export interface VariantHandler { + /** + * Callback to process the handler. + */ + handle?: (input: VariantHandlerContext, next: (input: VariantHandlerContext) => VariantHandlerContext) => VariantHandlerContext /** * The result rewritten selector for the next round of matching */ matcher: string + /** + * Order in which the variant is applied to selector. + */ + order?: number /** * Rewrite the output selector. Often be used to append pesudo classes or parents. */ @@ -206,10 +241,6 @@ export interface VariantHandler { * Provide a parent selector(e.g. media query) to the output css. */ parent?: string | [string, number] | undefined - /** - * Order in which the variant is applied to selector. - */ - order?: number /** * Order in which the variant is sorted within single rule. */ diff --git a/test/__snapshots__/variant-handler.test.ts.snap b/test/__snapshots__/variant-handler.test.ts.snap new file mode 100644 index 0000000000..42e13942d4 --- /dev/null +++ b/test/__snapshots__/variant-handler.test.ts.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1 + +exports[`variants > variant can stack 1`] = ` +"/* layer: default */ +.first\\\\:second\\\\:third\\\\:foo > :third > :second > :first, +.first\\\\:three\\\\:two\\\\:foo > :first + :three + :two, +.one\\\\:two\\\\:three\\\\:foo + :one + :two + :three{name:bar;}" +`; + +exports[`variants > variant context is propagated 1`] = ` +"/* layer: default */ +.foo{name:bar;} +/* layer: variant */ +@supports{ +.selector{name:bar !important;} +}" +`; diff --git a/test/variant-handler.test.ts b/test/variant-handler.test.ts new file mode 100644 index 0000000000..4c85e01a7b --- /dev/null +++ b/test/variant-handler.test.ts @@ -0,0 +1,91 @@ +import { createGenerator } from '@unocss/core' +import { describe, expect, test } from 'vitest' + +describe('variants', () => { + test('variant context is propagated', async () => { + const uno = createGenerator({ + rules: [ + ['foo', { name: 'bar' }], + ], + variants: [ + { + match(input) { + const match = input.match(/^var:/) + if (match) { + return { + matcher: input.slice(match[0].length), + handle: (input, next) => next({ + selector: '.selector', + entries: input.entries.map((entry) => { + entry[1] += ' !important' + return entry + }), + parent: '@supports', + layer: 'variant', + }), + } + } + }, + }, + ], + }) + + const { css } = await uno.generate([ + 'foo', + 'var:foo', + ].join(' '), { preflights: false }) + + expect(css).toMatchSnapshot() + }) + + test('variant can stack', async () => { + const uno = createGenerator({ + rules: [ + ['foo', { name: 'bar' }], + ], + variants: [ + { + multiPass: true, + match(input) { + const match = input.match(/^(first|second|third):/) + if (match) { + return { + matcher: input.slice(match[0].length), + handle: (input, next) => next({ + ...input, + selector: `${input.selector} > :${match[1]}`, + }), + } + } + }, + }, + { + multiPass: true, + match(input) { + const match = input.match(/^(one|two|three):/) + if (match) { + return { + matcher: input.slice(match[0].length), + handle: (input, next) => { + const result = next(input) + return { + ...result, + selector: `${result.selector} + :${match[1]}`, + } + }, + } + } + }, + }, + ], + }) + + const { css } = await uno.generate([ + 'first:second:third:foo', + 'one:two:three:foo', + 'first:three:two:foo', + ].join(' '), { preflights: false }) + + expect(css).toMatchSnapshot() + }) +})