zpaper-draft

Type to search...

to open search from anywhere

zudotext のアーキテクチャ: Electron + React + monorepo の構成

概要

個人用のAI執筆ヘルパーアプリ「zudotext」をElectron + React + Vite + TypeScriptで作っている。設定ファイル駆動で、異なる設定を読み込ませると異なるアプリインスタンスが生成されるという構成。monorepo(pnpm workspace)で管理していて、共有パッケージが8つある。そのアーキテクチャのまとめ。

zudotext とは

zudotextは「テキスト生成アプリ・ウィズ・AI」のアプリケーション生成器みたいなもの。Electronアプリ自体がコンフィグ駆動で、設定ファイル(.zudotext.settings.json)を変えると、カラースキーム、エディタ設定、ターミナル設定、ショートカット、レイアウトなどが変わった別のアプリインスタンスができる。

アプリインスタンスを定義するのは以下の要素。

  • Settings(.zudotext.settings.json)— カラースキーム、エディタ設定、ターミナル設定、ショートカット、レイアウト
  • Pins — サイドバーに表示するコンテンツディレクトリの設定
  • Content directories — メッセージ作成用のワークスペースやドキュメント
  • Default workspace — ~/Documents/zudo-text/<appname>/に初回起動時に自動生成される

monorepo の構成

pnpm workspaceでmonorepo構成にしている。ディレクトリ構成は以下。

  • electron-app/ — スタンドアロンElectronアプリ本体。main processはCommonJS、rendererはReact + Vite + TypeScript
  • doc/ — Docusaurusドキュメントサイト。開発仕様のドキュメント置き場で、メッセージアーカイブのビューワーとしても使う
  • message-workspace/ — Claude Codeで執筆するための作業ディレクトリ。執筆ルールが入っている
  • packages/ — 8つの共有パッケージ

共有パッケージ

packages/以下に8つのパッケージがある。

  • @takazudo/app-defaults — 設定スキーマ、デフォルト値、バリデーション。main process向けのCJSとrenderer向けのESMの両方を提供する
  • @takazudo/app-scaffold — 開発・テスト用のアプリ環境ジェネレーター
  • @takazudo/code-block — マークダウンコードブロックのレンダリング。コピーボタン付き
  • @takazudo/color-themes — 8つのカラーテーマ定義とCSS変数生成。3層のCSS変数システムを持つ
  • @takazudo/command-palette — VS Code風のコマンドパレット。ファジーサーチ対応
  • @takazudo/file-utils — フロントマターのパース、安全なパス処理、ファイル名生成
  • @takazudo/shortcut-engine — キーボードショートカットエンジン。コードショートカット(Mod+B → S)対応
  • @takazudo/css-playground — CSS開発ツール

monorepoにしている理由はシンプルで、Electronのmain processとrendererで共通のロジック(設定バリデーションなど)を使いたいから。@takazudo/app-defaultsがその代表で、main processからはCJSとして、rendererからはESMとして同じモジュールを読み込める。

Electron のアーキテクチャ

Electronアプリは3つのレイヤーで構成されている。

main process

main.jssrc/以下がmain processのコード。CommonJSで書かれている。役割は以下。

  • ウィンドウ管理
  • IPCハンドラー
  • PTYマネージャー(node-ptyでターミナルセッションを管理)
  • 設定ファイルの読み書き

preload bridge

preload.jscontextBridge経由で安全なwindow.apiを公開する。rendererが直接Node.js APIにアクセスできないように、必要なAPIだけをブリッジする構成。

IPC namespaces

main processとrenderer processの通信はIPCで行う。名前空間が分かれていて、以下のようなチャンネルがある。

  • messages — メッセージの読み書き
  • pins — ピン留めコンテンツの操作
  • terminal — ターミナルセッションの入出力
  • draft — 下書き管理
  • tabs — タブ操作
  • rules — 執筆ルールの取得
  • settings — 設定の読み書き
  • workspace — ワークスペース管理
  • findInPage — ページ内検索
  • vimrc — Vimrc設定

IPCチャンネルを名前空間で整理しておくと、handler側のコードがどの責務に属するかが明確になる。

renderer のアーキテクチャ

rendererはReact + Vite + TypeScriptで構成されている。

ルーティング

HashRouterで以下のページを持っている。

  • WritePage — メッセージの編集画面。メインの画面
  • ArchivesPage — 過去のメッセージ一覧
  • SearchPage — メッセージ検索
  • PinsPage — ピン留めコンテンツの閲覧

主なコンポーネント

  • TerminalPane — xterm.jsベースのターミナル。node-ptyとIPC経由で接続する
  • EditorPane — CodeMirror 6ベースのエディタ。Vimモード対応
  • PreviewPane — マークダウンのプレビュー表示
  • TabBar — タブバー
  • PanelDivider — パネル間のリサイズハンドル
  • ChordIndicator — コードショートカットの入力状態表示
  • CommandPalette — VS Code風のコマンドパレット

設定はReact Contextで管理していて、デザイントークンはCSS custom propertiesとして定義されている。

3層カラーシステム

カラーテーマの設計が特徴的で、3層のCSS変数システムになっている。

Tier 1: Palette(--palette-*

テーマから供給される生のカラー値。ターミナルの16色規約に従った命名。

Tier 2: Theme(--theme-*

セマンティックな役割を持つ変数。--theme-bg-primary--theme-text-primary--theme-accentなど。Tier 1の値を参照する。

Tier 3: Component(--[name]-*

コンポーネントスコープの変数。--settings-bg--archives-accentなど。Tier 2の値を参照する。

テーマ切り替えはapplyTheme()という関数で行う。Tier 1の値をセットすると、Tier 2が自動的に更新され、Tier 3も連鎖的に更新される。CSSカスタムプロパティの参照関係だけでカスケードするので、JSで個別に更新する必要がない。

テーマは8種類用意している。Catppuccin Mocha(デフォルト)、Tokyo Night、Dracula、Nord、Solarized、One Dark、Gruvbox、あと1つ。

この3層構造にした理由は、テーマの追加とコンポーネントの追加を独立して行えるようにするため。新しいテーマを追加するときはTier 1だけ定義すれば良い。新しいコンポーネントを追加するときはTier 2を参照するTier 3を定義すれば良い。テーマとコンポーネントが直接結合しないので、組み合わせが爆発しない。

キーボードショートカットエンジン

@takazudo/shortcut-engineはキーボードショートカットの処理を担当するパッケージ。

単発ショートカット

Mod+Eのような通常のショートカット。ModはmacOSではCmd、Windows/LinuxではCtrlに変換される。

コードショートカット

Mod+B → Sのような2ストロークのショートカット。最初のキーを押してから1.5秒以内に次のキーを押すと発動する。

コードショートカットの状態管理にはChordStateというオブジェクトを使っていて、「今コードの1ストローク目が入力された状態か」をトラッキングしている。UIにはChordIndicatorコンポーネントがあり、1ストローク目を入力すると「次のキーを待っています」みたいな表示が出る。

テスト戦略

テストは以下の方針で書いている。

  • ユニットテスト(Vitest)— 純粋関数とIPCヘルパーのテスト
  • E2Eテスト(Playwright)— Electronアプリ全体を起動してユーザーフローをテスト
  • Reactコンポーネントのユニットテストは書いていない

Reactコンポーネントのユニットテストを書いていないのは、コンポーネントがIPCに強く依存しているため。モックの量が多くなりすぎて、テストの意味が薄くなる。E2Eで実際のアプリを起動して操作するほうが、実際の動作を検証できる。

設定バリデーション

@takazudo/app-defaultsが設定のバリデーションとマイグレーションを担当する。

設定ファイルのスキーマが変わったとき(フィールドの追加・削除・型変更)、古い設定ファイルを読み込んでも壊れないようにする必要がある。app-defaultsは以下のことをやっている。

  • 欠けているフィールドをデフォルト値で埋める
  • 範囲外の値をクランプする(フォントサイズが0以下なら14に戻す、など)
  • スキーマ変更時のマイグレーション

このパッケージがCJSとESMの両方を提供しているのは前述の通り。main processがCJSで動いているので、ESMだけだとmain processから読めない。かといってrenderer(Vite)はESMで動いているので、CJSだけだとツリーシェイキングが効かない。両方提供するのが現実的な解。

余談

monorepoでElectronアプリを作ると、「main processはCJS、rendererはESM」というモジュール形式の違いが地味に面倒になる。shared packagesを両形式で提供するか、main process側もESMに寄せるかという選択肢があるが、Electronのmain processでESMを使うとrequireが使えなくなってnode_modulesとの互換性で問題が出ることがあるので、今のところCJS + ESMのデュアルパッケージで対応している。

あと、カラーテーマの3層構造は最初からこうだったわけではなく、テーマを3つ目、4つ目と追加していく中で「コンポーネントごとにテーマの色を直接指定していると、テーマ追加のたびに全コンポーネントを修正しなければならない」という問題に直面して、セマンティックレイヤー(Tier 2)を挟む設計に変更した。こういうのはある程度実装が進んでから見えてくるものなので、最初から完璧な設計を目指すより、リファクタリングの余地を残しておくほうが良さそう。