Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(transformer-compile-class): support for custom class names #2577

Merged
merged 9 commits into from May 23, 2023
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;}"
`)
})
})