フロントエンドのアーキテクチャ
Oleafly が、デスクトップアプリをフロントエンドのワークスペース、Rust のサービス、固定されたサイドカーにどのように分けているかを説明します。
Oleafly のフロントエンドは pnpm ワークスペースです。アプリシェルはリポジトリのルート(src/)にあり、再利用できるフロントエンドのコードは、役割ごとに分かれた 12 個のパッケージとして packages/* にあります。Tauri のバックエンドは src-tauri/ にあり、再利用できる AI プロバイダとエージェントのコードは crates/oleafly-agent/ にあります。フロントエンド、Rust、サイドカーの全体像は README のアーキテクチャ図 (新しいタブで開きます)を、日常的なコマンドは開発を参照してください。
このドキュメントでは、各パッケージの役割、パッケージを純粋に保つ依存性逆転のルール、機能をシェルに接続するコントリビューションレジストリ、モジュール解決のしくみ、パッケージの追加と切り出しの方法を説明します。
ワークスペースにする理由
「ワークスペースにする理由」セクション以前は、どの機能もアプリの Zustand ストア、Tauri の IPC クライアント、shadcn の UI キットに直接アクセスしていました。そのため機能をシェルから切り離せず、単独でテストしたり、再利用したり、理解したりすることができませんでした。ワークスペースは、この関係を逆転させます。パッケージは機能のロジックを持ち、必要なものすべてについて、ポートと呼ぶ狭いインターフェースを宣言します。アプリは、自身のストアと Tauri クライアントの上にそのポートを実装し、パッケージに注入します。アプリがパッケージに依存し、その逆は決してありません。
パッケージはTypeScript のソースのまま使われます(main は src/index.ts を指します)。ビルド手順も、公開も、バージョンのずれもありません。Vite、Vitest、tsc はいずれも、エイリアスを通じて @oleafly/* をパッケージのソースへ直接解決します(モジュール解決を参照)。
パッケージ一覧
「パッケージ一覧」セクション| パッケージ | 内容 | 注入されるポート | 主な依存関係 |
|---|---|---|---|
@oleafly/latex | 純粋な LaTeX と図のロジック:ダイアグラムのモデル、TikZ のシリアライザ(modelToTikz、埋め込みモデルの往復変換)、スタンドアロン文書のビルダー(buildStandaloneDoc)、図の名前とバイト数のヘルパー | なし(純粋) | なし |
@oleafly/ai-core | プロバイダの表示用メタデータと、フロントエンドのモデル選択。Rust のカタログと同じ内容に保たれています | なし(純粋) | なし |
@oleafly/latex-intelligence | エディタのインテリジェンスが使う LaTeX コーパスのための、検証済みの型とデータアクセス | なし(純粋) | なし |
@oleafly/registry | コントリビューションレジストリ:レールのタブ、パレットとオムニバーのコマンド、AI ツールセット(詳細は後述) | なし(コントリビューションが自分の動作を持ちます) | 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 コンポーネントから、決してインポートしてはいけません。この純粋性のチェックは、パッケージを切り出すときの衛生管理の一部です。
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src # must return nothingポートのパターン
「ポートのパターン」セクションパッケージはインターフェースを宣言します。アプリはそれを 1 回だけ、通常はストアをクロージャで参照するモジュールレベルのアダプターオブジェクトとして実装します。繰り返し現れる形が 4 つあります。
ホストポートは、パッケージごとに 1 つある interface XHost で、そのパッケージが必要とするサービスをまとめたものです。アプリは、ストアの命令的な読み取りでアダプターを作ります。
// 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 は、登録されたものだけを描画します。
機能のタブやコマンドを追加するには、それを登録します。シェルの編集は要りません。
// 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 か所すべてを同期させておく必要があります。
tsconfig.json→compilerOptions.paths。@oleafly/preflight/pdf-extractのようなサブパスには、専用のエントリを明示的に追加する必要があります。vite.config.ts→resolve.alias。文字列のエイリアスは前方一致するため、サブパスは別のエントリなしで解決されます。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 の外に置くためです。