Skip to content

Commit

Permalink
feat(transformer-compile-class): support for custom class names (#2577)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
  • Loading branch information
Techassi and antfu committed May 23, 2023
1 parent e9b15cc commit ef5db97
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 16 deletions.
65 changes: 55 additions & 10 deletions packages/transformer-compile-class/src/index.ts
Expand Up @@ -3,10 +3,35 @@ import { escapeRegExp, expandVariantGroup } from '@unocss/core'

export interface CompileClassOptions {
/**
* Trigger string
* @default ':uno:'
* Trigger regex literal. The default trigger regex literal matches `:uno:`,
* for example: `<div class=":uno: font-bold text-white">`.
*
* @example
* The trigger additionally allows defining a capture group named `name`, which
* allows custom class names. One possible regex would be:
*
* ```
* export default defineConfig({
* transformers: [
* transformerCompileClass({
* trigger: /(["'`]):uno(?:-)?(?<name>[^\s\1]+)?:\s([^\1]*?)\1/g
* }),
* ],
* })
* ```
*
* This regular expression matches `:uno-MYNAME:` and uses `MYNAME` in
* combination with the class prefix as the final class name, for example:
* `.uno-MYNAME`. It should be noted that the regex literal needs to include
* the global flag `/g`.
*
* @note
* This parameter is backwards compatible. It accepts string only trigger
* words, like `:uno:` or a regex literal.
*
* @default `/(["'`]):uno:\s([^\1]*?)\1/g`
*/
trigger?: string
trigger?: string | RegExp

/**
* Prefix for compile class name
Expand Down Expand Up @@ -34,43 +59,63 @@ export interface CompileClassOptions {

export default function transformerCompileClass(options: CompileClassOptions = {}): SourceCodeTransformer {
const {
trigger = ':uno:',
trigger = /(["'`]):uno:\s([^\1]*?)\1/g,
classPrefix = 'uno-',
hashFn = hash,
keepUnknown = true,
} = options
const regex = new RegExp(`(["'\`])${escapeRegExp(trigger)}\\s([^\\1]*?)\\1`, 'g')

// Provides backwards compatibility. We either accept a trigger string which
// gets turned into a regexp (like previously) or a regex literal directly.
const regexp = typeof trigger === 'string'
? RegExp(`(["'\`])${escapeRegExp(trigger)}\\s([^\\1]*?)\\1`, 'g')
: trigger

return {
name: '@unocss/transformer-compile-class',
enforce: 'pre',
async transform(s, _, { uno, tokens }) {
const matches = [...s.original.matchAll(regex)]
const matches = [...s.original.matchAll(regexp)]
if (!matches.length)
return

for (const match of matches) {
let body = expandVariantGroup(match[2].trim())
let body = (match.length === 4 && match.groups)
? expandVariantGroup(match[3].trim())
: expandVariantGroup(match[2].trim())

const start = match.index!
const replacements = []

if (keepUnknown) {
const result = await Promise.all(body.split(/\s+/).filter(Boolean).map(async i => [i, !!await uno.parseToken(i)] as const))
const known = result.filter(([, matched]) => matched).map(([i]) => i)
const unknown = result.filter(([, matched]) => !matched).map(([i]) => i)
replacements.push(...unknown)
body = known.join(' ')
}

if (body) {
body = body.split(/\s+/).sort().join(' ')
const hash = hashFn(body)
const className = `${classPrefix}${hash}`
const className = (match.groups && match.groups.name)
? `${classPrefix}${match.groups.name}`
: `${classPrefix}${hashFn(body)}`

// FIXME: Ideally we should also check that the hash doesn't match. If the hash is the same, the same class
// name is allowed, as the applied styles are the same.
if (tokens && tokens.has(className))
throw new Error(`duplicate compile class name '${className}', please choose different class name`)

replacements.unshift(className)
if (options.layer)
uno.config.shortcuts.push([className, body, { layer: options.layer }])
else
uno.config.shortcuts.push([className, body])
tokens.add(className)

if (tokens)
tokens.add(className)
}

s.overwrite(start + 1, start + match[0].length - 1, replacements.join(' '))
}
},
Expand Down
20 changes: 18 additions & 2 deletions playground/src/auto-imports.d.ts
Expand Up @@ -22,7 +22,9 @@ declare global {
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createProjection: typeof import('@vueuse/math')['createProjection']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const cssFormatted: typeof import('./composables/prettier')['cssFormatted']
const customCSS: typeof import('./composables/url')['customCSS']
Expand Down Expand Up @@ -139,11 +141,14 @@ declare global {
const until: typeof import('@vueuse/core')['until']
const useAbs: typeof import('@vueuse/math')['useAbs']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
Expand Down Expand Up @@ -236,6 +241,8 @@ declare global {
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
Expand Down Expand Up @@ -284,7 +291,6 @@ declare global {
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToFixed: typeof import('@vueuse/math')['useToFixed']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
Expand All @@ -309,8 +315,10 @@ declare global {
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
Expand Down Expand Up @@ -347,7 +355,9 @@ declare module 'vue' {
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly cssFormatted: UnwrapRef<typeof import('./composables/prettier')['cssFormatted']>
readonly customCSS: UnwrapRef<typeof import('./composables/url')['customCSS']>
Expand Down Expand Up @@ -464,11 +474,14 @@ declare module 'vue' {
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
Expand Down Expand Up @@ -561,6 +574,8 @@ declare module 'vue' {
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
Expand Down Expand Up @@ -609,7 +624,6 @@ declare module 'vue' {
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToFixed: UnwrapRef<typeof import('@vueuse/math')['useToFixed']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
Expand All @@ -634,8 +648,10 @@ declare module 'vue' {
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
Expand Down
54 changes: 50 additions & 4 deletions test/transformer-compile-class.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest'
import type { UnoGenerator } from '@unocss/core'
import type { SourceCodeTransformer, UnoGenerator } from '@unocss/core'
import { createGenerator } from '@unocss/core'
import MagicString from 'magic-string'
import transformerCompileClass from '@unocss/transformer-compile-class'
Expand All @@ -11,11 +11,15 @@ describe('transformer-compile-class', () => {
presetUno(),
],
})
const transformer = transformerCompileClass()

async function transform(code: string, _uno: UnoGenerator = uno) {
const defaultTransformer = transformerCompileClass()
const customClassNameTransformer = transformerCompileClass({
trigger: /(["'`]):uno(?:-)?(?<name>[^\s\1]+)?:\s([^\1]*?)\1/g,
})

async function transform(code: string, _uno: UnoGenerator = uno, _tranformer: SourceCodeTransformer = defaultTransformer) {
const s = new MagicString(code)
await transformer.transform(s, 'foo.js', { uno: _uno, tokens: new Set() } as any)
await _tranformer.transform(s, 'foo.js', { uno: _uno, tokens: new Set() } as any)
const result = s.toString()
const { css } = await uno.generate(result, { preflights: false })
return {
Expand Down Expand Up @@ -61,4 +65,46 @@ describe('transformer-compile-class', () => {
expect(order1.css).toBe(order2.css)
expect(order1.code).toBe(order2.code)
})

test('custom class name trigger (without class name)', async () => {
const result = await transform(`
<div class=":uno: bg-red-500 text-xl">`.trim(), uno, customClassNameTransformer)

expect(result.code.trim()).toMatchInlineSnapshot(`
"<div class=\\"uno-trmz0g\\">"
`)

expect(result.css).toMatchInlineSnapshot(`
"/* layer: shortcuts */
.uno-trmz0g{--un-bg-opacity:1;background-color:rgba(239,68,68,var(--un-bg-opacity));font-size:1.25rem;line-height:1.75rem;}"
`)
})

test('custom class name trigger (with basic class name)', async () => {
const result = await transform(`
<div class=":uno-foo: bg-red-500 text-xl">`.trim(), uno, customClassNameTransformer)

expect(result.code.trim()).toMatchInlineSnapshot(`
"<div class=\\"uno-foo\\">"
`)

expect(result.css).toMatchInlineSnapshot(`
"/* layer: shortcuts */
.uno-foo{--un-bg-opacity:1;background-color:rgba(239,68,68,var(--un-bg-opacity));font-size:1.25rem;line-height:1.75rem;}"
`)
})

test('custom class name trigger (with complex class name)', async () => {
const result = await transform(`
<div class=":uno-foo_bar-baz: bg-red-500 text-xl">`.trim(), uno, customClassNameTransformer)

expect(result.code.trim()).toMatchInlineSnapshot(`
"<div class=\\"uno-foo_bar-baz\\">"
`)

expect(result.css).toMatchInlineSnapshot(`
"/* layer: shortcuts */
.uno-foo_bar-baz{--un-bg-opacity:1;background-color:rgba(239,68,68,var(--un-bg-opacity));font-size:1.25rem;line-height:1.75rem;}"
`)
})
})

0 comments on commit ef5db97

Please sign in to comment.