How UnoCSS works internally with Vite?

As I’m building my own CSS-in-JS solution MigaCSS, I became interested in how other solutions work internally. One of them is UnoCSS, and in this post I’m going to figure out how it works under the hood.

1. Rough intro to UnoCSS

UnoCSS claims to be Instant On-demand Atomic CSS Engine. Let’s briefly talk about its position.

Below is some code we would write usually, in which style is attached to DOM by semantic class names.

<style>
.title {
font-weight: bold;
font-size: 16px;
}
</style>
<p class="title">Hello JSer</p>
-----

It is a huge pain to choose a good class name!

<style>
.title {
font-weight: bold;
font-size: 16px;
}
</style>
<p class="title">Hello JSer</p>
-----

It is a huge pain to choose a good class name!

This is not very scalable and Atomic CSS tries to improve by creating utility class which is separated from the semantics of HTML, like blow.

<style>
.font-weight-bold {
font-weight: bold;
}
.font-size-16px {
font-size: 16px;
}
</style>
<p class="font-weight-bold font-size-16px">Hello JSer</p>
--------------------------------

Pain of naming is gone!

<style>
.font-weight-bold {
font-weight: bold;
}
.font-size-16px {
font-size: 16px;
}
</style>
<p class="font-weight-bold font-size-16px">Hello JSer</p>
--------------------------------

Pain of naming is gone!

Well it looks pretty verbose, and Tailwind has well-defined classes to make it concise.

<script src="https://cdn.tailwindcss.com"></script>
<p class="font-bold text-base">Hello JSer</p>
--------------------
<script src="https://cdn.tailwindcss.com"></script>
<p class="font-bold text-base">Hello JSer</p>
--------------------

Tailwind is a dialect with lots of built-in CSS primitives and so it is opinionated. If we want to do Atomic CSS then probably we have to create the same set of rules, so don’t bother spending the unnecessary time to create your own, just follow the community’s de-facto standard - Tailwind.

Atomic CSS has the core idea of defining utility CSS, but it doesn’t necessary means we have to enable it through class names. Say we want to do Style Props, it is also sort of Atomic CSS if we have utility style under the hood.

That’s what UnoCSS is for, it enables us to do Atomic CSS in any fashion we want, the Tailwind way could be just one of them.

ts
// uno.config.ts
import { defineConfig } from 'unocss'
export default defineConfig({
rules: [
['font-bold', { fontWeight: 'bold' }],
['text-base', { fontSize: '16px'}]
],
})
ts
// uno.config.ts
import { defineConfig } from 'unocss'
export default defineConfig({
rules: [
['font-bold', { fontWeight: 'bold' }],
['text-base', { fontSize: '16px'}]
],
})

With above configuration, with appropriate plugins, we are able to do styling in multiple way.

jsx
<p className="font-bold text-base">Hello JSer</p>
// or
<p font="bold" text="base">Hello JSer</p>
// or some other fashions.
jsx
<p className="font-bold text-base">Hello JSer</p>
// or
<p font="bold" text="base">Hello JSer</p>
// or some other fashions.

See the difference? Tailwind is more about defining the proper Atomic CSS rules, while UnoCSS is more about how to get Atomic CSS easily adopted.

2. How does UnoCSS work under the hood (with Vite)?

The setup for above UnoCSS code example is quite simple, you can find it on the homepage.

  1. add plugin UnoCSS() in Vite.
ts
// vite.config.ts
import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
UnoCSS(),
],
})
ts
// vite.config.ts
import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
UnoCSS(),
],
})
  1. configure the UnoCSS
ts
// uno.config.ts
import { defineConfig } from "unocss";
import { presetAttributify } from "unocss";
export default defineConfig({
rules: [
["font-bold", { "font-weight": "bold" }],
["text-base", { "font-size": "16px" }],
],
presets: [presetAttributify()],
});
ts
// uno.config.ts
import { defineConfig } from "unocss";
import { presetAttributify } from "unocss";
export default defineConfig({
rules: [
["font-bold", { "font-weight": "bold" }],
["text-base", { "font-size": "16px" }],
],
presets: [presetAttributify()],
});
  1. import virtual:uno.css
ts
// main.ts
import 'virtual:uno.css'
ts
// main.ts
import 'virtual:uno.css'

Let’s dive into the implementation details to understand each step.

2.1 Vite plugin to extract the tokens from user code

Looking again at the usage, the task is actually pretty straightforward.

We have a bunch of CSS rules, we want to see which of them are used in our code base and then output used ones into a standalone CSS.

Vite plugins (based on Rollup) allow us to hook into the internals, touch the source code and also the module graph it holds.

The entry plugin UnoCSS is actually a bunch of plugins for different tasks.

export default function UnocssPlugin<Theme extends object>(
configOrPath?: VitePluginConfig<Theme> | string,
defaults: UserConfigDefaults = {},
): Plugin[] {
const ctx = createContext<VitePluginConfig>(configOrPath as any, {
envMode: process.env.NODE_ENV === 'development' ? 'dev' : 'build',
...defaults,
})

Context is global across different plugins inside, so that they could share data

Think about a plugin to collect usage of rules, and another plugin to generate CSS code,

there must be a place to hold the usage data - and this is what context is for,

const mode = inlineConfig.mode ?? 'global'
const plugins = [
ConfigHMRPlugin(ctx),
...createTransformerPlugins(ctx),
...createDevtoolsPlugin(ctx),
]
if (inlineConfig.inspector !== false)
plugins.push(UnocssInspector(ctx))
if (mode === 'per-module') {
plugins.push(...PerModuleModePlugin(ctx))
}
else if (mode === 'vue-scoped') {
plugins.push(VueScopedPlugin(ctx))
}
...
else if (mode === 'global') {
plugins.push(...GlobalModePlugin(ctx))
}

This plugin is where we'll look more into

return plugins.filter(Boolean) as Plugin[]
}
export default function UnocssPlugin<Theme extends object>(
configOrPath?: VitePluginConfig<Theme> | string,
defaults: UserConfigDefaults = {},
): Plugin[] {
const ctx = createContext<VitePluginConfig>(configOrPath as any, {
envMode: process.env.NODE_ENV === 'development' ? 'dev' : 'build',
...defaults,
})

Context is global across different plugins inside, so that they could share data

Think about a plugin to collect usage of rules, and another plugin to generate CSS code,

there must be a place to hold the usage data - and this is what context is for,

const mode = inlineConfig.mode ?? 'global'
const plugins = [
ConfigHMRPlugin(ctx),
...createTransformerPlugins(ctx),
...createDevtoolsPlugin(ctx),
]
if (inlineConfig.inspector !== false)
plugins.push(UnocssInspector(ctx))
if (mode === 'per-module') {
plugins.push(...PerModuleModePlugin(ctx))
}
else if (mode === 'vue-scoped') {
plugins.push(VueScopedPlugin(ctx))
}
...
else if (mode === 'global') {
plugins.push(...GlobalModePlugin(ctx))
}

This plugin is where we'll look more into

return plugins.filter(Boolean) as Plugin[]
}

Inside GlobalModePlugin is where magic happens.

{
name: 'unocss:global',
apply: 'serve',
enforce: 'pre',
--------------

This makes sure this plugin is run first, before the built-in plugin kicks in

...
transform(code, id) {
---------

transform() is a hook for a plugin to transform the code and pass it to other plugins

notice that it returns null, so here it only uses the timing to see usage of rules

We can roughly think the id as file path

if (filter(code, id))
tasks.push(extract(code, id))

The code says itself.

It checks matched files, and spawn tasks to tokenize the code

return null
},
async load(id) {
----

load() hook allows a plugin to override the reading of source code

The import of virtual:uno.css is handled here

const layer = resolveLayer(getPath(id))
if (!layer)
return null
------------

This makes sure only the virtual:uno.css is handled

const { hash, css } = await generateCSS(layer)
---------------------------------------------

So the file contents of virtual:uno.css is dynamically generated here!

return {
// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generated
code: `__uno_hash_${hash}{--:'';}${css}`,
map: { mappings: '' },
}
},
closeBundle() {
clearWarnTimer()
},
}
{
name: 'unocss:global',
apply: 'serve',
enforce: 'pre',
--------------

This makes sure this plugin is run first, before the built-in plugin kicks in

...
transform(code, id) {
---------

transform() is a hook for a plugin to transform the code and pass it to other plugins

notice that it returns null, so here it only uses the timing to see usage of rules

We can roughly think the id as file path

if (filter(code, id))
tasks.push(extract(code, id))

The code says itself.

It checks matched files, and spawn tasks to tokenize the code

return null
},
async load(id) {
----

load() hook allows a plugin to override the reading of source code

The import of virtual:uno.css is handled here

const layer = resolveLayer(getPath(id))
if (!layer)
return null
------------

This makes sure only the virtual:uno.css is handled

const { hash, css } = await generateCSS(layer)
---------------------------------------------

So the file contents of virtual:uno.css is dynamically generated here!

return {
// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generated
code: `__uno_hash_${hash}{--:'';}${css}`,
map: { mappings: '' },
}
},
closeBundle() {
clearWarnTimer()
},
}

Actually the logic is pretty simple right? It requires some knowledge of Vite plugins but not that hard.

Let’s summarize a bit. The plugin unocss:global

  1. checks the source code and extract the tokens in transform() hook
  2. dynamically generate the CSS code for virtual:uno.css in load() hook.

2.2 Token extracting is done by splitting the code with RegExp.

What would you do if asked to extract the tokens of some code?

I’d parse the code and traverse the AST. But this is heavy, UnoCSS claims to be fast because it doesn’t parse AST rather it split the code into tokens by RegExp.

async function extract(code: string, id?: string) {
if (id)
modules.set(id, code)
const len = tokens.size
await uno.applyExtractors(code.replace(SKIP_COMMENT_RE, ''), id, tokens)
---------------

Configured extractors are run here

if (tokens.size > len)
invalidate()
----------

invalidate() is update the contents of generate CSS

}
async function extract(code: string, id?: string) {
if (id)
modules.set(id, code)
const len = tokens.size
await uno.applyExtractors(code.replace(SKIP_COMMENT_RE, ''), id, tokens)
---------------

Configured extractors are run here

if (tokens.size > len)
invalidate()
----------

invalidate() is update the contents of generate CSS

}
async applyExtractors(
code: string,
id?: string,
extracted: Set<string> | CountableSet<string> = new Set<string>(),
): Promise<Set<string> | CountableSet<string>> {
const context: ExtractorContext = {
original: code,
code,
id,
extracted,
envMode: this.config.envMode,
}
for (const extractor of this.config.extractors) {
----------------------

Extractors are from config, the presets.

const result = await extractor.extract?.(context)
if (!result)
continue
if (isCountableSet(result) && isCountableSet(extracted)) {
for (const token of result)
extracted.setCount(token, extracted.getCount(token) + result.getCount(token))
}
else {
for (const token of result)
extracted.add(token)

The code is turned into a set of tokens

}
}
return extracted
}
async applyExtractors(
code: string,
id?: string,
extracted: Set<string> | CountableSet<string> = new Set<string>(),
): Promise<Set<string> | CountableSet<string>> {
const context: ExtractorContext = {
original: code,
code,
id,
extracted,
envMode: this.config.envMode,
}
for (const extractor of this.config.extractors) {
----------------------

Extractors are from config, the presets.

const result = await extractor.extract?.(context)
if (!result)
continue
if (isCountableSet(result) && isCountableSet(extracted)) {
for (const token of result)
extracted.setCount(token, extracted.getCount(token) + result.getCount(token))
}
else {
for (const token of result)
extracted.add(token)

The code is turned into a set of tokens

}
}
return extracted
}

And extractorArbitraryVariants() is the default extractor which, by its name, tries to extract from arbitrary files.

export const extractorArbitraryVariants: Extractor = {
name: '@unocss/extractor-arbitrary-variants',
order: 0,
extract({ code }) {
return splitCodeWithArbitraryVariants(code)
},
}
export const extractorArbitraryVariants: Extractor = {
name: '@unocss/extractor-arbitrary-variants',
order: 0,
extract({ code }) {
return splitCodeWithArbitraryVariants(code)
},
}
import type { Extractor } from '@unocss/core'
import { defaultSplitRE, isValidSelector } from '@unocss/core'
export const quotedArbitraryValuesRE = /(?:[\w&:[\]-]|\[\S+=\S+\])+\[\\?['"]?\S+?['"]\]\]?[\w:-]*/g
export const arbitraryPropertyRE = /\[(\\\W|[\w-])+:[^\s:]*?("\S+?"|'\S+?'|`\S+?`|[^\s:]+?)[^\s:]*?\)?\]/g
const arbitraryPropertyCandidateRE = /^\[(\\\W|[\w-])+:['"]?\S+?['"]?\]$/
export function splitCodeWithArbitraryVariants(code: string): string[] {
const result: string[] = []
for (const match of code.matchAll(arbitraryPropertyRE)) {
if (!code[match.index! - 1]?.match(/^[\s'"`]/))
continue
result.push(match[0])
}
for (const match of code.matchAll(quotedArbitraryValuesRE))
result.push(match[0])
code
.split(defaultSplitRE)
.forEach((match) => {
if (isValidSelector(match) && !arbitraryPropertyCandidateRE.test(match))
result.push(match)
})

Yep, it simply split the code into chunks.

return result
}
import type { Extractor } from '@unocss/core'
import { defaultSplitRE, isValidSelector } from '@unocss/core'
export const quotedArbitraryValuesRE = /(?:[\w&:[\]-]|\[\S+=\S+\])+\[\\?['"]?\S+?['"]\]\]?[\w:-]*/g
export const arbitraryPropertyRE = /\[(\\\W|[\w-])+:[^\s:]*?("\S+?"|'\S+?'|`\S+?`|[^\s:]+?)[^\s:]*?\)?\]/g
const arbitraryPropertyCandidateRE = /^\[(\\\W|[\w-])+:['"]?\S+?['"]?\]$/
export function splitCodeWithArbitraryVariants(code: string): string[] {
const result: string[] = []
for (const match of code.matchAll(arbitraryPropertyRE)) {
if (!code[match.index! - 1]?.match(/^[\s'"`]/))
continue
result.push(match[0])
}
for (const match of code.matchAll(quotedArbitraryValuesRE))
result.push(match[0])
code
.split(defaultSplitRE)
.forEach((match) => {
if (isValidSelector(match) && !arbitraryPropertyCandidateRE.test(match))
result.push(match)
})

Yep, it simply split the code into chunks.

return result
}
export const defaultSplitRE = /[\\:]?[\s'"`;{}]+/g
------------------
export const defaultSplitRE = /[\\:]?[\s'"`;{}]+/g
------------------

Above RegExp helps splits the code nicely no matter the CSS rule usage is in HTML attribute, JSX props literal or expression .etc.

For example. <p className="font-bold text-base"/> is turned into ['<p', 'className=','font-bold', 'text-base', "/>"] and we can easily see that bold CSS rules are used.

But this approach could cause false alert, for example <p> font-bold </p> would be treated as using font-bold which leads unnecessary CSS code being generated, illustrated as screenshot below.

This must be some trade-off that UnoCSS is already aware of.

2.3 Rule usage is derived from the extracted tokens then CSS code is generated.

async function generateCSS(layer: string) {
await flushTasks()
----------

Notice that extract() is async

let result: GenerateResult
let tokensSize = tokens.size
do {
result = await uno.generate(tokens)
-----------------------------------
// to capture new tokens created during generation
if (tokensSize === tokens.size)
break
tokensSize = tokens.size
} while (true)
const css = layer === LAYER_MARK_ALL
? result.getLayers(undefined, Array.from(entries)
.map(i => resolveLayer(i)).filter((i): i is string => !!i))
: result.getLayer(layer)
const hash = getHash(css || '', HASH_LENGTH)
lastServedHash.set(layer, hash)
lastServedTime = Date.now()
return { hash, css }
}
async function generateCSS(layer: string) {
await flushTasks()
----------

Notice that extract() is async

let result: GenerateResult
let tokensSize = tokens.size
do {
result = await uno.generate(tokens)
-----------------------------------
// to capture new tokens created during generation
if (tokensSize === tokens.size)
break
tokensSize = tokens.size
} while (true)
const css = layer === LAYER_MARK_ALL
? result.getLayers(undefined, Array.from(entries)
.map(i => resolveLayer(i)).filter((i): i is string => !!i))
: result.getLayer(layer)
const hash = getHash(css || '', HASH_LENGTH)
lastServedHash.set(layer, hash)
lastServedTime = Date.now()
return { hash, css }
}

In uno.generate() each token is matched against configured rule set. The matched rules are grouped by CSS layers.

async generate(
input: string | Set<string> | CountableSet<string> | string[],
options: GenerateOptions<boolean> = {},
): Promise<GenerateResult<unknown>> {
const {
id,
scope,
preflights = true,
safelist = true,
minify = false,
extendedInfo = false,
} = options
...
const tokenPromises = Array.from(tokens).map(async (raw) => {
if (matched.has(raw))
return
const payload = await this.parseToken(raw)
--------------------------

Each token is processed against the rule set

if (payload == null)
return
if (matched instanceof Map) {
matched.set(raw, {
data: payload,
count: isCountableSet(tokens) ? tokens.getCount(raw) : -1,
})
}
else {
matched.add(raw)
}
for (const item of payload) {
const parent = item[3] || ''
const layer = item[4]?.layer
if (!sheet.has(parent))
sheet.set(parent, [])
sheet.get(parent)!.push(item)
if (layer)
layerSet.add(layer)
}

Matched rules are put in target layers

})
...
const getLayers = (includes = layers, excludes?: string[]) => {
return includes
.filter(i => !excludes?.includes(i))
.map(i => getLayer(i) || '')
.filter(Boolean)
.join(nl)
}

This function here gets rules from target layer and put them together into CSS code!

return {
get css() { return getLayers() },
layers,
matched,
getLayers,
getLayer,
}
}
async generate(
input: string | Set<string> | CountableSet<string> | string[],
options: GenerateOptions<boolean> = {},
): Promise<GenerateResult<unknown>> {
const {
id,
scope,
preflights = true,
safelist = true,
minify = false,
extendedInfo = false,
} = options
...
const tokenPromises = Array.from(tokens).map(async (raw) => {
if (matched.has(raw))
return
const payload = await this.parseToken(raw)
--------------------------

Each token is processed against the rule set

if (payload == null)
return
if (matched instanceof Map) {
matched.set(raw, {
data: payload,
count: isCountableSet(tokens) ? tokens.getCount(raw) : -1,
})
}
else {
matched.add(raw)
}
for (const item of payload) {
const parent = item[3] || ''
const layer = item[4]?.layer
if (!sheet.has(parent))
sheet.set(parent, [])
sheet.get(parent)!.push(item)
if (layer)
layerSet.add(layer)
}

Matched rules are put in target layers

})
...
const getLayers = (includes = layers, excludes?: string[]) => {
return includes
.filter(i => !excludes?.includes(i))
.map(i => getLayer(i) || '')
.filter(Boolean)
.join(nl)
}

This function here gets rules from target layer and put them together into CSS code!

return {
get css() { return getLayers() },
layers,
matched,
getLayers,
getLayer,
}
}

In parseToken() there is call of parseUtil() where the actual matching happens.

async parseUtil(
input: string | VariantMatchedResult<Theme>,
context: RuleContext<Theme>,
internal = false,
shortcutPrefix?: string | string[] | undefined,
): Promise<(ParsedUtil | RawUtil)[] | undefined> {
const [raw, processed, variantHandlers] = isString(input)
? await this.matchVariants(input)
: input
if (this.config.details)
context.rules = context.rules ?? []
// use map to for static rules
const staticMatch = this.config.rulesStaticMap[processed]
--------------------------

This is from the rule set in uno config

if (staticMatch) {
----------------

Matching happens here!

if (staticMatch[1] && (internal || !staticMatch[2]?.internal)) {
if (this.config.details)
context.rules!.push(staticMatch[3])
const index = staticMatch[0]
const entry = normalizeCSSEntries(staticMatch[1])
const meta = staticMatch[2]
if (isString(entry))
return [[index, entry, meta]]
else
return [[index, raw, entry, meta, variantHandlers]]
}
}
context.variantHandlers = variantHandlers
....

There are logic handling dynamic rule set

}
async parseUtil(
input: string | VariantMatchedResult<Theme>,
context: RuleContext<Theme>,
internal = false,
shortcutPrefix?: string | string[] | undefined,
): Promise<(ParsedUtil | RawUtil)[] | undefined> {
const [raw, processed, variantHandlers] = isString(input)
? await this.matchVariants(input)
: input
if (this.config.details)
context.rules = context.rules ?? []
// use map to for static rules
const staticMatch = this.config.rulesStaticMap[processed]
--------------------------

This is from the rule set in uno config

if (staticMatch) {
----------------

Matching happens here!

if (staticMatch[1] && (internal || !staticMatch[2]?.internal)) {
if (this.config.details)
context.rules!.push(staticMatch[3])
const index = staticMatch[0]
const entry = normalizeCSSEntries(staticMatch[1])
const meta = staticMatch[2]
if (isString(entry))
return [[index, entry, meta]]
else
return [[index, raw, entry, meta, variantHandlers]]
}
}
context.variantHandlers = variantHandlers
....

There are logic handling dynamic rule set

}

2.4 CSS is generated when virtual:uno.css is requested.

export const VIRTUAL_ENTRY_ALIAS = [
/^(?:virtual:)?uno(?::(.+))?\.css(\?.*)?$/,
]
export const LAYER_MARK_ALL = '__ALL__'
export const RESOLVED_ID_WITH_QUERY_RE = /[\/\\]__uno(?:(_.*?))?\.css(\?.*)?$/
export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/
export function resolveId(id: string) {
if (id.match(RESOLVED_ID_WITH_QUERY_RE))
return id
for (const alias of VIRTUAL_ENTRY_ALIAS) {
const match = id.match(alias)
if (match) {
return match[1]
? `/__uno_${match[1]}.css`
: '/__uno.css'
}
}

so virtual:uno.css is resolved to /__uno.css

}
{
name: 'unocss:global',
resolveId(id) {
const entry = resolveId(id)
if (entry) {
resolved = true
clearWarnTimer()
entries.add(entry)
return entry
}
},
async load(id) {
const layer = resolveLayer(getPath(id))
if (!layer)
return null

This makes sure that CSS is generated for target paths like /__uno.css

const { hash, css } = await generateCSS(layer)
return {
// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generated
code: `__uno_hash_${hash}{--:'';}${css}`,
map: { mappings: '' },
}
},
}
export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/
export function resolveLayer(id: string) {
const match = id.match(RESOLVED_ID_RE)
if (match)
return match[1] || LAYER_MARK_ALL
}
export const VIRTUAL_ENTRY_ALIAS = [
/^(?:virtual:)?uno(?::(.+))?\.css(\?.*)?$/,
]
export const LAYER_MARK_ALL = '__ALL__'
export const RESOLVED_ID_WITH_QUERY_RE = /[\/\\]__uno(?:(_.*?))?\.css(\?.*)?$/
export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/
export function resolveId(id: string) {
if (id.match(RESOLVED_ID_WITH_QUERY_RE))
return id
for (const alias of VIRTUAL_ENTRY_ALIAS) {
const match = id.match(alias)
if (match) {
return match[1]
? `/__uno_${match[1]}.css`
: '/__uno.css'
}
}

so virtual:uno.css is resolved to /__uno.css

}
{
name: 'unocss:global',
resolveId(id) {
const entry = resolveId(id)
if (entry) {
resolved = true
clearWarnTimer()
entries.add(entry)
return entry
}
},
async load(id) {
const layer = resolveLayer(getPath(id))
if (!layer)
return null

This makes sure that CSS is generated for target paths like /__uno.css

const { hash, css } = await generateCSS(layer)
return {
// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generated
code: `__uno_hash_${hash}{--:'';}${css}`,
map: { mappings: '' },
}
},
}
export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/
export function resolveLayer(id: string) {
const match = id.match(RESOLVED_ID_RE)
if (match)
return match[1] || LAYER_MARK_ALL
}

So above code explains whyimport 'virtual:uno.css' is required in the setup.

2.5 Attributify mode has different extractor and CSS output format.

Attributify Mode is the reason why I wanted to dive into UnoCSS. It is similar to what I want to achieve in MigaCSS.

A few things to notice from above screenshot.

  1. generated CSS code has attribute selector, rather than .font-bold{font-weight:bold;}, it is [font~="bold"]{font-weight:bold}.
  2. in HTMl, the attributes are not stripped, such as font="bold" and text="base".

This doesn’t look very elegant comparing to the clean class names, and attribute selector of course is not the best at performance. I guess UnoCSS choose this is to avoid modifying the HTMl structure because it requires AST parsing to do it right which compromise the performance in all.

To understand how it works under the hood, following what we’ve learned so far, we need to treat font="bold" as a usage of font-bold. Obviously this needs some special extractor, which is @unocss/preset-attributify/extractor in preset-attributify.

const splitterRE = /[\s'"`;]+/g
const elementRE = /<[^>\s]*\s((?:'.*?'|".*?"|`.*?`|\{.*?\}|[^>]*?)*)/g
const valuedAttributeRE = /([?]|(?!\d|-{2}|-\d)[a-zA-Z0-9\u00A0-\uFFFF-_:!%-.~<]+)=?(?:["]([^"]*)["]|[']([^']*)[']|[{]([^}]*)[}])?/gms
------------------------------------------------------------------------------------------------------------

This matches the key-value pair like font="bold"

export const defaultIgnoreAttributes = ['placeholder', 'fill', 'opacity']
export function extractorAttributify(options?: AttributifyOptions): Extractor {
const ignoreAttributes = options?.ignoreAttributes ?? defaultIgnoreAttributes
const nonValuedAttribute = options?.nonValuedAttribute ?? true
const trueToNonValued = options?.trueToNonValued ?? false
return {
name: '@unocss/preset-attributify/extractor',
extract({ code }) {
const result = Array.from(code.matchAll(elementRE))
.flatMap(match => Array.from((match[1] || '').matchAll(valuedAttributeRE)))
.flatMap(([, name, ...contents]) => {
const content = contents.filter(Boolean).join('')

For font="bold", content here is "bold" and name is "font"

...
if (['class', 'className'].includes(name)) {
return content
.split(splitterRE)
.filter(isValidSelector)
}
else {
if (options?.prefixedOnly && options.prefix && !name.startsWith(options.prefix))
return []
return content.split(splitterRE)
.filter(v => Boolean(v) && v !== ':')
.map(v => `[${name}~="${v}"]`)
------------------

This generates [font~="bold"]

}
})
return new Set(result)
},
}
}
const splitterRE = /[\s'"`;]+/g
const elementRE = /<[^>\s]*\s((?:'.*?'|".*?"|`.*?`|\{.*?\}|[^>]*?)*)/g
const valuedAttributeRE = /([?]|(?!\d|-{2}|-\d)[a-zA-Z0-9\u00A0-\uFFFF-_:!%-.~<]+)=?(?:["]([^"]*)["]|[']([^']*)[']|[{]([^}]*)[}])?/gms
------------------------------------------------------------------------------------------------------------

This matches the key-value pair like font="bold"

export const defaultIgnoreAttributes = ['placeholder', 'fill', 'opacity']
export function extractorAttributify(options?: AttributifyOptions): Extractor {
const ignoreAttributes = options?.ignoreAttributes ?? defaultIgnoreAttributes
const nonValuedAttribute = options?.nonValuedAttribute ?? true
const trueToNonValued = options?.trueToNonValued ?? false
return {
name: '@unocss/preset-attributify/extractor',
extract({ code }) {
const result = Array.from(code.matchAll(elementRE))
.flatMap(match => Array.from((match[1] || '').matchAll(valuedAttributeRE)))
.flatMap(([, name, ...contents]) => {
const content = contents.filter(Boolean).join('')

For font="bold", content here is "bold" and name is "font"

...
if (['class', 'className'].includes(name)) {
return content
.split(splitterRE)
.filter(isValidSelector)
}
else {
if (options?.prefixedOnly && options.prefix && !name.startsWith(options.prefix))
return []
return content.split(splitterRE)
.filter(v => Boolean(v) && v !== ':')
.map(v => `[${name}~="${v}"]`)
------------------

This generates [font~="bold"]

}
})
return new Set(result)
},
}
}

So in this extractor, we’ll get the attribute selector [font~="bold"], then there must be some normalization in the rule matching, and it is actually inside parseToken() we mentioned before.

async parseToken(
raw: string,
alias?: string,
): Promise<StringifiedUtil<Theme>[] | undefined | null> {
const applied = await this.matchVariants(raw, current)
------------------

This transform raw '[font~="bold"]' to ['[font~="bold"]', 'font-bold'

if (!applied || this.isBlocked(applied[1])) {
this.blocked.add(raw)
this._cache.set(cacheKey, null)
return
}
}
async parseToken(
raw: string,
alias?: string,
): Promise<StringifiedUtil<Theme>[] | undefined | null> {
const applied = await this.matchVariants(raw, current)
------------------

This transform raw '[font~="bold"]' to ['[font~="bold"]', 'font-bold'

if (!applied || this.isBlocked(applied[1])) {
this.blocked.add(raw)
this._cache.set(cacheKey, null)
return
}
}

And in parseUtil(), the 2nd item in the array is actually used.

3. Summary

Now we see how UnoCSS works under the hood, here is a brief summary.

  1. UnoCSS contains a bunch of Vite(Rollup) plugins that do different jobs
  2. It reads raw source code at plugin’s transform() hook to collect tokens.
    • They are not technically tokens, but chunks of code split by RegExp
    • AST parsing is avoided for performance
    • Because of no AST, extracted tokens are not guaranteed to be used as styling, which has chances of false alert.
  3. On requesting virtual:uno.css, it generates the CSS code from the tokens
    • It picks up the rules from config that also exist in tokens
    • Then it generate CSS code by layers
  4. Different extractors allows different Atomic CSS approach to work in UnoCSS, e.g. attributify mode.

IMHO, I don’t like some of design choices from UnoCSS, I’d put performance as lower priority comparing to correctness and output format. On the other hand UnoCSS shows us perfectly that in programming trade-offs are everywhere.

😳 Share my post ?    
or sponsor me

❮ Prev: Spread operator in TypeScript is not sound!!

Next: Migrate TypeScript Code Progressively