Read OSS

ASTレベルのコード変換:shadcn/ui がコンポーネントをプロジェクトに適合させる仕組み

上級

前提知識

  • 第1回:アーキテクチャ概要
  • 第2回:レジストリシステムと依存関係の解決
  • AST(抽象構文木)操作の基本的な理解
  • PostCSS と Tailwind CSS の基礎知識

ASTレベルのコード変換:shadcn/ui がコンポーネントをプロジェクトに適合させる仕組み

第2回で解説したレジストリプロトコルは、コンポーネントのソースコードをそのままの形で届けます。しかしそのコードは、正規の import パス(@/registry/new-york/ui/...)、汎用的なアイコンのプレースホルダー、抽象的な cn-* CSS クラスを使って書かれています。プロジェクトへの配置前に、このコードは多段階のトランスフォーマーパイプラインを通過し、あなたの設定に合わせて AST レベルで書き換えられます。本記事では、各トランスフォーマーの詳細、抽象的な CSS と具体的なユーティリティを橋渡しするスタイルマップシステム、ファイルへの書き出しを担うサブシステムを順に解説します。

トランスフォーマーパイプラインのアーキテクチャ

パイプライン全体は packages/shadcn/src/utils/transformers/index.tstransform 関数が統括しています。この関数は生のソースコードとトランスフォーマー関数の配列を受け取ります。

export async function transform(
  opts: TransformOpts,
  transformers: Transformer[] = [
    transformImport,
    transformRsc,
    transformCssVars,
    transformTwPrefixes,
    transformRtl,
    transformIcons,
    transformCleanup,
  ]
) {

各トランスフォーマーは ts-morph の SourceFile を受け取り、変更した上で返す関数です。同じ SourceFile インスタンスを共有しながら順番に実行されます。実際のインストール処理を担う update-files.ts では、さらにいくつかのトランスフォーマーを加えた拡張パイプラインが使われます。

sequenceDiagram
    participant Raw as Raw Source
    participant TS as ts-morph Project
    participant T1 as transformImport
    participant T2 as transformRsc
    participant T3 as transformCssVars
    participant T4 as transformTwPrefixes
    participant T5 as transformIcons
    participant T6 as transformMenu
    participant T7 as transformAsChild
    participant T8 as transformRtl
    participant T9 as transformFont
    participant T10 as transformCleanup
    participant JSX as transformJsx (optional)

    Raw->>TS: Create SourceFile
    TS->>T1: Rewrite imports
    T1->>T2: Add/remove "use client"
    T2->>T3: CSS variable transforms
    T3->>T4: Tailwind prefix
    T4->>T5: Icon library swap
    T5->>T6: Menu transforms
    T6->>T7: asChild patterns
    T7->>T8: RTL transforms
    T8->>T9: Font transforms
    T9->>T10: Remove unused imports
    T10->>JSX: Strip TypeScript (if tsx: false)

パイプラインはまず project.createSourceFile(tempFile, opts.raw, { scriptKind: ScriptKind.TSX }) で一時的な SourceFile を生成します。ScriptKind.TSX を指定することで、実際のファイル拡張子に関わらず、TypeScript と JSX の両方の構文をパーサーが正しく処理できるようになります。

Import の書き換え

transformImport は、レジストリの正規フォーマットで書かれた import パスを、プロジェクトのエイリアス設定に合わせて書き換えます。正規フォーマットでは @/registry/<style>/ui/@/registry/<style>/lib/ といったパターンが使われます。

updateImportAliases 関数は各パターンを次のようにマッピングします。

レジストリのパターン 設定キー 変換例
@/registry/*/ui/ aliases.ui @/components/ui/
@/registry/*/components/ aliases.components @/components/
@/registry/*/lib/ aliases.lib @/lib/
@/registry/*/hooks/ aliases.hooks @/hooks/

cn ユーティリティの import には特別な処理が入ります。import パスが @/lib/utils に解決され、かつ cn の名前付き import が含まれている場合、パスはユーザーが設定した aliases.utils の値に書き換えられます(29〜47行目)。これにより、プロジェクト構造に関わらず、あらゆる場所で使われる cn() 呼び出しが常に正しく解決されます。

flowchart TD
    A["import path starts with @/registry/"] --> B{Matches /ui/?}
    B -->|Yes| C["Replace with aliases.ui"]
    B -->|No| D{Matches /lib/?}
    D -->|Yes| E["Replace with aliases.lib"]
    D -->|No| F{Matches /hooks/?}
    F -->|Yes| G["Replace with aliases.hooks"]
    F -->|No| H["Replace with aliases.components"]
    I["import from @/lib/utils with cn"] --> J["Replace with aliases.utils"]

RSC ディレクティブとアイコンの変換

transformRsc はシンプルな作りになっています。config.rsctrue の場合は何もせず、コンポーネントの "use client" ディレクティブをそのまま残します。false の場合は、/^["']use client["']$/ にマッチする最初の ExpressionStatement を探して first.remove() を呼び出し、ディレクティブを削除します。

transformIcons はもう少し複雑です。コンポーネントは各アイコンライブラリ固有の props を持つ <IconPlaceholder> 要素を使って書かれています。

<IconPlaceholder lucide="ChevronDown" tabler="IconChevronDown" />

このトランスフォーマーはすべての JsxSelfClosingElement ノードを走査し、IconPlaceholder タグを探します。対象のライブラリ(config.iconLibrary で指定)に対応するアイコン名を取り出し、他のライブラリ固有の props を削除してタグ名を置換し、適切な import を追加します。元の IconPlaceholder の import も合わせて削除されます。この仕組みにより、1つのソースファイルでコードを重複させることなく Lucide、Tabler、Phosphor、Hugeicons の4ライブラリに対応できます。

ヒント: カスタムレジストリ向けにコンポーネントを書く際は、対応したいアイコンライブラリの props をそれぞれ指定した <IconPlaceholder> を使いましょう。CLI がユーザーの iconLibrary 設定に基づいて、適切なアイコンに自動的に差し替えてくれます。

スタイルマップシステム:cn-* クラスから Tailwind ユーティリティへ

トランスフォーマーシステムの中で最も設計が興味深い部分です。コンポーネントは cn-buttoncn-accordion-triggercn-alert-variant-destructive といった抽象的な CSS クラス名を使って書かれています。これらの cn-* クラス自体にスタイルは定義されておらず、インストール時に選択されたスタイルに応じて、具体的な Tailwind ユーティリティクラスへと解決されます。

createStyleMap 関数は、スタイル用の CSS ファイルを PostCSS でパースしてマッピングを構築します。

/* From style-nova.css */
.cn-accordion-trigger {
  @apply focus-visible:ring-ring/50 rounded-lg py-2.5 text-sm font-medium;
}

PostCSS がすべてのルールを走査して @apply ディレクティブを抽出します。cn-* クラスを含むセレクターごとに、クラス名と適用される Tailwind ユーティリティのマッピングが作られます。最終的な結果は次のようなプレーンオブジェクトです。

{
  "cn-accordion-trigger": "focus-visible:ring-ring/50 rounded-lg py-2.5 text-sm font-medium",
  "cn-alert-variant-destructive": "text-destructive bg-card"
}
flowchart TD
    A["style-nova.css"] --> B["PostCSS parse"]
    B --> C["Walk rules"]
    C --> D["Find cn-* selectors"]
    D --> E["Extract @apply values"]
    E --> F["Build StyleMap: cn-class → Tailwind utilities"]
    F --> G["transformStyleMap"]
    G --> H["ts-morph AST walker"]
    H --> I["Find cn-* in cva() and className"]
    I --> J["Replace with Tailwind via tailwind-merge"]

transformStyleMap 関数は TypeScript の AST を走査し、次の3箇所で cn-* クラスを探します。

  1. cva() の呼び出し:ベース文字列とバリアントオブジェクトの両方
  2. className JSX 属性:ネストされた cn() 呼び出しを含む
  3. mergeProps() の呼び出し:オブジェクト引数内の className プロパティ

cn-* クラスが見つかると、tailwind-mergetwMerge を使って解決済みの Tailwind ユーティリティに置き換えられます。これにより、競合するユーティリティ(例:py-2 py-4py-4)が適切に解決されます。

19行目 に定義された許可リストには、スタイルコンテナとしてではなく CSS セレクターとして機能する cn-* クラスが登録されており、変換の対象から除外されます。cn-menu-targetcn-menu-translucentcn-logical-sidescn-rtl-flipcn-font-heading がこれに該当し、他のトランスフォーマーやランタイム CSS から参照されます。

ファイルパスの解決と書き出し

updateFiles 関数は変換済みのソースコードをディスクに書き出す最後の工程を担います。各ファイルの書き出し先は resolveFilePath 関数がファイルの種類に基づいて決定します。

flowchart TD
    A["resolveFilePath(file)"] --> B{Has custom --path?}
    B -->|Yes| C["Use custom path"]
    B -->|No| D{Has file.target?}
    D -->|Yes| E["Resolve target with src/ handling"]
    D -->|No| F{file.type?}
    F -->|registry:ui| G["config.resolvedPaths.ui"]
    F -->|registry:lib| H["config.resolvedPaths.lib"]
    F -->|registry:hook| I["config.resolvedPaths.hooks"]
    F -->|registry:block| J["config.resolvedPaths.components"]
    F -->|default| J

この関数はいくつかのエッジケースにも対応しています。

  • .env ファイル:既存の環境変数を保持するため、上書きではなく既存の内容にマージされます
  • 差分チェック:ワークスペース内の import の違いを無視して内容が同一のファイルはスキップされます
  • 上書き確認:既存ファイルがある場合は --overwrite フラグがない限りインタラクティブな確認が入ります
  • TypeScript から JavaScript への変換tsx: false の場合、.tsx.jsx.ts.js という拡張子の変換がここで行われます
  • Next.js 16 の middlewaremiddleware.ts という名前のファイルは、Next.js 16 以降との互換性のために proxy.ts にリネームされます

ヒント: registry:fileregistry:item の type はすべてのトランスフォーマーをスキップし、内容をそのまま書き出します。import やスタイルを書き換えるべきでないフレームワーク非依存のファイルには、これらの type を使いましょう。

TypeScript から JavaScript への変換

プロジェクトで tsx: false が設定されている場合、他のすべてのトランスフォーマーが終わった後に、オプションの transformJsx パスが実行されます。Babel の @babel/plugin-transform-typescript を使って型アノテーションを取り除き、ランタイムコードはそのまま保持します。この処理を最後に置くことで、それ以前のすべてのトランスフォーマーは TypeScript の構文だけを想定して実装できます。各トランスフォーマーが TS と JS の両方の構文を扱う必要がなくなるわけです。

これは意図的なトレードオフです。Babel パスによってビルド時間は若干増えますが、各トランスフォーマーが理解すべき構文が1つに統一されます。もし JavaScript の構文にも対応しなければならないとしたら、すべての正規表現パターンと AST ウォーカーに2つのコードパスが必要になってしまいます。

次回予告

ここまでで、ソースコードが正規の形からプロジェクト固有の形へと変換される流れを確認しました。では、その「正規の形」はどのように生成されるのでしょうか。第4回では apps/v4 ワークスペースで動くビルドパイプラインを追います。Radix と Base UI という2つのベースライブラリで書かれたコンポーネントが6つのビジュアルスタイルと組み合わさり、ui.shadcn.com/r/ の静的 JSON API として出力されるまでの仕組みを解説します。