迁移
范围说明,请先阅读。 这次迁移由两个工具共同驱动,它们本就是要配合使用的。
tabula migrate负责机械性的部分: 四个代码转换器(codemod),只重写可证明是 1:1 的部分,并在其余每一处留下一条定位精确的TODO注释外加一条诊断信息。 ESLint 的migration预设负责判断性的部分:它把每一个剩余的被禁用模式,都变成一条可见的、有定位的警告, 由你手动解决。这里的一切都不是一条命令就能完成的转换,这是刻意的设计——一个猜测设计者意图的代码转换器, 会产生一次没有人真正审查过的改动,却出现在一个作者以为已经迁移完成的文件里。SPEC(.orchestrator/SPEC.md,J6) 描述的那个转换里,唯一被刻意不自动化的,是dark:的提升;第 4 节说明了原因。
从 Tabula v0.1.0 升级到 v0.2.0
v0.2.0 修复了变体封闭性缺陷:在 v0.1.0 中,每一个带变体前缀的 class(hover:bg-accent-hover、sm:p-md, 任何链)都完全不产生任何 CSS,因为该预设只把基础 class 列入了 @source inline 的白名单。v0.2.0 通过 已声明的变体产物,把变体链变成了已注册集合中的一等公民(见 concepts.md § 变体)。 这对一个既有项目意味着:
- 需要一次重新构建——注册表 schema v3 是一次破坏性变更。 注册表 schema 版本升级到了 3(注册表新增了
variantProducts、chainCount、chainExceptions,外加已解析的媒体条件),因此sourceHash/cssHash会发生变化,已提交的.tabula/在升级后会变得过时。一份 v0.1.0 的注册表(schema v2)会被 v0.2 的合并运行时 以及每一个注册表加载器拒绝(TAB-E302),直到tabula build重新生成它为止——请在做任何其他事之前先 运行它;tabula doctor与build --check在你这样做之前都会失败。这是常规的漂移/重建路径,而不是一次 手动迁移。 - 变体现在会产生 CSS。 你代码里已经在用的一条链(例如
hover:bg-accent-hover)一旦它的产物被声明, 就会开始产生一条规则——之前静默地什么都不做的那个 class,现在真的生效了。 interaction预设是默认值。 当tabula.config.json中没有variants小节时,配置档案会声明每一个 自身状态变体(hover、focus、focus-visible、focus-within、active、disabled)覆盖交互族群, 外加placeholder覆盖ink/caret/accent。这就在最常见的场景下开箱修复了这个已发布的缺陷。- 要声明更多, 添加一份
variants.products映射(链前缀 → 族群,用"*"表示所有非原子族群), 或者把variants.preset切换为all-len1;对单独的一次性链,使用tabula except add --chain <chain>。 媒体变体(sm:、……)需要你的设计令牌中存在一个breakpoint轴(否则TAB-E170),产物展开则被variants.maxChainCandidates(默认 20,000,超出预算为TAB-E172)设了上限。
tabula migrate
tabula migrate logical # pl-* → ps-*, and the rest of the physical → logical axis
tabula migrate spacing # space-x/y-* → gap-x/y-*, only where the axis is provable
tabula migrate merge # clsx / classnames / tailwind-merge imports → @tabula-css/merge's cn
tabula migrate dark # report-only: every dark:/light:/[data-theme=…] usage每一个子命令默认都是一次空跑(dry run),会打印出一份真实的统一 diff(--- a/…、@@ 代码块头), 可以直接管道给 git apply 或任何审查工具。加上 --write 即可应用它。除了一个 class 汇(class sink)—— 也就是一个 className/class 属性,或者传给 cn、clsx、cva、tv 等函数的一个参数——之外, 不会重写任何东西;并且只在静态字符串区域内生效,因此一段注释、一个 URL 常量、一个 id 或者一个恰好包含 pl-4 的 alt,都会在结构上就不被触及,而不是依赖一项可能被遗忘的检查。一个无法解析的文件会被报告并跳过; 没有正则表达式回退方案。
退出码是面向智能体的契约,1 和 2 从不含糊:
| 退出码 | 含义 |
|---|---|
0 | 无事可做,或者 --write 已经应用了一切、没有留下任何未完成的部分。 |
1 | 存在尚未完成的迁移工作:一次带有待处理重写的空跑,或者任何被标记 TODO、或仅供报告的发现。 |
2 | 工具本身出了问题或者被误用了:未知的子命令、无法加载的项目,或者该子命令需要却缺失的一份注册表。 |
两条通道始终会给出回应,包括在失败路径上。人类通道得到 diff 与一份摘要;机器通道得到一个信封结构, 其中每一条发现都是一条诊断,携带 file、line、col、一个 subject,以及至少一个可运行的 fix。 没有任何东西只存在于 diff 里,因此一个读取 --format=json 的智能体永远不需要去解析 diff 本身。
从原生 Tailwind v4 迁移
1. 安装并逐步采纳
// eslint.config.js
import tabula from '@tabula-css/eslint-plugin';
export default [
{
...tabula.configs.migration,
files: ['**/*.{ts,tsx}'],
settings: { tabula: { registry: '.tabula/registry.json' } },
},
];migration 让四条规则保持为硬性错误——no-runtime-class-construction、no-unregistered-arbitrary-value、 no-theme-variant、no-important——并把其余的规则(no-unknown-class、class-order、 no-conflicting-classes,等等)放宽为警告,让你可以逐个文件地落地这次切换,而不必一次性全部完成。 甚至可以在你还没写出一个 tokens/*.tokens.json 文件之前就运行它——这四条硬性错误规则与注册表无关。
2. 物理方向 → 逻辑方向的行内轴
pl-* pr-* ml-* mr-* left-* right-* border-l-* border-r-* text-left text-right 根本不会被注册——Tabula 只会注册逻辑形式(ps-* pe-* ms-* me-* start-* end-* border-s-*border-e-* align-start align-end)。你代码库里每一个物理方向轴的 class 都会以 tabula/no-unknown-class 的形式浮现出来,而且因为逻辑形式与物理形式的名字恰好只相差一两个字符(pl-4 → ps-4), 这条规则内置的 Levenshtein "你是不是想写"提示,几乎总能在 lint 输出里直接给出正确的替换方案。 这条规则没有 ESLint 自动修复(这是刻意的——见它头部的注释:一个未知的 class 需要一个人类或智能体去做决策, 而不是一次盲目的重写)。
这是唯一一条轴,一个代码转换器确实可以证明是 1:1 的,所以它有一个:
tabula migrate logical # preview the diff
tabula migrate logical --write # apply it这份映射是关于物理 → 逻辑这条轴本身的一个陈述,对任何项目、无论其设计令牌是什么都成立, 因此这个子命令不需要一份已构建的注册表,会无条件地重写。有两个后果值得牢记:
- 它修的是轴,不是取值。 如果你写了
pl-4,而又没有4这个间距设计令牌,那么结果ps-4依然是未注册的,依然不产生任何 CSS。tabula scan才是捕捉这一点的门禁;命令本身也会打印同样的提示。 text-left会变成align-start,而不是text-start。align-start/align-end才是@tabula-css/core的静态工具类表实际注册的名称,而输出text-start会把代码库里每一个text-left迁移成一个编译不出任何东西的 class。
匹配发生在整个 class token 上,是在剥离了变体与任何取负符号之后进行的——所以 place-content-center、 border-large 和 xpl-4 都不会被误伤,变体链会被保留下去(md:hover:border-l-2 → md:hover:border-s-2), 负值会被映射到可取负的逻辑族群(-ml-4 → -ms-4)。在一个模板字面量内部,静态的 quasi 部分会被重写, 但一个紧贴着插值的片段不会:在 `pl-${n}` 中,文本 pl- 是一个 class 片段,其真实取值是代码转换器 看不到的,因此它会拒绝在这里做出猜测。
3. space-* / divide-* → gap-* 与逐子元素边框
这些同样不在注册表里,所以它们也会通过 no-unknown-class 浮现出来——但只是作为一个泛泛的"未知 class", 而不是一条被禁用机制的说明。有两个更好的选项可以看清为什么,而不只是确实如此:
- 向
tabula-mcp的explain_ban工具(或者tabula explain)询问那个具体的 class——它返回的是该机制的原因 及其替代方案,而绝不是一句干巴巴的"未找到"。 - 如果你已经在使用
eslint-plugin-better-tailwindcss,可以把betterTailwindcssBanlist()(从@tabula-css/eslint-plugin/banlist导出)展开进它的no-restricted-classes选项, 在你自己的编辑器里内联获得同样的提示信息。
把父元素上的 space-y-4 替换为 gap-y-md(或者你最接近的间距设计令牌)加上 flex flex-col; 把 divide-y 替换为在子元素之间放一个 <Separator />(@tabula-css/react 提供了一个), 或者直接在每个子元素上使用一个边框工具类。这两种改动都是把样式从父元素的标记移到了子元素自己身上—— 这正是问题的关键所在。
tabula migrate spacing 会处理其中可证明的那一部分子集,并标记出其余的部分:
tabula migrate spacing --write它只在单个元素上、当以下所有条件都在该元素自身的 class 字符串上成立时,才会把 space-x-* → gap-x-* 以及 space-y-* → gap-y-*:它携带 flex(或 inline-flex);它携带一个与该轴匹配的显式 flex-row/flex-col;没有任何带变体前缀的 display 或方向 class 会在某个断点上改变该轴; 并且目标 gap-x-*/gap-y-* class 确实存在于你的注册表中。其他一切情况都会得到一条 TODO(tabula migrate spacing) 注释,说明确切原因,外加一条诊断——而绝不会被重写。
有四种拒绝是刻意为之,而不是尚未实现:
- 裸的
flex、没有显式方向时会被拒绝。flex-direction: row是 CSS 的初始值,所以单独的flex今天确实是 row 方向——但一个响应式变体、一份父级样式表,或者一个style属性都可能改变它, 而这些都无法从 class 字符串里看出来。拒绝只会让你多写一个词(flex-row);接受则可能悄悄产生一个错误的布局。 grid永远不会被自动重写。space-x-*的 margin 应用于 DOM 顺序中第一个之后的每一个子元素, 一旦条目换行到第二行,这就不再对应于column-gap了。- 绝不写成单纯的
gap-*。gap、gap-x和gap-y是三个独立的族群;折叠成gap-*会在另一条轴上 悄悄加上间距。 - 它从不发明一个取值。 如果
gap-y-4没有被注册,space-y-4会被标记出来,而不是被重写成一个看起来最接近的设计令牌。
与 migrate logical 不同,这个子命令需要一份已构建的注册表——目标是一个取值,而不是一条轴, 只有注册表才知道 md 是不是一个真实的间距设计令牌。没有它,命令会以退出码 2 结束,并告诉你去运行 tabula build,因为"我无法判断这次重写是否安全"是一个坏掉的工具的表现,而不是一个违规的项目。
4. dark: → 主题轴设计令牌
no-theme-variant 在两套预设下都会把每一个 dark:/light:/[data-theme=…]: 变体标记为错误 (它与注册表无关——是一次纯粹的语法检查)。这里没有自动修复,因为修复需要一个只有你才拥有的值: 该设计令牌另一种主题下的字面量。对每一个被标记的组件:
- 在
tokens/*.tokens.json中找到或创建那个颜色设计令牌,用一个携带两种字面量的$axis: "theme"取值 (见 concepts.md § 主题化)。 - 把
bg-white dark:bg-gray-900替换为那一个设计令牌工具类,例如bg-surface。 - 彻底删除
dark:这个 class——该设计令牌工具类已经同时携带了两种取值。
tabula migrate dark 会为你生成这项工作的清单,附带每一处 dark:/light:/[data-theme=…] 用法的 file:line:col,以及附加在每一条上的轴设计令牌解释。它是仅供报告的,--write 什么都不会改变—— 这正是这个子命令的意义所在,而不是一个缺失的功能。有两点使这个转换无法自动化。目的地是一个设计令牌文件, 而不是 class 字符串本身:这个 class 会缩减成一个名字,而信息则转移进了 tokens/*.tokens.json。 而生成那个设计令牌需要该设计令牌另一种主题的字面量,当源码里只存在一个 dark: class 时, 这个值根本不存在于任何地方,也无法通过任何规则从浅色取值推导出来。一个代码转换器就不得不去发明它—— 而这恰恰是这份配置档案要极力阻止的那种编造,并且比没有代码转换器更糟糕,因为它的输出看起来像是被审查过的。 所以这个命令选择报告,并指向真正拥有那个缺失取值的路径:你自己、tabula except add, 以及 MCP 的 propose_token / get_tokens 工具。任何用法都会导致退出码 1,这正是有意为之的信号: 这里存在着没有任何工具能替你完成的主题工作。
5. 任意值 → 已注册的设计令牌或例外
no-unregistered-arbitrary-value 在两套预设下都是硬性错误。对每一个 [...] 取值:先检查 tokens.resolved.json / find_class_for,看是否有足够接近的现成设计令牌;如果没有,运行 tabula except add(见 getting-started.md § 7) 来铸造一个具名的、有归属者的、会到期的 class,而不是把方括号语法原样留在那里。
从 shadcn/ui 迁移
shadcn/ui 与 Tabula 用不同的机制解决着重叠的问题(一套小型的自有组件集合、基于 Tailwind、按设计对智能体友好)。 以下是发生变化的地方:
cn() → @tabula-css/merge 的 cn()
shadcn 的 cn = (...inputs) => twMerge(clsx(inputs)) 依靠名字形态的启发式规则来合并——tailwind-merge 从前缀猜测哪些工具类会冲突,而且它明确不解决一个任意值与一个工具类之间的冲突(twMerge('p-4 [padding:1rem]') 会把两者都保留下来,任由样式表顺序悄悄决定谁获胜)。@tabula-css/merge 的 cn() 拥有相同的调用形态—— cn(...classValues)——但依靠注册表声明的槽位归属来合并:它是数学上可靠的(T2),而不是启发式的, 每一对真正可组合的搭配(例如 shadow-md + ring-2)都是一个经过测试的黄金用例,而不是命名上的巧合。 替换掉这个导入即可;调用现场不需要改变形态,只是现在每一个 class 都必须是你注册表里确实包含的那种。
tabula migrate merge 会替换掉导入,并且保持每一个调用现场不变——这正是它强制做别名替换的原因: 一个带有 import clsx from 'clsx' 与四十次 clsx(...) 调用的文件,会变成 import { cn as clsx } from '@tabula-css/merge'。绑定名称保持是你自己的;只有它的来源变了。 有三种行为值得了解:
twMerge会被重写,同时被标记。 目的地是那个可靠的方案,但合并语义确实发生了根本性变化—— 从启发式变成了槽位归属——所以每一个调用现场都需要审查。这个命令会输出一条TODO注释与一条相应的警告, 即便在--write之后,这次运行依然保持退出码1,因此它绝不会被误认为一次纯机械性的空操作。- 只有单独的说明符(sole specifier)会被重写。
import clsx, { type ClassValue } from 'clsx'会被原样保留并附带一个标记:ClassValue在@tabula-css/merge里是否存在于同一个名字下并不确定, 重写这条声明要么会丢失一个绑定,要么会断言一个这个命令尚未验证过的导出。请拆开这条声明后重新运行。 - 它从不会产生一个重复的绑定。 如果那个局部名字在该文件里已经是从
@tabula-css/merge导入的, 过时的那个导入会被直接删除,而不是被重写——一个重复的局部绑定会是一个语法错误。
cva → variants()
同样的思路(一个基础字符串加上具名的变体分组,再加上 defaultVariants),在 @tabula-css/merge 里被 重新实现为一份静态的、字面量的配置,因此覆盖普通 class 字符串的那套同一份 ESLint 词汇检查,也会覆盖它内部的 每一个字符串。形状请见 getting-started.md § Variants; examples/reference-ui 中的 button.tsx 是一份从 shadcn Button 模式完整转换过来的实例。
:root / .dark CSS 变量对 → 轴设计令牌
shadcn 的主题文件把 CSS 自定义属性定义了两遍——一遍在 :root 下,一遍在 .dark 下——组件则通过 Tailwind 的 @theme inline 桥接来读取它们。Tabula 的答案是 concepts.md 中的 轴模型:一个设计令牌、一个携带两种字面量的 $axis: "theme" 取值,在构建时被解析进 :root[data-theme="dark"]。两处具体的改动:删除 .dark { --variable: ... } 代码块,把它的取值折叠进 该设计令牌的轴映射;并且永远不要在你自己的 CSS 里写 @theme inline——它在这里是一种被禁用的机制 (它会绕过让主题切换真正生效的那次轴重新指向)。
className 透传 → 类型化的 ClassName + 把 cn(..., className) 放在最后
两套生态都已经按约定把 className 放在最后;Tabula 把它变成了一条 lint 规则 (tabula/classname-last,可自动修复),并把这个 prop 的类型定为 ClassName(一个由 @tabula-css/merge 导出的品牌化字符串),这样 tabula/no-runtime-class-construction 就会把这个被解构出来的 prop 识别为 一次受核准的透传,而不是把它标记为未经检查的运行时构造。
保留不变的部分
组件的形态——一个被转发的 ref、一个类型化的 props 接口、组合优于配置——保持不变;Tabula 不要求移除 Radix 基元或者 shadcn 的复制式文件布局。@tabula-css/react 并不试图取代 Radix——它提供三个小型基元 (<Text>、<Separator>、<Prose>),它们的存在正是为了给你一条铺好的路径,用来替换这份配置档案所移除的三种机制 (局部排版 class、divide-*、prose 插件的后代选择器)。一个 shadcn 风格组件中的其他一切——可访问的交互逻辑、 复合组件结构——都与样式层正交,无需任何改动。
从 .tabula/ 恢复一份配置档案
一份已提交的 .tabula/ 足以近乎无损地重建出构建它所用的源配置档案。正向构建在设计上就会保留出处信息: tokens.resolved.json 会原样携带每一个设计令牌的 $deprecated 与 $extensions——因此 $extensions.tabula.contrastWith 无障碍契约以及任何外部厂商命名空间(例如一个 com.example.figma 引用) 都会完整地回来——并且每当源 $value 是一个像 {color.surface} 这样的单一顶层别名引用时,一个 $alias 标记会记录下那个点路径,因此恢复出来的是引用本身,而不是一个被拍平的字面量。与它并列的是 tabula.config.json,一份构建出该目录所用配置的字节级相同拷贝,因此轴、配置档案级别与变体产物都无需靠猜测。 要恢复,读取 tokens.resolved.json,把每一个 $alias 转回它的 {path} 引用,把 $extensions/$deprecated 原样带过去,再把这些设计令牌与被复制的配置配对起来。
唯一仍然存在的损失类别: 一个复合取值内部的嵌套或局部别名——一个作为 type 复合值某个字段、shadow 某一层,或者某个轴映射成员使用的别名——不会被标记,会以它被解析后的字面量形式回来,因为只有单一的顶层 {path} $value 才会被记录下来。除此之外的一切都能往返回同一份词汇表。
彻底从 Tabula 弹出
恢复重建的是源头;弹出(ejecting,实验性)则反其道而行——它冻结的是产物。tabula eject 把一份已验证的 .tabula/ 复制进一个项目自有的目录,该目录在原生的 @tailwindcss/cli 上编译,class 零改动,并且从此停止 设计令牌流水线向它输送内容。这是一扇单向门:弹出之后不再有重新构建、不再有扫描门禁、也不再有漂移检查, 设计令牌的改动不会再抵达那份被冻结的拷贝。运行时的 cn() 仍然需要 @tabula-css/merge 与被复制的 registry.json——用 tailwind-merge 替换它会改变渲染出的输出。完整流程、它所处理的四种风险,以及确切的 命令形态,见 弹出(Eject)。