移行
最初に読むべきスコープに関する注記。 この移行は2つのツールによって進められ、両者は一緒に使われることを前提としています。
tabula migrateは機械的な部分を担当します:証明可能に 1:1 であるものだけを書き換える4つのコードモッドで、それ以外の場所にはすべて、位置情報付きのTODOコメントと診断メッセージを残します。ESLint のmigrationプリセットは判断の部分を担当します:残っているすべての禁止パターンを、目に見える、位置情報付きの警告に変え、あなたが手で解決します。ここには、ワンコマンドで済む変換は意図的に一つもありません――デザイナーの意図を推測するコードモッドは、誰にもレビューされていない変更を、その作者は移行済みだと信じているファイルの中に生み出してしまうからです。SPEC(.orchestrator/SPEC.md、J6)が説明している変換のうち、意図的に自動化されていない唯一のものがdark:の巻き上げ(hoisting)です。理由は §4 で説明します。
Tabula v0.1.0 → v0.2.0 へのアップグレード
v0.2.0 はバリアント閉包の欠陥を修正します:v0.1.0 では、バリアントが前置されたすべてのクラス(hover:bg-accent-hover、sm:p-md、あらゆるチェーン)がCSS を一切生成していませんでした。プリセットが @source inline にベースクラスだけをホワイトリスト化していたためです。v0.2.0 は、宣言済みのバリアントプロダクトを通じて、バリアントチェーンを登録済みの集合の第一級のメンバーにします(concepts.md § バリアント を参照)。既存プロジェクトにとっての意味は次のとおりです:
- リビルドが必須です――レジストリスキーマ v3 は破壊的変更です。 レジストリのスキーマバージョンは 3 に上がり(レジストリに
variantProducts、chainCount、chainExceptions、加えて解決済みのメディア条件が追加されます)、その結果sourceHash/cssHashが変わり、コミット済みの.tabula/はアップグレード時に stale になります。v0.1.0 のレジストリ(スキーマ v2)は、tabula buildがそれを再生成するまで、v0.2 のマージランタイムとすべてのレジストリローダーによって拒否されます(TAB-E302)――何よりも先にそれを実行してください。実行するまでtabula doctorとbuild --checkは失敗し続けます。これは手動での移行ではなく、通常の drift/リビルドの経路です。 - バリアントが CSS を生成するようになります。 あなたのコードがすでに使っていたチェーン(例:
hover:bg-accent-hover)は、そのプロダクトが宣言され次第、ルールを生成し始めます――以前は静かに何もしなかったクラスが、今は機能します。 interactionプリセットがデフォルトです。tabula.config.jsonにvariantsセクションがない場合、プロファイルは、インタラクティブなファミリーに対するすべての自己状態バリアント(hover、focus、focus-visible、focus-within、active、disabled)に加えて、ink/caret/accentに対するplaceholderを宣言します。これにより、一般的なケースについては、この欠陥がそのまま(out of the box)修正されます。- さらに宣言するには、
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すべてのサブコマンドはデフォルトでドライランであり、git apply や任意のレビューツールにパイプできる、本物の unified diff(--- a/…、@@ のハンクヘッダー)を出力します。適用するには --write を追加してください。書き換えが行われるのは、常にクラスのシンク――className/class 属性、あるいは cn、clsx、cva、tv などへの引数――の中だけであり、しかも静的な文字列の領域の中だけです。そのため、たまたま pl-4 を含んでいるコメント、URL の定数、id、alt は、忘れられうるチェックによってではなく、構造的に触れられません。パースできないファイルは報告されてスキップされます。正規表現によるフォールバックはありません。
終了コードはエージェント向けの契約であり、1 と 2 が曖昧にされることは決してありません:
| Exit | Meaning |
|---|---|
0 | やることが何もない、あるいは --write がすべてを適用し、未処理のものが何も残らなかった。 |
1 | 移行作業が未処理のまま残っている:保留中の書き換えがあるドライラン、あるいは TODO フラグ付き、またはレポートのみの検出結果がある。 |
2 | ツール自体が壊れている、あるいは誤って駆動されている:未知のサブコマンド、読み込めないプロジェクト、あるいはサブコマンドがレジストリを必要とするのにそれが欠けている。 |
失敗の経路を含め、両方のチャネルが常に答えを返します。人間向けのチャネルは diff とサマリーを受け取ります。マシン向けのチャネルは、すべての検出結果が file、line、col、subject、そして少なくとも1つの実行可能な 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 は、4つのルール――no-runtime-class-construction、no-unregistered-arbitrary-value、no-theme-variant、no-important――を厳格なエラーのまま保ち、残り(no-unknown-class、class-order、no-conflicting-classes など)は警告に緩和します。そのため、一度にではなく、ファイルごとに切り替えを進めることができます。tokens/*.tokens.json ファイルをまだ書いてもいない段階でも実行できます――この4つの厳格エラールールはレジストリに依存しないからです。
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)だけです。あなたのコードベース内のすべての物理軸クラスは tabula/no-unknown-class として表面化します。論理名と物理名はちょうど1〜2文字しか違わないため(pl-4 → ps-4)、このルールに組み込まれたレーベンシュタインによる「もしかして」機能は、ほとんどの場合、正しい置き換え候補をリントの出力の中に直接示してくれます。このルールには ESLint の自動修正はありません(意図的なものです――ヘッダーコメントを参照:未知のクラスは、盲目的な書き換えではなく、人間かエージェントの判断を必要とします)。
これは、コードモッドが証明可能に 1:1 である唯一の軸であるため、専用のコードモッドが用意されています:
tabula migrate logical # preview the diff
tabula migrate logical --write # apply itこのマッピングは、物理 → 論理という軸についての言明であり、トークンが何であれすべてのプロジェクトに当てはまります。そのため、このサブコマンドはビルド済みのレジストリを必要とせず、無条件に書き換えを行います。頭に入れておくべき、2つの帰結があります:
- 修正するのは軸であって、値ではありません。
pl-4と書いていて、4という間隔トークンがない場合、結果であるps-4も依然として未登録であり、依然として CSS を生成しません。それを捕捉するゲートはtabula scanです。コマンドも同じ注意を表示します。 text-leftはtext-startではなくalign-startになります。@tabula-css/coreの静的ユーティリティテーブルが実際に登録しているのはalign-start/align-endであり、text-startを出力してしまうと、コードベース内のすべてのtext-leftを、何もコンパイルされないクラスへと移行させてしまうことになります。
マッチングは、バリアントと否定記号を取り除いた後の、クラストークン全体に対して行われます――そのため place-content-center、border-large、xpl-4 はそのままにされ、バリアントチェーンは維持され(md:hover:border-l-2 → md:hover:border-s-2)、否定は否定可能な論理ファミリーへマッピングされます(-ml-4 → -ms-4)。テンプレートリテラルの内部では、静的な部分(quasis)は書き換えられますが、補間に隣接するフラグメントは書き換えられません:`pl-${n}` の中のテキスト pl- は、コードモッドがその実際の値を見ることのできないクラスフラグメントであるため、そこで推測することは拒否されます。
3. space-* / divide-* → gap-* と、子要素ごとのボーダー
これらもレジストリには存在しないため、同じく no-unknown-class として表面化しますが、禁止メカニズムの説明としてではなく、一般的な「未知のクラス」としてです。単にそれが起きていることだけでなく、なぜなのかを知るための、より良い2つの方法があります:
tabula-mcpのexplain_banツール(またはtabula explain)に、その具体的なクラスについて尋ねてください――そのメカニズムの理由と代替手段を返します。単なる「見つかりません」を返すことは決してありません。- すでに
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 が1つ提供しています)か、各子要素に直接付けるボーダーのユーティリティに置き換えてください。どちらの変更も、スタイリングを親のマークアップから子のマークアップへと移します――それこそがこの変更のすべてです。
tabula migrate spacing は、証明可能な部分だけをその中から行い、残りにはフラグを立てます:
tabula migrate spacing --writeこれは space-x-* → gap-x-*、space-y-* → gap-y-* の書き換えを、その要素だけに対して、その要素自身のクラス文字列上で次のすべてが成り立つ場合にのみ行います:flex(または inline-flex)を持っている;軸に一致する明示的な flex-row/flex-col を持っている;どのバリアント接頭辞付きの display やディレクションのクラスも、あるブレークポイントで軸を変えていない;そして、対象となる gap-x-*/gap-y-* クラスが実際にあなたのレジストリに存在している。それ以外のものはすべて、正確な理由を名指しした TODO(tabula migrate spacing) コメントと診断メッセージを受け取ります――書き換えは決して行われません。
4つの拒否は、未実装なのではなく、意図的なものです:
- 明示的な方向を持たない裸の
flexは拒否されます。flex-direction: rowは CSS の初期値であるため、flex単体は今日の時点ではrow です――しかし、レスポンシブなバリアント、親のスタイルシート、あるいはstyleプロパティがそれを変えることがあり、そのいずれもクラス文字列からは見えません。拒否することのコストは1単語(flex-row)ですが、受け入れることのコストは静かに壊れたレイアウトです。 gridは決して自動書き換えされません。space-x-*のマージンは DOM の順序で最初の子以降のすべての子に適用され、アイテムが2行目に折り返した瞬間に、それはcolumn-gapと対応しなくなります。- 単なる
gap-*になることは決してありません。gap、gap-x、gap-yは3つの別々のファミリーです。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のような、その1つのトークンユーティリティに置き換える。dark:のクラスを完全に削除する――トークンのユーティリティはすでに両方の値を保持している。
tabula migrate dark は、そのための作業リストを与えてくれます。すべての dark:/light:/[data-theme=…] の使用箇所について file:line:col と、それぞれに付随するトークン軸の説明とともにです。これはレポートのみであり、--write は何も変更しません――それはこのサブコマンドの欠けている機能ではなく、まさにその趣旨です。この変換を自動化不可能にしている理由は2つあります。移行先はトークンファイルであって、クラス文字列ではありません:クラスは1つの名前に縮小され、情報は tokens/*.tokens.json へと移動します。そして、そのトークンを作るには、トークンのもう一方のテーマのリテラル値が必要ですが、dark: クラスが1つしか存在しない場合、それはソースのどこにも存在せず、ライトの値からいかなるルールによっても導出できません。コードモッドはそれを捏造せざるを得なくなります――まさに、このプロファイルが対抗するよう設計されている捏造そのものであり、その出力がレビュー済みに見えてしまう分、コードモッドが存在しないよりも悪い結果になります。そのためこのコマンドはレポートするだけで、欠けている値を実際に持っている経路――あなた自身、tabula except add、そして MCP の propose_token / get_tokens ツール――を指し示します。1件でも使用があれば終了コード 1 になります。これは意図された合図です:ここには、どんなツールにもできないテーマの作業が残っている、ということです。
5. 任意の値 → 登録済みトークンまたは例外
no-unregistered-arbitrary-value は、両方のプリセットにおいて厳格なエラーです。それぞれの [...] の値について:tokens.resolved.json / find_class_for を調べ、十分に近い既存のトークンがあるか確認してください。どれも合わなければ、角括弧の構文をそのまま残す代わりに、tabula except add(getting-started.md § 7 を参照)を実行して、名前付きで、所有者が明確で、有効期限のあるクラスを新たに発行してください。
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)はどれも、命名の偶然ではなく、テストされたゴールデンケースです。インポートを差し替えるだけでよく、呼び出し箇所は形を変える必要はありません。ただし、すべてのクラスは、今やあなたのレジストリが実際に含んでいるものでなければなりません。
tabula migrate merge はインポートを差し替え、すべての呼び出し箇所はそのままにします。これがエイリアシングを強制する理由です:import clsx from 'clsx' と40個の clsx(...) 呼び出しを持つファイルは、import { cn as clsx } from '@tabula-css/merge' になります。バインディングの名前はあなたのものとして残り、その出自だけが変わります。知っておくべき3つの挙動があります:
twMergeは書き換えられ、かつフラグも立てられます。 移行先は健全な方ですが、マージのセマンティクスは実際に変わります――ヒューリスティックからスロットの所有関係へ――そのためすべての呼び出し箇所にレビューが必要です。コマンドはその旨を伝えるTODOコメントと警告を発行し、--writeの後でも実行は終了コード1のままなので、機械的な no-op と誤解されることは決してありません。- 単独の指定子だけが書き換えられます。
import clsx, { type ClassValue } from 'clsx'はフラグを立てられたまま、そのままにされます:ClassValueは@tabula-css/mergeの中に同じ名前で存在するかもしれないし、しないかもしれず、この宣言を書き換えるとバインディングを失うか、コマンドが確認していないエクスポートを主張することになってしまうからです。宣言を分割してから再実行してください。 - 重複したバインディングを生成することは決してありません。 そのファイル内でローカル名がすでに
@tabula-css/mergeからインポートされている場合、古いインポートは書き換えではなく削除されます――重複したローカルバインディングは構文エラーだからです。
cva → variants()
同じ考え方(ベースの文字列、名前付きバリアントのグループ、defaultVariants)を、@tabula-css/merge の中で静的でリテラルな設定として再実装したものです。そのため、普通のクラス文字列をカバーするのと同じ ESLint の語彙チェックが、その中のすべての文字列もカバーします。形については getting-started.md § バリアント を参照してください。examples/reference-ui の button.tsx は、shadcn の Button パターンからの完全な変換例です。
:root / .dark の CSS 変数のペア → 軸トークン
shadcn のテーマファイルは、CSS カスタムプロパティを2回定義します――1回は :root の下、もう1回は .dark の下――そしてコンポーネントは、Tailwind の @theme inline ブリッジを通じてそれらを読みます。Tabula の答えは、concepts.md にある軸モデルです:1つのトークン、両方のリテラルを持つ1つの $axis: "theme" の値が、ビルド時に :root[data-theme="dark"] へと解決されます。具体的な変更は2つです:.dark { --variable: ... } ブロックを削除し、その値をトークンの軸マップへ折りたたむこと。そして、あなた自身の CSS の中に @theme inline を決して書かないこと――ここではこれは禁止されたメカニズムです(テーマの切り替えを実際に機能させている軸の再ポイント処理を迂回してしまうからです)。
className パススルー → 型付きの ClassName と、最後に置く cn(..., className)
両方のエコシステムは、すでに慣例として className を最後に置いています。Tabula はそれをリントルール(tabula/classname-last、自動修正可能)にし、そのプロパティに ClassName という型(@tabula-css/merge がエクスポートする、ブランド付きの文字列)を付けます。これにより、tabula/no-runtime-class-construction は、分割代入されたプロパティを、チェックされていない実行時構築としてフラグする代わりに、承認されたパススルーとして認識します。
変わらないもの
コンポーネントの形――転送される ref、型付けされた props のインターフェース、設定よりも合成――は変わりません。Radix のプリミティブや、shadcn のコピーイン方式のファイル配置を取り除くことを、Tabula は何も要求しません。@tabula-css/react は Radix を置き換えようとはしていません――このプロファイルが取り除く3つのメカニズム(部分的なタイポグラフィのクラス、divide-*、prose プラグインの子孫セレクタ)に対する、舗装された道 (paved-path) の代替を与えるためだけに存在する、3つの小さなプリミティブ(<Text>、<Separator>、<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 コンポジットの1つのフィールド、shadow の1つのレイヤー、あるいは軸マップの1つのメンバーとして使われるエイリアス――はマークされず、解決済みのリテラルとして戻ってきます。記録されるのはトップレベルの単一の {path} の $value だけだからです。それ以外のすべては、同じ語彙へと往復します。
Tabula から完全にイジェクトする
復元はソースをリビルドします。イジェクト(実験的機能)はその逆方向です――出力を凍結します。tabula eject は、検証済みの .tabula/ を、クラスの変更なしに標準の @tailwindcss/cli でコンパイルできる、プロジェクト所有のディレクトリへとコピーし、トークンパイプラインがそこへ流れ込むのを止めます。これは一方通行のドアです:イジェクトの後にはリビルドも、スキャンゲートも、ドリフトチェックもなく、トークンの変更は凍結されたコピーにはもう届きません。ランタイムの cn() は依然として @tabula-css/merge とコピーされた registry.json を必要とします――それを tailwind-merge に置き換えると、描画される出力が変わってしまいます。全体の流れ、対処される4つのハザード、そして正確なコマンド体系については イジェクト を参照してください。