sekien 実装モデルの変遷
モノリス → workspace 分割 → 外部バイナリ依存 → lib + bin 化 → gazu との共進化で API 半減。pandoc 対応は sekien 内のヒューリスティックから始まり、5 つの構成を経て別バイナリに完全分離された。
(1) モノリス構成 — 2026-04-17〜19
最初の実装 (2a14a10) は mmsvg という名前で、winit = "0.30" + wry = "0.44" で Mermaid を SVG に変換する単一バイナリだった。この時点では pandoc フィルタモードは明示的な --pandoc-filter フラグで切り替える設計で、内部的には EventLoop::new() → event_loop.run(move |event, evl| { ... }) という、一度開始したら evl.exit() を呼ぶまで戻ってこないイベントループの上に乗っていた。
同じ日のうちに ca01e8d で、フラグなしでも pandoc フィルタモードを自動判定するヒューリスティックが入った。pandoc は外部フィルタを <binary> <output-format> という形 (出力フォーマット名を単一引数として渡す) で呼び出すため、それを逆手に取って「引数が 1 つで、/ も . も含まず - で始まらない」という条件で pandoc 呼び出しと判定している:
// pandoc は filter を `<binary> <output-format>` で呼び出す。// 引数が 1 つでフラグでもファイルパスでもなければ pandoc filter モードと判定する。fn is_pandoc_filter(args: &[String]) -> bool { if args.iter().any(|a| a == "--pandoc-filter") { return true; } args.len() == 1 && !args[0].starts_with('-') && !args[0].contains('/') && !args[0].contains('.')}
そして翌日 43cdb8d で --pandoc-filter フラグそのものを削除し、このヒューリスティックのみが pandoc フィルタモードの入口になった。
良かった点: ユーザーは 1 バイナリを cargo install するだけで、CLI としても pandoc フィルタとしても使える。配布・インストールが最もシンプル。
悪かった点: 「Mermaid → SVG変換」という核の責務と「pandoc フィルタプロトコルへの適合」という別の責務が、is_pandoc_filter という auto-detect ヒューリスティックを介して 1 つのエントリポイントに混在していた。判定ロジック自体が「引数が 1 個でドット・スラッシュを含まない」という ad-hoc な条件で、pandoc 以外の呼び出し規約を持つツールと組み合わせたときに誤判定するリスクを抱えていた。機能を追加するたびにこの分岐に手を入れる必要が出てくることが見えていた。
(2) spawn 型 API 構成 — 2026-05-12〜05-29
9373932 (2026-05-12、”refactor: split into library, cli, and pandoc filter”) で cargo workspace 化し、3 クレートに分割した: sekien (wry/tao レンダラ本体)、sekien-cli (スタンドアロン CLI)、sekien-pandoc (pandoc フィルタ)。
ここで一つ補足しておきたい事実がある。その 3 日前の d92c48b (2026-05-09、”fix: support headless rendering on Linux Wayland/X11 by migrating to tao”) で winit から tao への移行が済んでおり、tao は run_return() (イベントループが process::exit せずに呼び出し元へ戻ってくる API) を提供していた。つまり「終了せずに戻ってくる」手段は (2) が始まる時点で既に依存ツリーの中に存在していた。しかし 9373932 の sekien/src/lib.rs を見ると、レンダラのコア部分は (1) から変わらず event_loop.run() + process::exit() のままだった:
//! sekien//! |-- render_all//! | |-- event_loop.run(...)//! | |-- on success -> process::exit(0)//! | |-- on error -> process::exit(1)/// 成功時は `on_complete` を呼んだ後 `process::exit(0)` で終了する。/// エラー時は `eprintln!` でメッセージを出力して `process::exit(1)` で終了する。
つまり (2) が始まった段階では、「run_return() を使って lib として戻ってこられるようにする」という選択肢は技術的には既に手が届く位置にあったが、まだ採用されていなかった。
2026-05-12〜23 の間は Linux ヘッドレス対応を中心とした細かい改善が続く (GTK3 ヘッドレス対応 a29c365、内部 Xvfb 管理 14cd9eb、バイナリストリーミング対応 8ac0af4 など)。そして d65d6a6 (2026-05-23、”feat: complete overhaul to streaming architecture with 3-generation process model”) で大きな再構成が入る。3 クレート workspace は単一クレートのルート構成 (src/render.rs + src/main.rs = スタンドアロン sekien バイナリ) に戻り、新たに src/api/rust/ (sekien-api) が追加された。これが「spawn 型 API 構成」の実体である。
sekien-api は Command::spawn で sekien バイナリを子プロセスとして起動し、\0 区切りの stdin/stdout/stderr プロトコルを隠蔽する ergonomic wrapper だった。公開 API はこの形 (6c1f624 時点):
pub struct RenderConfig { pub font_family: Option<String>, pub theme: Option<String>, pub look: Option<String>,}pub fn render_blocks( sekien: impl AsRef<OsStr>, blocks: Vec<String>, config: &RenderConfig,) -> Result<Vec<BlockOutcome>, SekienApiError> { if blocks.is_empty() { return Ok(vec![]); } let sekien = sekien.as_ref(); let mut child = build_command(sekien, config) .spawn() .map_err(|source| SekienApiError::Spawn { path: sekien.into(), source })?; { let mut stdin = child.stdin.take().expect("piped stdin"); blocks.iter().try_for_each(|block| { stdin.write_all(block.as_bytes())?; stdin.write_all(&[0]) })?; } let output = child.wait_with_output()?; // ... stdout/stderr を \0 区切り・XMLコメントでparseしてBlockOutcomeを再構成}
呼び出し前には SEKIEN_FONT / SEKIEN_THEME / SEKIEN_LOOK という env hygiene 用の定数リスト (SEKIEN_OWNED_ENV_VARS) を使って、呼び出し元プロセスの環境変数を env_remove してから spawn する設計も入っていた。sekien-api 自身の DESIGN.md には、この層の位置づけが明記されている:
Rust 以外の言語からは sekien の stdin/stdout protocol を直接叩けば良いため、sekien-api は Rust 利用者のための補助 という位置付け。
つまり sekien-api は「exit() するレンダラを lib として使うための回避策」ではなく、「プロトコル定義の source of truth は sekien バイナリ側に置いたまま、Rust から呼ぶ手間を減らす薄い wrapper」という設計意図だった。実際、d65d6a6 以降も src/render.rs のレンダラ本体は event_loop.run() + process::exit() のままで (run_return() への切り替えは (4) まで起きない)、sekien-api はその外側を Command::spawn で覆っているに過ぎない。
良かった点: 「変換ロジック (CLI)」「Rust から呼ぶための糊 (API)」「pandoc フィルタとしての振る舞い」がそれぞれ専用クレート/リポジトリに分かれ、関心の分離は一見綺麗に見えた。プロトコルの source of truth を sekien バイナリ 1 つに保てるという理屈も立っていた。
悪かった点: render_blocks は呼び出しごとに spawn → wait_with_output する one-shot 設計のため、WebView/Xvfb のウォームな状態をアプリの生存期間全体で使い回すには、アプリ側で sekien の子プロセスのライフサイクル (起動・監視・終了処理) を自前管理する必要があり、結合度の高さが気になった。さらに、レンダラ本体が process::exit() で終了する設計を変えていない以上、「sekien-api を lib として in-process に呼ぶ」という選択肢はそもそも存在せず、Command::spawn 越しに使うことが構造的に強制されていた。run_return() という代替手段が依存ツリーには既に存在していたにもかかわらず、それを使う必要性が当時はまだ認識されていなかった、というのが実情に近い。
(3) 外部バイナリ依存構成 — 2026-05-29〜2026-06-12
78704c9 (2026-05-29、”refactor: remove all pandoc-related tests and logic from core project”) で src/api/rust/ (sekien-api、649 行) と pandoc 関連のテストを丸ごと削除し、本リポジトリを sekien (スタンドアロン CLI) 単体に戻した。sekien-pandoc は別リポジトリのまま存続し、sekien-api を経由せず sekien バイナリを PATH 経由で直接呼び出し、stdin/stdout の \0 区切りプロトコルを自分で叩く形に変更された。sekien-api の DESIGN.md が言っていた「プロトコルの source of truth は sekien バイナリ側」という前提に立てば、sekien-pandoc がそれを直接叩くのは筋が通っており、独自 API レイヤーを維持・バージョン管理するコストをなくせる、というのが当時の判断だった。
この commit では同時に、src/main.rs に Linux 向けの自己再起動ロジックが追加されている。Wayland セッションや $DISPLAY 未設定の環境では、自分自身を xvfb-run 経由で再実行する:
{ let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); let no_display = std::env::var("DISPLAY").is_err(); let already_in_xvfb = std::env::var("SEKIEN_XVFBRUN").is_ok(); if (is_wayland || no_display) && !already_in_xvfb { let status = std::process::Command::new("xvfb-run") .arg("-a") .arg("-s") .arg("-screen 0 1280x1024x24") .env("GDK_BACKEND", "x11") .env("LIBGL_ALWAYS_SOFTWARE", "1") .env("SEKIEN_XVFBRUN", "1") .arg(std::env::current_exe()?) .args(std::env::args().skip(1)) .status()?; std::process::exit(status.code().unwrap_or(0)); }}
つまり (3) の時点では、sekien バイナリ自身が「必要なら自分を xvfb-run でラップして再 spawn する」という、まさに (2) で問題視していたのと同種の自己 spawn を、ヘッドレス対応のためにバイナリ内部に抱えていた。
良かった点: 本体の sekien は「Mermaid → SVG変換」だけに専念するシンプルなスタンドアロン CLI に戻った。sekien-pandoc 側も独自レイヤーへの依存を切り、sekien バイナリのプロトコルを直接叩くだけのシンプルな実装になった。
悪かった点: ユーザーが sekien-pandoc を使うには、sekien-pandoc 本体に加えて sekien バイナリも別途インストールする必要があった。2 つのバイナリのインストールとバージョン整合性をユーザーに押し付ける形になり、UX として良くないと感じた。また sekien 自身は依然 process::exit() で終了する設計のままで、sekien-pandoc のような Rust ホストプロセスから直接呼べる形にはなっていなかった。
(4) 組み込みライブラリ構成 — 2026-06-12〜2026-06-14
d426765 (2026-06-12、”restructure as lib+bin crate with render_stream API”) で sekien を [lib] + [[bin]] のデュアルターゲットクレートにし、render_stream を公開 API とした。src/lib.rs はこの形になった:
//! Library API for sekien: renders Mermaid diagrams to SVG using an OS-native WebView.//!//! The CLI (`src/main.rs`) and this library are both thin layers over//! [`render_stream`], the single entry point for rendering.mod render;mod linux_display;pub use render::{render_stream, Error, RenderConfig, RenderOutcome, Result, MERMAID_VERSION};
この commit のもう一つの核心は、レンダラ内部のイベントループを event_loop.run() から event_loop.run_return() に切り替えたことだ。d92c48b (2026-05-09) で tao に移行した時点から 1 ヶ月以上、依存としては使えるのに使われていなかった run_return() が、ここで初めて採用される:
use tao::platform::run_return::EventLoopExtRunReturn;// ...event_loop.run_return(|event, _, control_flow| { // ... Collector::Done で control_flow = ControlFlow::Exit、process::exit は呼ばない});
render_stream はレンダラを純粋な Collector (状態機械) と薄いイベントループの shell に分離した上で、run_return() によって呼び出し元に戻ってくるようになった。これにより wry の Drop 実装が WebView/window を片付け、sekien-pandoc のような長命なホストプロセスからも安全に呼べる。process::exit は CLI (src/main.rs) の致命的エラー・出力失敗時のみに限定された。
ただし、この時点の RenderConfig はまだ過渡的な形をしていた:
pub struct RenderConfig { pub font_family: Option<String>, pub theme: Option<String>, pub look: Option<String>, /// Normalised JSON object string, spread into mermaid.initialize(). pub config_json: Option<String>,}
font_family/theme/look という CLI のフラグに対応するフィールドと、config_json という汎用の JSON 文字列が同じ struct に混在しており、「lib の公開 API に CLI フラグ起源の要素が紛れ込んでいる」状態だった。これは翌日の cc08fc0 (2026-06-13、”refactor: simplify RenderConfig to a plain config_json string”、v0.3.0 としてリリース) で整理され、render_stream の第 2 引数は config_json: Option<&str> という単一の JSON 文字列になった。CLI 側の --font/--theme/--look/--config は build_config_json によって CLI 内部でこの JSON 文字列にマージされる。さらに、config_json が JSON object としてパースできない場合に WebView の準備待ちでハングしていた問題を防ぐため、Error::Config が新設された。
sekien-pandoc のような Rust アプリは sekien を Cargo 依存として組み込み、render_stream をプロセス内関数として直接呼べる。
良かった点: ユーザーから見ると sekien-pandoc は単一の自己完結したバイナリになり、(3) のインストール問題は解消した。さらに子プロセスの spawn/wait 自体が存在しないため、(2) で気になっていた「ウォームな状態を維持するための子プロセスのライフサイクル管理」という問題も構造的に消えた。Collector (pure) / render_stream (impure shell) の分離も、テスト容易性の副産物として得られた。
今後の課題: RenderConfig/RenderOutcome/Error/render_stream が公開 API になったため、以後 SemVer を意識した変更が必要になる (実際 RenderConfig は (4) の中だけで一度 breaking change を経験している)。\0 プロトコルも、これまでの「唯一のインターフェース」から「lib の一利用者である CLI が採用する一プロトコル」という位置づけに変わった。
(5) 共進化による削減構成 — 2026-06-13〜現在
(4) で render_stream が公開 API になったことを受けて、(3) で別リポジトリに切り出されていた pandoc フィルタが sekien の lib API を直接呼ぶ形で再実装された。29398ff (2026-06-13) で sekien-pandoc として始まり、翌日の 44d79e2 で gazu にリネーム。gazu が最初の実消費者として sekien の API を叩いたことで、両者の間に「下流が上流の設計の余剰を炙り出す」フィードバックループが生まれた。(5) の大局は機能追加ではなく簡略化であり、両プロジェクトのコード量・API 表面積・中間層がいずれも減る方向に進んだ。
変更は大きく 3 つの類型に分けられる。
sekien の API 表面積の削減 (v0.3.1〜v0.4.0)。gazu を通じて露出した API の粗が、5 日間で 3 リリースに渡って修正された。
- v0.3.1 (
138d674) は SVG 出力をXMLSerializer経由に切り替え、下流が名前空間宣言の欠落 (xmlns:xlink等) を心配しなくてよくした — gazu → typst パイプラインで usvg が拒否する形で発覚したものだ。 - v0.3.2 (
3592737) はon_resultのSend + 'staticバウンドをFnMutのみに緩和 — gazu がmpsc::channelを強いられていたことで過剰さが判明。 - v0.4.0 (
8ef45c8) はon_resultからid: usizeを削除し (結果は入力順なのでカウンタは呼び出し元の責務)、Errorの 4 バリアントをConfig/Internalの 2 つに統合した。公開 API の表面積は v0.3.0 → v0.4.0 で実質的に半減した。
gazu 内部の中間層の消滅。sekien の API 変更のたびに gazu 側の中間層が不要になった。
- v0.3.2 のバウンド緩和で
mpsc::channel→Vec直接 push に簡素化 (4516af1)。 - v0.4.0 で
idが消えたことで、sekien のRenderOutcomeと同型だった独自のBlockOutcomeenum を持つrenderer.rsが存在意義を失い、8e4a168でモジュールごと削除。同 commit でpandoc.rs→filter.rsのリネームと、sekien 側で既に検証される JSON バリデーション (load_config_json) の削除も行われた。
結果、gazu は初期の 3 ファイル (main.rs / pandoc.rs / renderer.rs) から main.rs + filter.rs の 2 ファイルに減った。
gazu 固有の構造化。いずれも特殊ケースの削除・散乱の集約・関心事の分離と、簡略化の方向で一貫している。
- AST 内の Mermaid ブロック検索をコンテナ型ホワイトリストから
Value::Array/Value::Objectの汎用走査に変更 (8aad5be)、 - SVG 出力先をカレントディレクトリから
gazu/サブディレクトリに集約 (452b25f)、 filter()を pure (plan_outcomes) / impure に分離して WebView なしでテスト可能に、- 引数パースと実行を
Commandenum +resolve_command()で分離し TTY 判定を集約 (5f6a59b)。
良かった点: (4) の「今後の課題」で予告した SemVer コストは、gazu との共進化の中で支払われた。sekien の公開 API は render_stream(diagrams, config_json, on_result) という 3 引数に落ち着き、gazu は中間層が消えた 2 ファイル構成になった。(1) で is_pandoc_filter ヒューリスティックとして始まった pandoc 対応は完全に sekien の外に出た。sekien は「Mermaid → SVG」だけを知っていればよく、pandoc の AST も、HTML/typst のフォーマット判定も、SVG ファイルの出力先も、全て gazu の責務になった。(1) のモノリスで混在していた 2 つの関心事は、5 つの構成を経て、ようやく別のバイナリに分離された。
今後の課題: sekien の現在の公開 API が次の消費者にとっても十分かどうかは、まだ検証されていない。
