eslint-plugin-unocss-normalize-classes
| Install | |
|---|---|
npm install eslint-plugin-unocss-normalize-classes |
|
| Latest Version: | 0.9.1 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Sep 25, 2026 |
| Links: | GitHub · npm |
eslint-plugin-unocss-normalize-classes
Rewrites UnoCSS class attributes into the shorter spellings your own config already defines - and refuses to write any rewrite it cannot prove generates identical CSS.
The problem it exists for
A UnoCSS blocklist is a list of spellings a project has decided against, each with a message telling a human what to write instead. Turning those messages into an autofix is the obvious next step, and it is where the trouble starts:
m-[4px] -> m-1 4px and 0.25rem are the same length
blur-[4px] -> blur-1 4px of blur and 1px of blur are not
Both rewrites divide by four. One is an alias, the other is a visual
regression, and nothing in the class name says which is which - m-* is on the
0.25rem spacing scale and blur-* is not. A rule that applies both looks
correct in every test that checks class names, and changes what the page
renders.
So this plugin does not trust a rewrite because it came from a config. It generates the CSS for both sides, compares what a browser would compute, and applies the rewrite only when they match. A rewrite it cannot prove is reported, never written.
Install
npm install --save-dev eslint-plugin-unocss-normalize-classes
Requires Node 24 or newer, ESLint 9 or newer, and a project with a UnoCSS
config. Vue templates need vue-eslint-parser, which you almost certainly
already have.
Setup
// eslint.config.js
import unocssNormalize from 'eslint-plugin-unocss-normalize-classes'
import vueParser from 'vue-eslint-parser'
export default [
{
files: ['**/*.vue'],
languageOptions: { parser: vueParser },
plugins: { 'unocss-normalize': unocssNormalize },
rules: { 'unocss-normalize/classes': 'error' },
},
]
The config is found the way UnoCSS finds it - a uno.config.ts at the project
root. Point somewhere else with the rule's configPath option, or with
settings: { unocss: { configPath } }, which is the same setting
@unocss/eslint-plugin reads.
The plugin has a second rule, unocss-normalize/manual-shortcuts, which stays
silent until the config marks a shortcut manual - see
Shortcuts to suggest rather than apply.
recommended turns both on.
Where the rewrites come from
Two sources, both already in your config. Neither one reads a message: prose written for a human is not a rewrite rule, and parsing it would be guessing.
Shortcuts
A shortcut is already a name for a set of utilities, so the collapse direction needs no new convention:
shortcuts: {
center: 'items-center justify-center',
}
- <div class="flex justify-center gap-2 items-center" />
+ <div class="flex center gap-2" />
Matching is order-independent, because a class attribute is a set. The shortcut's name takes the position of the earliest token it replaces, so nothing else in the attribute moves. When two shortcuts compete for the same tokens the larger one wins, and a single-token shortcut is never collapsed - that is a rename, not a simplification.
Blocklist entries that declare a fix
A blocklist says what is wrong. To also say what is right, add a fix:
blocklist: [
[/^border$/, {
message: 'use shorter "b"',
fix: () => ['b'],
}],
[/^size-(.+)$/, {
message: 'use "w-* h-*"',
fix: (v) => {
const size = /^size-(.+)$/.exec(v)?.[1]
return size ? [`w-${size}`, `h-${size}`] : [v]
},
}],
]
Wrap the list in
hideFixesif you also rununocss/blocklist.import { hideFixes } from 'eslint-plugin-unocss-normalize-classes/config' blocklist: hideFixes([ [/^border$/, { message: 'use shorter "b"', fix: () => ['b'] }], ])That rule sends a matched entry's meta to a worker thread, and it sends the whole object -
{ ...meta, message }. A function cannot cross that boundary. On a real project, linting a two-line file went from 12 seconds to 13 minutes with an ordinaryfixproperty.hideFixesdefines it non-enumerably, which takes it out of the spread and leaves property access - how this plugin reads it - untouched. The helper imports nothing, so it is safe in a config your build also loads.
fix returns the tokens that replace the blocked one - one, or several. This
is an additive convention: BlocklistMeta upstream carries only message, and
UnoCSS ignores meta keys it does not recognise, so a config can declare fix
today without waiting for anything to change.
Because the convention is additive, BlocklistRule upstream does not know
about fix - a project typing its blocklist against it gets no checking on the
one part this plugin reads. The same entry point exports the types that do:
import { hideFixes } from 'eslint-plugin-unocss-normalize-classes/config'
import type {
FixableBlocklistMeta,
FixableBlocklistRule,
} from 'eslint-plugin-unocss-normalize-classes/config'
const blocklist: FixableBlocklistRule[] = [
[/^border$/, { message: 'use shorter "b"', fix: () => ['b'] }],
]
export default defineConfig({
blocklist: hideFixes(blocklist), // BlocklistRule[] - no cast
})
FixableBlocklistMeta extends UnoCSS's BlocklistMeta, so anything upstream
adds to it arrives here too. hideFixes returns BlocklistRule[] when given
FixableBlocklistRule[], which is what defineConfig wants - the cast a
project would otherwise write at that boundary is the one place a fix typo
could hide.
Telling a colour apart from the rest of text-*
text-* is three unrelated utilities behind one prefix - a colour
(text-red-300), a size (text-sm), an alignment (text-center) - and only
the colour has a c-* spelling. There are two ways to write that entry, and
the prover changes which one you need.
Name the colours, read off your theme so the pattern cannot drift:
const wind = presetWind3()
const colours = Object.keys(wind.theme?.colors ?? {}).join('|')
blocklist: [
[new RegExp(`^text-(?:${colours})(?:-\d+)?(?:\/\d+)?$`), {
message: 'use shorter "c-*" for colours',
fix: (v) => [v.replace(/^text-/, 'c-')],
}],
]
Shade and opacity are optional there on purpose: text-red, text-red-300
and text-red-500/50 are all colours, and c-* takes all three.
Or do not tell them apart at all, and let the proof do it:
[/^text-(.+)$/, { message: 'use "c-*" for colours', fix: (v) => [v.replace(/^text-/, 'c-')] }],
This proposes c-* for every text-*. c-red-300 generates the same CSS as
text-red-300, so it is applied; c-sm and c-center generate nothing at
all, so they are reported and never written. The loose pattern is safe -
it just costs a report per non-colour token, which is why naming the colours
is still the better entry.
A fix written against the bare utility still answers for a token carrying
variants or an important marker: ^border$ fixes sm:hover:!border. Chains
are followed, so ma-auto -> m-auto -> m-a resolves in one pass. Variant
groups are expanded before the rewrite and collapsed after it, using the same
UnoCSS helpers the unocss/order rule uses, so hover:(border opacity-50)
comes back as hover:(b op-50).
Variant groups
Tokens that share a variant can be collapsed into a group:
- <div class="md:text-center md:mx-a" />
+ <div class="md:(text-center mx-a)" />
Off by default, and it needs one thing from your build:
transformerVariantGroup. The generator does not understand a group on its own -
uno.generate('md:(a b)') matches nothing - so in a project without that
transformer the grouped class names would produce no CSS at all.
'unocss-normalize/classes': ['error', { variantGroups: true }],
Two things it will not do, both so it stays out of unocss/order's way:
- It never moves a token to group it. Only tokens already next to each other are collapsed. Ordering is the sorter's job.
- It will not form a group the sorter would tear apart.
unocss/orderexpands a group, sorts the members, and re-collapses only what stayed adjacent - so a group whose members sort apart comes back as one loose token and one group of one, gets regrouped here, and never settles. The rule asks the generator where each token would sort and declines those runs.
true groups a prefix as soon as two tokens share it; { minimum: 3 } waits
for three. Only exact prefixes are grouped - md:a and md:hover:b do not
share one, and flattening them together would be wrong. A group the author
already wrote is kept whatever the minimum.
This is not detected automatically, on purpose. A Nuxt project registers its
transformers through the module options rather than uno.config.ts, so the
config this rule loads can report no transformers in a project whose build runs
them - auto-detection would refuse to group in exactly the projects that can.
How a rewrite is proved
A second generator is built from the same config with the blocklist, safelist and preflights cleared - otherwise the blocked side generates nothing and every comparison is "something versus nothing".
Both sides are generated and reduced to the declarations a browser would compute:
- class names are dropped, so a rename compares equal
- declaration order, and the order rules appear in, are ignored
--un-*custom properties are resolved the way the cascade resolves them, soc-black/50andc-black c-op-50are recognised as the same colour- the condition travels with the declaration.
padding: 1reminside@media (min-width: 640px)is not the same promise aspadding: 1remoutside one, andcolor: redon:hoveris notcolor: red. A fix that adds or drops a variant is refused, however plausible the rename looks - two empty stylesheets are never equal, so rewriting one unknown token into another is refused rather than waved through
One assumption, stated out loud: rem is compared against px at a
16-pixel root, which is what makes m-[4px] -> m-1 provable. Set
rootFontSize to your root size if it differs, or to false for a strict
comparison in which 4px and 0.25rem are simply different - the honest
reading for a project whose users change their browser font size.
Shortcuts that mean different things in different builds
A project that composes its config from layers - a brand, a theme, a white-label tenant - can define one shortcut name twice:
// brands/timberland/…/typography.ts
'title-5': 'text-xs fw-bold font-secondary'
// brands/vans/…/typography.ts
'title-5': 'text-base fw-bold lh-1 font-secondary'
Both are correct where they are. But a component shared by both brands is
linted against whichever config is on disk, so --fix under Timberland
rewrites text-xs fw-bold font-secondary to title-5, and the Vans build then
renders that element at text-base with a line height nobody asked for.
The proof cannot catch this. It builds one generator from one config, so both sides of the comparison come from the same layer and the collapse is genuinely equivalent there. What the rewrite changes is not the CSS but the meaning: a fixed set of utilities becomes a lookup whose result differs per build. Nothing inside a single config makes that visible.
So the config says it, on the shortcut itself:
import { scoped } from 'eslint-plugin-unocss-normalize-classes/config'
// brands/timberland/config/unocss/shortcuts/index.ts
export default [
...scoped({ ...button, ...typography }),
] as UserShortcuts
scoped moves a shortcut map to the tuple form - the only one with a meta slot
- and marks each entry. Same additive convention as
fixon a blocklist entry:RuleMetaupstream knows nothing aboutscoped, UnoCSS ignores meta keys it does not recognise, and the generated CSS is byte for byte what it was.
A marked shortcut is no longer a collapse source. It still generates, still works everywhere it is written by hand, and every unmarked shortcut in the config keeps collapsing as before.
Per shortcut, not per config, because a merged config cannot say which is
which. After mergeConfigs a top-level flag is one scalar with nothing
tying it to any shortcut, and while the merged shortcuts array does keep its
per-layer segments in order, it does not keep their boundaries. The marker has
to travel on the shortcut.
Turning it back on where the layer is known
Code that only ever ships with one layer - a themes/dark/** component, a
tenant-specific page - should get the collapse, because there the name means
one thing. allowScoped says so, on a block scoped to that layer's files and
pointed at that layer's config:
{
files: ['themes/dark/**/*.vue'],
rules: {
'unocss-normalize/classes': ['error', { configPath: 'dark.uno.config.ts', allowScoped: true }],
},
}
That is one block per layer, each needing a config that merges the shared layers with that layer's own. A Nuxt project gets both from this package.
In a Nuxt project
The /nuxt subpath is a Nuxt module that writes one merged config per theme,
and themedConfigs() turns the same map into the blocks above:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@unocss/nuxt', 'eslint-plugin-unocss-normalize-classes/nuxt'],
unocss: { nuxtLayers: true },
unoThemedConfigs: {
// each theme, and the directory holding its layers
themes: { dark: 'themes/dark', light: 'themes/light' },
// directories whose layers every theme merges beneath its own
shared: ['layers'],
},
})
// eslint.config.js
import unocssNormalize, { themedConfigs } from 'eslint-plugin-unocss-normalize-classes'
export default [
...unocssNormalize.configs.recommended,
// after the project-wide entry, so these win for the files they match
...themedConfigs({ themes: { dark: 'themes/dark', light: 'themes/light' } }),
]
- What gets written:
.nuxt/uno/config/<theme>.mjs, as Nuxt templates - rewritten by everyprepare,devandbuild. They are templates rather than files a script drops in, becausenuxi preparedeletes build-directory files it did not write itself. - Which layers: the shared ones are found exactly the way
@unocss/nuxtfinds layers for its ownuno.config.mjs, so the active theme's file matches that one layer for layer. Anything outsideshared- the active theme, or a layer only one command switches on - stays out, so the files never depend on which command ran last. themedConfigsoptions:rootDir(defaultprocess.cwd()),buildDir(default.nuxt),severity(default'error'), andruleOptions. The last one matters: a later flat-config block replaces a rule's options wholesale, so anything the project-wide entry sets has to be repeated there.- Before the first
prepare: a theme whose file is not on disk gets no block, so a fresh checkout lints with the project-wide entry rather than failing to load a config. - Versions: Nuxt 3.17 and newer, including 4.
@nuxt/kitand@nuxt/schemaare optional peers - the ESLint plugin on its own installs nothing from Nuxt.
A project-wide block and a theme block may name the same config: the session
cache is keyed by config alone, and allowScoped is applied per plan.
A scoped shortcut that is skipped is skipped silently - like shortcuts: false,
and unlike a refused rewrite. There is nothing to fix in the config, so there
is nothing to report.
Shortcuts to suggest rather than apply
Some shortcut names say more than their utilities do. vf-button-xs is
p-2 text-sm lh-1 today, but it also says "this is a small button". Collapse a
badge's p-2 text-sm lh-1 into it and the CSS is provably the same - and the
badge is now a button by name, and will change whenever the button does.
Mark such shortcuts manual:
export default [
...scoped(button, { manual: true }), // suggested, never written
...scoped(typography),
] as UserShortcuts
unocss-normalize/classes never collapses into a manual shortcut.
unocss-normalize/manual-shortcuts reports the collapse instead - with the
rewrite as an editor suggestion rather than a fix, so --fix leaves it alone
and a person decides:
"p-2 text-sm lh-1 c-grey-20" could be written as "vf-button-xs c-grey-20",
which generates the same CSS - but "vf-button-xs" is marked manual. Rewrite it
by hand if the name fits this element.
-
Where it asks: exactly where the collapse would otherwise have been written. A scoped manual shortcut is only suggested under
allowScoped; in shared code the collapse would be wrong, so nothing is asked. -
It holds its tokens: a manual shortcut still takes part in matching, largest first, and a match for it reserves its tokens. No smaller shortcut can take them, so
--fix- a pre-commit hook, say - cannot answer the question before anyone has seen it. -
Its own rule, its own severity: ESLint gives a rule one severity for everything it reports, and a question for a reviewer is not the same kind of finding as a proved rewrite.
recommendedandthemedConfigs()set it towarn. -
Options:
configPath,allowScopedandrootFontSize, meaning what they mean forclasses. A collapse is proved before it is suggested, the same way one is proved before it is written. -
An unscoped shortcut can be marked by hand. Restate the layer: any meta at all replaces the default that keeps a shortcut in the shortcuts layer.
['halo', 'ring-2 ring-offset-2', { layer: 'shortcuts', manual: true }]
Options
| Option | Default | What it does |
|---|---|---|
shortcuts |
true |
Collapse token sets that a shortcut already names |
allowScoped |
false |
Also collapse into shortcuts the config marked scoped |
blocklist |
true |
Apply the fix a blocklist entry declares |
variantGroups |
false |
Collapse tokens sharing a variant into a group; true, or { minimum: n } |
reportUnproven |
true |
Report a rewrite that was proposed and refused, instead of staying silent |
rootFontSize |
16 |
Root size for comparing rem against px; false compares them strictly |
configPath |
- | Path to the UnoCSS config, when it is not where UnoCSS would look |
reportUnproven is on by default because a refused rewrite is a finding about
your config, not noise: it means an entry claims a replacement that renders
differently. That is the blur-[4px] case, and you want to hear about it.
Alongside unocss/order
They compose, and they are better together than either is alone. The sorter decides the order of the tokens; this rule decides which tokens they are.
rules: {
'unocss/order': 'error',
'unocss-normalize/classes': 'error',
}
This rule unblocks the sorter. unocss/order sorts by asking the generator
to parse each token, and a token your blocklist blocks does not parse - so the
sorter cannot place it among the others, and moves it to the front with every
other token it could not parse, in the order they were written. On a file
written in the spellings your config rejects, sorting does almost nothing:
class="opacity-50 border flex"
unocss/order alone opacity-50 border flex nothing it can sort
this rule alone op-50 b flex normalised, order untouched
both, in one ESLint run flex b op-50
Configure them in the same run rather than one after the other. ESLint re-runs every rule after each fix pass, so the pair converges; a whole run of one piped into a whole run of the other does not, because the sorter has already had its turn before the tokens became sortable.
Which one goes first is up to the config. When both rules want the same
attribute in one pass, only one fix can land - they rewrite the same text. Both
replace exactly the text between the quotes, and for fixes over the same range
ESLint keeps the order the rules are configured in. List unocss/order first,
as above, to sort before normalising:
class="justify-center items-center flex gap-2"
unocss/order listed first pass 1 flex items-center justify-center gap-2 sorted
pass 2 flex center gap-2 normalised
this rule listed first pass 1 center flex gap-2 normalised
pass 2 flex center gap-2 sorted
The result is the same either way: ESLint keeps making passes until neither rule has anything left, so the sorter always has the last word.
One caveat worth knowing: unocss/order rewrites a class attribute onto a
single line, including when the tokens are already in order. With it enabled, a
multi-line class list will be flattened - by the sorter, not by this rule. The
suite pins that behaviour, so if upstream changes it this note goes.
What it does not do
- Dynamic
:classexpressions. A binding is JavaScript, and the class list is a value the rule cannot see. Guessing at string literals inside one would trade the guarantee for reach. - JSX, Svelte, or plain HTML. Vue static attributes only, for now.
- Invent conventions. Every rewrite comes from your shortcuts or your blocklist. The plugin ships no opinions about what your class names should be.
- Replace
unocss/blocklist. That rule reports everything your blocklist blocks. This one only speaks up where it can either fix something or tell you a declared fix is wrong. Run both. - Reformat your templates. A rewrite is joined with the whitespace the attribute already used, so a class list written one token per line stays that way, and an attribute nothing could be proved about is left byte for byte.
- Deduplicate.
border borderbecomesb b. Removing a repeat is a different change from normalising one, and doing it here would mean editing attributes nobody asked about.
Using the parts directly
The interesting half has no ESLint in it:
import { isEquivalent, planRewrite } from 'eslint-plugin-unocss-normalize-classes'
planRewrite takes declaredFix, prove and shortcuts as arguments and
knows nothing about UnoCSS, ESLint, or where the answers came from - useful for
a one-off codemod, and the reason it could be lifted into
@unocss/eslint-plugin whose own blocklist rule already declares
fixable: 'code' and never emits a fix.
Try it without installing it
demo/ is a minimal Nuxt 4 app wired to the working copy of this package:
pnpm demo:install
pnpm demo:lint
Two cards with the same design, one written in the spellings the demo's
blocklist rejects. pnpm --dir demo lint:fix rewrites it and the page renders
identically - except for one deliberately wrong fix, which the plugin reports
instead of applying.
The app is also built as one of two themes, picked by THEME at build time,
that define the same shortcut names with different utilities. It shows
scoped shortcuts and the /nuxt module end to end: each theme's files
collapse into that theme's shortcuts, a shared file that happens to spell one
theme's shortcut is left alone, and the lint result is the same whichever theme
was prepared. See demo/README.md.
The demo is its own workspace root, so it never enters this package's install or its CI.
Development
pnpm install
pnpm test # builds, then runs the suite
pnpm smoke # the built artifact, through a real ESLint
pnpm check:docs # every claim this README makes about the package
pnpm test:unit skips the build when dist/ is current. The rule suite needs
dist/worker.mjs, because the rule loads its worker from there in development
too.
Releasing
pnpm release bumps the version, tags it and pushes; the tag is what starts
the publish workflow. Nothing is published from a laptop - npm only attaches a
provenance attestation inside a supported CI provider, so a local publish would
ship a weaker version of the same package and nothing downstream would say so.
Licence
MIT
Related Packages
Shared ESLint plugin and flat config for DiPhyx Nuxt/Vue + Harlemify projects
Version History
| Version | Released | License |
|---|---|---|
| 0.9.1 | MIT | |
| 0.9.0 | MIT | |
| 0.8.1 | MIT | |
| 0.8.0 | MIT | |
| 0.7.0 | MIT | |
| 0.6.0 | MIT | |
| 0.5.0 | MIT | |
| 0.4.2 | MIT | |
| 0.4.1 | MIT | |
| 0.4.0 | MIT | |
| 0.3.1 | MIT | |
| 0.3.0 | MIT | |
| 0.2.0 | MIT | |
| 0.1.0 | MIT |