Skip to content

@tabula-css/eslint-plugin

Tabula 的强制执行层:将封闭词汇表变为硬性门禁的 flat-config ESLint 插件。只读取注册表——导入图中不含 Tailwind。

安装

bash
npm install --save-dev @tabula-css/eslint-plugin

开发依赖,与 eslint(对等依赖,>=9.0.0)搭配使用。

概览

tabula build 推导出封闭词汇表,tabula scan 在 CI 中对每一个源文件进行地毯式排查;而 @tabula-css/eslint-plugin 是面向编辑器和 pre-commit 的门禁:一个词汇表之外的类、一个在运行时拼装出来的类名,或一个过期的例外,都会变成带有 TAB-E/TAB-W 诊断代码的 lint 错误,并且在你输入代码的同时实时生效。它直接读取生成的 registry.json——从不导入 Tailwind——因此 lint 速度保持很快。共发布十五条 tabula/* 规则,分为两个预设(strictmigration)。

启用

eslint.config.js(flat config):

js
import tabula from '@tabula-css/eslint-plugin';

export default [
  {
    ...tabula.configs.strict,
    files: ['**/*.{ts,tsx}'],
    settings: {
      tabula: { registry: '.tabula/registry.json' },
    },
  },
];

strict 会把全部 15 条规则都设为 error。要在一个既有代码库中采用它?请改用 tabula.configs.migration:四条封禁规则(no-runtime-class-constructionno-unregistered-arbitrary-valueno-theme-variantno-important)保持硬性 error,其余的放宽为 warn,这样你就可以分阶段、逐步完成这次切换。

settings.tabula

默认值含义
registry从当前工作目录向上遍历查找 .tabula/registry.jsonregistry.json 的显式路径。
calleescncxclsxcvatvvariantstwMergeclassnames其参数会被视为类 sink 的函数调用表达式。
classAttributesclassNameclass被视为类 sink 的 JSX 属性名。
textLeafComponents[]项目声明为渲染文本叶子节点的组件名(<Card>),供 inherited-property-boundary 使用。
classMapSources/\.classmap(\.(c|m)?[jt]sx?)?$/指定用作查找表模块的文件名模式(no-runtime-class-construction 的 A7 形式)。
classNameSources/^@tabula-css\/merge$/可以提供带标记的 ClassName 类型的模块说明符。
now真实时钟exception-scope 到期检查使用的 ISO 日期覆盖值,用于确定性测试。

规则

tabula/registry-required

要求存在一个可读、且 Schema 有效的注册表——这是其他每一条依赖注册表的规则所共享的前置条件。将其单独导出,是为了让一个项目即使关闭了内容规则,也能要求这一条规则生效。

ts
// fails: no readable registry.json at the configured/discovered path

tabula/no-runtime-class-construction

禁止在运行时拼装类字符串。Tailwind 的扫描器只能看到完整的字面量字符串,因此 `p-${n}` 会静默地编译成空。允许条件表达式(cond && "x"、三元表达式、由字面量组成的数组/对象)、一个模块作用域的 const 查找表、来自 *.classmap.ts 模块的导入、一个 cva()/tv()/variants() 的结果,以及一个 ClassName 类型的直通参数。

tsx
// ❌ 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

禁止任何含 [...] 的候选项(任意值、任意属性、任意变体,或任意修饰符)未作为例外注册。从不自动修复。

tsx
// ❌ violating
<div className="w-[347px]" />

// ✅ passing — after `tabula except add` registers it
<div className="w-hero-legacy-width" />

tabula/no-unknown-class

禁止使用一个其工具类未在词汇表中注册的类。优先报告与注册表无关的封禁表命中情况(因此 space-x-4 会解释为什么它被封禁,而不是建议一个拼写修正),然后再对其余情况提供 Levenshtein 距离 ≤2 的“你是不是想输入……?”建议。从不自动修复。

tsx
// ❌ violating
<div className="bg-surfac" />
// Unknown class `bg-surfac`. Did you mean `bg-surface`?

// ✅ passing
<div className="bg-surface" />

tabula/no-theme-variant

禁止 dark:light:,以及任何 [data-theme…]/[prefers-color-scheme…] 变体。主题是一个令牌轴——bg-surface 已经携带了每个主题下的值——因此一个变体会重新引入该轴模型本应消除的分支。与注册表无关(即使完全没有注册表也会触发)。

tsx
// ❌ violating
<div className="bg-white dark:bg-gray-900" />

// ✅ passing
<div className="bg-surface" />

tabula/no-important

禁止 !important 标记;它会逃逸出“排名决定级联”的模型。以建议的形式给出(而非静默自动修复),因为移除它可能会改变渲染行为。

tsx
// ❌ violating
<div className="!p-md" />

// ✅ passing
<div className="p-md" />

tabula/class-order

对任何其令牌全部已知、非任意值、且非 !important 的类字符串,强制执行规范排序(先条件区间,再按工具类排名升序)。通过重新排序进行自动修复(这是保持行为不变的,因为一旦合并是可靠的,同一个 class 属性内部的顺序就不会影响计算出的样式)。

tsx
// ❌ violating
<div className="hover:bg-surface-raised bg-surface" />

// ✅ passing (autofixed)
<div className="bg-surface hover:bg-surface-raised" />

tabula/no-conflicting-classes

禁止在一个静态字符串中出现两个共享同一条件、且满足以下情形之一的已知类:要么它们的槽位相交(冗余——自动修复为保留排名更高的那个类),要么将一个原子类(例如 sr-only)与一个声明了同一 CSS 属性的、写入槽位的类配对(含糊不清——不做自动修复,必须由作者自行取舍)。

tsx
// ❌ 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

要求一个组合了两个或更多类来源(+ 拼接、数组字面量)的 className 表达式必须经过 cn();一次未经合并的拼接,其优先级是未定义的。通过包裹这些操作数进行自动修复。模板字符串拼接则属于 no-runtime-class-construction 的管辖范围。

tsx
// ❌ violating
<div className={base + ' ' + className} />

// ✅ passing (autofixed)
<div className={cn(base, className)} />

tabula/classname-last

要求 className 参数是 cn() 家族调用的最后一个参数,这样调用方的覆盖值才能始终胜出。通过将其移动到末尾进行自动修复(在展开参数下会跳过,因为此时重排是不安全的)。

tsx
// ❌ violating
<div className={cn(className, 'p-md')} />

// ✅ passing (autofixed)
<div className={cn('p-md', className)} />

tabula/named-group-only

要求每一个 group/peer 标记及其消费者都必须带有一个名字(group/cardgroup-hover/card:);一个裸的 group 会使“这指的是哪个祖先元素?”这个问题在不读遍整棵树的情况下无法回答。

tsx
// ❌ 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

要求一个命名的 group-* 消费者,在同一文件内的某个祖先元素上有一个与之匹配的 group/<name> 标记。当在这个文件中找不到该标记时,会降级为 TAB-W301(从不作为 error)——因为它可能合理地存在于该检查器看不到的一个父组件中。

tsx
// ⚠ warning — no `group/card` ancestor found in this file
<span className="group-hover/card:opacity-100" />

tabula/peer-source-order

要求一个命名的 peer-* 消费者在源码顺序上跟随其 peer/<name> 标记同级元素之后,这与 peer 所依赖的 :has()/通用兄弟选择器关系里 DOM 自身对 ~ 的要求相呼应。

tsx
// ⚠ warning — `peer/email` must precede this element
<span className="peer-invalid/email:text-danger" />
<input className="peer/email" />

tabula/inherited-property-boundary

仅在 base 配置档案层级下生效(在 strict 下无效),将一个可继承属性的工具类(text-*font-*leading-*tracking-*ink-*)限制在一个文本叶子标签,或一个被显式标记为 scope-text 的元素上——这是在编译期约束 base 排版中唯一那个具有继承性的机制的一半。通过在触犯规则的原生元素上插入 scope-text 进行自动修复;一个组件边界无法被自动修复,会降级为 TAB-W301

tsx
// ❌ 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

将一个已注册的例外类限制在其 allowedIn glob 和 expires 日期范围之内;在范围之外或过期之后使用它,会悄悄地重新打开这个例外的文书工作本应加以约束的那个封闭词汇表。

tsx
// ❌ 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`

已导出的配置

导出项作用
tabula.configs.strict每一条规则都设为 error
tabula.configs.migration四条封禁规则(no-runtime-class-constructionno-unregistered-arbitrary-valueno-theme-variantno-important)保持 error;其余全部为 warn,供渐进式采用使用。

@tabula-css/eslint-plugin/banlist

ts
import { BANLIST, betterTailwindcssBanlist, matchBan } from '@tabula-css/eslint-plugin/banlist';

重新导出 @tabula-css/core 中那份与注册表无关的封禁表:BANLIST(冻结的模式列表——space-*divide-**:**:、任意组合符、in-*rtl:/ltr:——参见禁用机制)、matchBan(token)(返回匹配到的封禁 id,或 undefined),以及 betterTailwindcssBanlist()(用于将同一套模式接入项目自身的 eslint-plugin-better-tailwindcss 配置)。

参见

  • @tabula-css/registry — 这些规则所读取的产物。
  • @tabula-css/merge — 若干规则所假定存在的运行时 cn()
  • @tabula-css/clitabula scan(CI 侧的地毯式排查)与 tabula canary(对一个测试样例进行 lint,以证明 strict 预设中的每一条规则依然会触发)。
  • 快速上手 — 完整的设置演练。
  • 概念 — 为什么每一种被禁止的机制会被禁止。

Released under the MIT License.