@tabula-css/eslint-plugin
Tabula's enforcement layer: the flat-config ESLint plugin that makes the closed vocabulary a hard gate. Reads the registry only — no Tailwind in its import graph.
Install
npm install --save-dev @tabula-css/eslint-pluginDev dependency, alongside eslint (peer, >=9.0.0).
Overview
Where tabula build derives the closed vocabulary and tabula scan sweeps every source file for it in CI, @tabula-css/eslint-plugin is the editor- and pre-commit-facing gate: a class outside the registry, a class name assembled at runtime, or a stale exception all become lint errors with TAB-E/TAB-W diagnostic codes, live as you type. It reads the generated registry.json directly — it never imports Tailwind — so linting stays fast. Fifteen tabula/* rules ship, grouped into two presets (strict, migration).
Enable
eslint.config.js (flat config):
import tabula from '@tabula-css/eslint-plugin';
export default [
{
...tabula.configs.strict,
files: ['**/*.{ts,tsx}'],
settings: {
tabula: { registry: '.tabula/registry.json' },
},
},
];strict turns every one of the 15 rules into an error. Adopting into an existing codebase? Use tabula.configs.migration instead: the four ban rules (no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important) stay hard errors, and the rest relax to warn, so you can land the switch incrementally.
settings.tabula
| Key | Default | Meaning |
|---|---|---|
registry | walks up from cwd for .tabula/registry.json | Explicit path to registry.json. |
callees | cn, cx, clsx, cva, tv, variants, twMerge, classnames | Call expressions whose arguments are treated as class sinks. |
classAttributes | className, class | JSX attribute names treated as class sinks. |
textLeafComponents | [] | Component names (<Card>) the project declares to render a text leaf, for inherited-property-boundary. |
classMapSources | /\.classmap(\.(c|m)?[jt]sx?)?$/ | Filename pattern for designated lookup-map modules (no-runtime-class-construction's A7 form). |
classNameSources | /^@tabula-css\/merge$/ | Module specifiers that may supply the branded ClassName type. |
now | real clock | ISO date override for exception-scope's expiry check, for deterministic tests. |
Rules
tabula/registry-required
Requires a readable, schema-valid registry — the precondition every other registry-backed rule shares. Exposed standalone so a project can require it even with content rules off.
// fails: no readable registry.json at the configured/discovered pathtabula/no-runtime-class-construction
Disallows constructing a class string at runtime. Tailwind's scanner only sees complete literal strings, so `p-${n}` compiles to nothing, silently. Allows conditionals (cond && "x", ternaries, arrays/objects of literals), a module-scope const lookup map, an import from a *.classmap.ts module, a cva()/tv()/variants() result, and a ClassName-typed pass-through parameter.
// ❌ violating
<div className={`p-${size}`} />
// ✅ passing
const PADDING = { sm: 'p-sm', md: 'p-md' } as const;
<div className={PADDING[size]} />tabula/no-unregistered-arbitrary-value
Disallows any [...]-containing candidate (arbitrary value, property, variant, or modifier) that isn't registered as an exception. Never autofixed.
// ❌ violating
<div className="w-[347px]" />
// ✅ passing — after `tabula except add` registers it
<div className="w-hero-legacy-width" />tabula/no-unknown-class
Disallows a class whose utility isn't registered in the vocabulary. Reports the registry-independent banlist first (so space-x-4 explains why it's banned rather than suggesting a typo fix), then offers Levenshtein-≤2 "did you mean?" suggestions for the rest. Never autofixes.
// ❌ violating
<div className="bg-surfac" />
// Unknown class `bg-surfac`. Did you mean `bg-surface`?
// ✅ passing
<div className="bg-surface" />tabula/no-theme-variant
Disallows dark:, light:, and any [data-theme…]/[prefers-color-scheme…] variant. Theme is a token axis — bg-surface already carries every theme's value — so a variant reintroduces the branch the axis model removes. Registry-independent (fires with no registry at all).
// ❌ violating
<div className="bg-white dark:bg-gray-900" />
// ✅ passing
<div className="bg-surface" />tabula/no-important
Disallows the !important marker; it escapes the rank-decides-cascade model. Offered as a suggestion (not a silent autofix), since removing it can change rendered behavior.
// ❌ violating
<div className="!p-md" />
// ✅ passing
<div className="p-md" />tabula/class-order
Enforces canonical ordering — condition band, then ascending utility rank — on any class string whose tokens are fully known, non-arbitrary, and non-!important. Autofixes by reordering (behavior-preserving, since order within one class attribute doesn't affect the computed style once the merge is sound).
// ❌ violating
<div className="hover:bg-surface-raised bg-surface" />
// ✅ passing (autofixed)
<div className="bg-surface hover:bg-surface-raised" />tabula/no-conflicting-classes
Disallows two known classes in one static string that share a condition and either intersect the same slots (redundant — autofixed by keeping the higher-rank class) or pair an atomic class (e.g. sr-only) with a slot-writing class declaring the same CSS property (ambiguous — no autofix; the author must choose).
// ❌ violating (autofixed to `p-8`)
<div className="p-4 p-8" />
// ❌ violating, no autofix — both declare `position`
<div className="sr-only absolute" />tabula/require-merge
Requires a className expression combining two or more class sources (+ concatenation, an array literal) to go through cn(); an un-merged concatenation has undefined precedence. Autofixes by wrapping the operands. Template-literal combining is no-runtime-class-construction's business instead.
// ❌ violating
<div className={base + ' ' + className} />
// ✅ passing (autofixed)
<div className={cn(base, className)} />tabula/classname-last
Requires the className argument to be the last argument of a cn()-family call, so a caller's override always wins. Autofixes by moving it to the end (skipped under a spread argument, where reordering isn't safe).
// ❌ violating
<div className={cn(className, 'p-md')} />
// ✅ passing (autofixed)
<div className={cn('p-md', className)} />tabula/named-group-only
Requires every group/peer marker and consumer to carry a name (group/card, group-hover/card:); a bare group makes "which ancestor is this?" unanswerable without reading the whole tree.
// ❌ violating
<div className="group"><span className="group-hover:opacity-100" /></div>
// ✅ passing
<div className="group/card"><span className="group-hover/card:opacity-100" /></div>tabula/group-marker-exists
Requires a named group-* consumer to have a matching group/<name> marker on a same-file ancestor. Downgrades to TAB-W301 (never an error) when the marker can't be found in this file — it may legitimately live in a parent component the linter can't see.
// ⚠ warning — no `group/card` ancestor found in this file
<span className="group-hover/card:opacity-100" />tabula/peer-source-order
Requires a named peer-* consumer to follow its peer/<name> marker sibling in source order, mirroring the DOM's own ~ requirement for the :has()/general-sibling relationship peer relies on.
// ⚠ warning — `peer/email` must precede this element
<span className="peer-invalid/email:text-danger" />
<input className="peer/email" />tabula/inherited-property-boundary
Under the base profile level only (inert under strict), restricts an inheritable-property utility (text-*, font-*, leading-*, tracking-*, ink-*) to a text-leaf tag or an element explicitly marked scope-text — the compile-time half of containing the one mechanism in base typography that inherits. Autofixes by inserting scope-text on the offending intrinsic element; a component boundary can't be autofixed and downgrades to TAB-W301.
// ❌ violating — <div> is a container, not a text leaf
<div className="text-sm">…</div>
// ✅ passing
<div className="scope-text text-sm">…</div>tabula/exception-scope
Restricts a registered exception class to its allowedIn globs and expires date; using it out of scope or past expiry quietly reopens the closed vocabulary the exception's own paperwork exists to bound.
// ❌ violating — used outside tokens/exceptions.tokens.json's allowedIn glob
<div className="w-hero-legacy-width" /> // in a file not matching `src/marketing/hero.tsx`Exported configs
| Export | What it does |
|---|---|
tabula.configs.strict | Every rule at error. |
tabula.configs.migration | The four ban rules (no-runtime-class-construction, no-unregistered-arbitrary-value, no-theme-variant, no-important) stay error; everything else is warn, for incremental adoption. |
@tabula-css/eslint-plugin/banlist
import { BANLIST, betterTailwindcssBanlist, matchBan } from '@tabula-css/eslint-plugin/banlist';Re-exports @tabula-css/core's registry-independent ban table: BANLIST (the frozen pattern list — space-*, divide-*, *:, **:, arbitrary combinators, in-*, rtl:/ltr: — see Banned mechanisms), matchBan(token) (returns the matching ban id or undefined), and betterTailwindcssBanlist() for wiring the same patterns into a project's own eslint-plugin-better-tailwindcss config.
See also
@tabula-css/registry— the artifact these rules read.@tabula-css/merge— the runtimecn()several rules assume.@tabula-css/cli—tabula scan(the CI-side sweep) andtabula canary(which lints a fixture against thestrictpreset to prove every rule still fires).- Getting started — the full setup walkthrough.
- Concepts — why each banned mechanism is banned.