# フロントエンドのアーキテクチャ

Oleafly が、デスクトップアプリをフロントエンドのワークスペース、Rust のサービス、固定されたサイドカーにどのように分けているかを説明します。

Oleafly のフロントエンドは pnpm ワークスペースです。アプリシェルはリポジトリのルート（`src/`）にあり、再利用できるフロントエンドのコードは、役割ごとに分かれた 12 個のパッケージとして `packages/*` にあります。Tauri のバックエンドは `src-tauri/` にあり、再利用できる AI プロバイダとエージェントのコードは `crates/oleafly-agent/` にあります。フロントエンド、Rust、サイドカーの全体像は [README のアーキテクチャ図](https://github.com/Oleafly/Oleafly/blob/main/README.md#architecture)を、日常的なコマンドは[開発](/docs/engineering/development/)を参照してください。

このドキュメントでは、各パッケージの役割、パッケージを純粋に保つ依存性逆転のルール、機能をシェルに接続するコントリビューションレジストリ、モジュール解決のしくみ、パッケージの追加と切り出しの方法を説明します。

## ワークスペースにする理由

以前は、どの機能もアプリの Zustand ストア、Tauri の IPC クライアント、shadcn の UI キットに直接アクセスしていました。そのため機能をシェルから切り離せず、単独でテストしたり、再利用したり、理解したりすることができませんでした。ワークスペースは、この関係を逆転させます。パッケージは機能のロジックを持ち、必要なものすべてについて、ポートと呼ぶ狭いインターフェースを宣言します。アプリは、自身のストアと Tauri クライアントの上にそのポートを実装し、パッケージに注入します。アプリがパッケージに依存し、その逆は決してありません。

パッケージは**TypeScript のソースのまま**使われます（`main` は `src/index.ts` を指します）。ビルド手順も、公開も、バージョンのずれもありません。Vite、Vitest、`tsc` はいずれも、エイリアスを通じて `@oleafly/*` をパッケージのソースへ直接解決します（[モジュール解決](#module-resolution)を参照）。

## パッケージ一覧

| パッケージ | 内容 | 注入されるポート | 主な依存関係 |
| --- | --- | --- | --- |
| `@oleafly/latex` | 純粋な LaTeX と図のロジック：ダイアグラムのモデル、TikZ のシリアライザ（`modelToTikz`、埋め込みモデルの往復変換）、スタンドアロン文書のビルダー（`buildStandaloneDoc`）、図の名前とバイト数のヘルパー | なし（純粋） | なし |
| `@oleafly/ai-core` | プロバイダの表示用メタデータと、フロントエンドのモデル選択。Rust のカタログと同じ内容に保たれています | なし（純粋） | なし |
| `@oleafly/latex-intelligence` | エディタのインテリジェンスが使う LaTeX コーパスのための、検証済みの型とデータアクセス | なし（純粋） | なし |
| `@oleafly/registry` | コントリビューションレジストリ：レールのタブ、パレットとオムニバーのコマンド、AI ツールセット（[詳細は後述](#the-contribution-registry)） | なし（コントリビューションが自分の動作を持ちます） | react（型） |
| `@oleafly/preflight` | プリフライトエンジン：文書の種類の判定、ソース、PDF、参照のルール、ATS の解析、アクセシブルなエクスポートの準備、スコアリング。`pdf-extract` は `@oleafly/preflight/pdf-extract` サブパスとして提供され、pdf.js が Node のテストの依存グラフに入らないようにしています | なし（純粋） | `pdfjs-dist`（サブパスのみ） |
| `@oleafly/pdf-to-latex` | ローカルで動く、PDF から LaTeX への復元パイプライン：行のグルーピング、見出しと段組みの検出、スタイルの推定、記号の対応付け、図の抽出、エスケープ、文書の組み立て。pdf.js のアダプターは、同じく Node のテストの依存グラフに入れないために、`@oleafly/pdf-to-latex/pdf-adapter` サブパスとして提供されます | なし（純粋） | `pdfjs-dist`（サブパスのみ） |
| `@oleafly/wysiwyg` | 機能を絞ったビジュアルエディタのコア：共通のスキーマ、LaTeX と Markdown のパースとシリアライズ、未対応のソースを破棄せずに見える状態で残す生のインラインノードとブロックノード | なし（純粋） | なし |
| `@oleafly/editor` | LaTeX、Typst、Markdown 向けの CodeMirror のコア：`CodeMirrorEditor`、エディタビューのコントローラー（`insertAtCursor`、`gotoLine`、…）、言語と補完、テーマ、折りたたみ、固定される見出し、リンター、latex-mask、数式プレビュー（LaTeX は KaTeX、Typst は Typst）、Typst の入力補助と LaTeX から Typst への数式変換、LaTeX と Typst でその場で切り替わるビジュアルモード（`@oleafly/editor/visual`）、検索パネル、スペルと文法のリンター | `EditorHost`（文書モデルと設定。フック形式）、`SpellHost`（スペルチェッカー、Harper、辞書）、`setBibKeysProvider`、`extraExtensions`/`extraKeymap` | `@codemirror/*`、`@replit/codemirror-vim`、`@replit/codemirror-emacs`、`katex` |
| `@oleafly/preview` | 仮想化された pdf.js ビューア（`PdfViewer`）、SyncTeX のページコントローラー（`gotoRect`、`pageClickToBp`）、pdf.js ワーカー、WebView のポリフィル（`@oleafly/preview/polyfills`。`main.tsx` で最初にインポートされます） | `onOpenLink` プロパティ（システムブラウザーでリンクを開く）、`setPdfLogger` | `pdfjs-dist` |
| `@oleafly/diagram` | TikZ、Typst（fletcher）、Mermaid 向けのビジュアルなダイアグラムコンポーザー：React Flow のキャンバス、図形インスペクタ、描画とコードのタブ、コンパイル、プレビュー、挿入の流れ。`src/languages/` の言語ごとに、ライター、リーダー、挿入用とファイル用の形式があり、キャンバスとコードは双方向で同期されます | `DiagramHost`（LaTeX のコンパイル、Typst と Mermaid のレンダリング、ファイル入出力、エディタへの挿入、AI による修正）、`DiagramKit` コンテキスト（Button/Tooltip/Select/toast/theme） | `@xyflow/react`、`@codemirror/*`、`@oleafly/latex`、`@oleafly/editor` |
| `@oleafly/ai-tools` | プロジェクト、リサーチ、メモリ、図のツール定義。承認を考慮した変更を伴います | `AiToolsHost`（ファイル、コンパイル、シンボル索引、図のパイプライン、エディタ） | `@oleafly/latex` |
| `@oleafly/templates` | 新規プロジェクトのテンプレートギャラリー（2 ステップのウィザード、カテゴリ、プレビュー、進捗付きの 1 回限りのアセットダウンロード） | `TemplatesHost`（プレビュー、アセットのダウンロード、ログ）、`TemplatesKit`（Button/Tooltip）、色のオプションはプロパティで渡します | UI ユーティリティ以外はなし |

依存の向きは、厳格に強制されています。

パッケージは、`src/`（`@/…`）、Zustand のストア、`@tauri-apps/*`、アプリの UI コンポーネントから、決してインポートしてはいけません。この純粋性のチェックは、パッケージを切り出すときの衛生管理の一部です。

```bash
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src   # must return nothing
```

## ポートのパターン

パッケージはインターフェースを宣言します。アプリはそれを 1 回だけ、通常はストアをクロージャで参照するモジュールレベルのアダプターオブジェクトとして実装します。繰り返し現れる形が 4 つあります。

**ホストポート**は、パッケージごとに 1 つある `interface XHost` で、そのパッケージが必要とするサービスをまとめたものです。アプリは、ストアの命令的な読み取りでアダプターを作ります。

```ts
// src/lib/ai-tools.ts (app adapter, abridged)
const HOST: AiToolsHost = {
  getProjectId: () => useFilesStore.getState().projectId,
  readFileContent,                       // Tauri client passthrough
  recompile: () => useCompileStore.getState().recompile(),
  insertAtCursor,                        // editor controller
  // ...
};
export const createOleaflyTools = (opts?) => createOleaflyToolsCore(HOST, opts);
```

アプリ専用の関心事をアダプターの内側に置くことで、パッケージに依存関係が入り込むことも防げます。プロバイダの認証情報とリクエストは Rust 側に残るため、フロントエンドのパッケージが受け取るのは、インターフェースが必要とする状態と結果だけです。

**UI キット**は、構造的に型付けされたコンポーネントを渡す React のコンテキスト（またはプロパティ）で、たとえば `Button: ComponentType<{ variant?: …; onClick?: () => void }>` のようなものです。より小さな構造的な型は互換性があるため、アプリは shadcn のコンポーネントをそのまま渡せます。キットには `toast` と `useThemeMode()` フックも含まれます。

**フック形式のポート**は、アプリの状態を*購読*しなければならないパッケージのためのものです。エディタの `EditorHost` には、フックのメンバー（`useActivePath()`、`useSettings()`）と、イベントリスナー用の命令的な対応物（`getActivePath()`）があります。ルールは次のとおりです。ホストオブジェクトはモジュールレベルでなければならず（同一性が安定しているため）、その `use*` メンバーは、フックを無条件に呼び出さなければなりません。

**モジュールのシングルトン**は、横断的なつなぎ込みのためのものです。`setPdfLogger(fn)`、`setSpellHost(host)`、`setBibKeysProvider(fn)` は、何もしないデフォルトを持ち、アプリのシムがインポートの副作用としてインストールします。

### シム

切り出しのたびに、古い `@/…` のパスにパッケージを再エクスポート（またはラップ）する薄いファイルを残したので、利用側や既存の `vi.mock(...)` のパスは変わりませんでした。例：`src/components/editor/cm/controller.ts`（再エクスポート）、`src/components/pdf/PdfViewer.tsx`（シェルプラグインのリンクオープナーを注入するラッパー）、`src/lib/ai-tools.ts`（アダプターと元のファクトリのシグネチャ）。新しいコードは `@oleafly/*` を直接インポートしてかまいません。シムは互換性のために存在するもので、推奨される経路ではありません。

## コントリビューションレジストリ

`@oleafly/registry` は、シェルが個々の機能を知らなくても、機能をアプリのシェルに接続するしくみです。コントリビューションには 3 つの種類があります。

- **レールのタブ**（`registerRailTab`）：アイコン、セクション（`explore` / `review` / `assist`。空でないセクションの間に区切り線が引かれます）、任意の `when(ctx)` による表示条件、任意の `useBadge()` フック（例：Git の変更数）、そしてサイドバーの `panel` コンポーネントです。
- **コマンド**（`registerCommand`）：`surfaces` によって、コマンドパレット（Cmd+K、`group` ごとにグループ化）かオムニバー（Cmd+P）、またはその両方に提供されます。ラベルとアイコンは、コンテキストの関数でもかまいません（例：「ライトテーマに切り替え」）。
- **AI ツールセット**（`registerAiToolset`）：チャットモード（`"chat"`、`"figure"`）をツールセットのファクトリに対応付けます。チャットパネルは、ツールセットを決め打ちするのではなく、現在のモードのツールセットを検索します。

`AppContext`（`{ projectId, projectKind, theme }`）は、描画を行う各画面が自分の購読から組み立てるため、`when` と `label` の条件はリアクティブなままです。

登録は**静的**です。`src/contributions/` が組み込みのものすべて（タブ、コマンド、ツールセット）を登録し、`main.tsx` がシェルをマウントする前に `registerContributions()` を 1 回呼び出します。`Rail`、`Sidebar`、`CommandPalette`、`SearchOmnibar`、`ChatPanel` は、登録されたものだけを描画します。

機能のタブやコマンドを追加するには、それを登録します。シェルの編集は要りません。

```ts
// src/contributions/my-feature.tsx (called from src/contributions/index.ts)
registerRailTab({
  id: "review", label: "Review", icon: MessageSquare,
  section: "review", order: 55,
  when: (ctx) => ctx.projectKind !== "image",
  panel: ReviewPanel,
});
registerCommand({
  id: "omnibar.review", surfaces: ["omnibar"], order: 45,
  label: "Open review", keywords: "review comments",
  run: () => useSettingsStore.getState().setRailTab("review"),
});
```

これは内部 API であり、プラグイン SDK ではありません。形は自由に変更される可能性があり、動的に読み込まれるものもありません。実際の利用者が現れるまで見送っているのは、エクスポートメニューの形式、設定のスキーマ、プロジェクト種別ごとの動作をコントリビューションとして扱うことです。

## モジュール解決

パッケージはソースのまま使われるため、同じエイリアスが 3 か所で宣言されており、パッケージを追加するときは 3 か所すべてを同期させておく必要があります。

1. `tsconfig.json` → `compilerOptions.paths`。`@oleafly/preflight/pdf-extract` のようなサブパスには、専用のエントリを明示的に追加する必要があります。
2. `vite.config.ts` → `resolve.alias`。文字列のエイリアスは前方一致するため、サブパスは別のエントリなしで解決されます。
3. `vitest.config.ts` → 同じ `resolve.alias`。さらに `test.include` が `packages/**/*.test.ts` を**含んでいる**必要があります。そうしないと、移動したテストが何も知らせずに実行から外れます。

このほか、ルートの `package.json` は各パッケージに `"workspace:*"` で依存しており、`src/styles/globals.css` には `@source "../../packages"` があるため、Tailwind v4 はクラス名を探してパッケージのソースをスキャンします。

Vite 専用のインポート（`?worker&url` のワーカー、CSS のインポート）は、エイリアスがソースを指しているため、パッケージのソースからでもそのまま動きます。pdf.js を引き込むものは、Node 環境のテストがインポートするバレルファイルに入れないでください。`pdf-extract` と preview パッケージのビューアが、DOM のある場所からしか到達できないのは、そのためです。

## パッケージで作業する

- **テスト**はソースの隣（`packages/*/src/*.test.ts`）にあり、アプリのテストと同じ `pnpm test` で実行されます。ファイルを移動するときは、テストの*件数*に注意してください。
- **依存関係**は緩く宣言します（`"react": "*"`）。実際のバージョンの下限があるルートに対して、pnpm が重複を排除します。パッケージがインポートしない依存関係は、宣言しないでください。
- **パッケージを変更するときのゲート**：`pnpm exec tsc --noEmit`、`pnpm build`、`pnpm test`（件数が変わらないこと）、そして上記の純粋性の grep です。
- **新しく切り出すとき**：`git mv` でファイルを移動し（履歴も引き継がれます）、ポートを定義し、古いパスにシムを残し、3 つのエイリアスを接続してから、ゲートを実行します。活発に作り込んでいる最中の機能は、切り出さないでください。
- **ライセンス**：すべてのパッケージは、アプリと同じ `AGPL-3.0-or-later` です。

## アプリに残るもの

`src/` には、フロントエンドアプリそのものであるものがすべて残ります。Zustand のストア、Tauri の IPC クライアント（`src/lib/tauri.ts`）、shadcn の UI キット、レイアウトとシェルのコンポーネント、ポートのアダプターとシム、`src/contributions/`、そして性質上ストアと結び付いている機能のつなぎ込み（プリフライトパネルの UI、インライン AI 編集プラグイン、`extraExtensions` としてエディタに注入されるコードインテリジェンスやホバーインテリジェンス）です。Tauri のコマンドとオペレーティングシステムとの連携は `src-tauri/` に残ります。プロバイダのクライアント、ストリーミング、エージェントのオーケストレーションは `crates/oleafly-agent/` に置きます。シークレットとプロバイダへのリクエストを、webview の外に置くためです。
