
AI 時代の docs のために、Zero Config なドキュメントサーバー「Tsumugu」を作った — Semantic AST で Markdown・HTML・MDX・OpenAPI をつなぐ
AI を使った開発では、以前にも増してドキュメントを書く機会が増えました。設計書や仕様書、調査メモ、ADR。AI に書かせたものも自分で書いたものも、コードと一緒に docs ディレクトリで管理しているプロジェクトは、もう珍しくないと思います。
ところが、ドキュメントが増えるほど「読む」体験は悪くなっていきます。普段は Zed で開いていますが、エディタは書くための道具です。検索も編集も速い代わりに、全体を見渡したり、関連するページをたどったり、気持ちよく読み進めたりするのには向いていません。
フォーマットも悩みどころでした。Markdown は人にも AI にも扱いやすく、たいていの場面では十分です。ただ、図を入れたくなったり、リッチなレイアウトを組みたくなったりすると、HTML や MDX の表現力が欲しくなる。
欲しかったのは、Markdown でも HTML でも MDX でも、docs に置くだけでそのまま読めるドキュメントサイトになるツールでした。目次があって、全文検索があって、設定ファイルは要らない。人にも AI にも扱いやすい。探してみたものの、ちょうどこれというものは見つかりませんでした。
それなら自分で作るか、と始めたのが Tsumugu です。この記事では、作った背景と設計について書きます。
docs に置くだけ
npx tsumugu dev docs
セットアップはこれで終わりです。ディレクトリの形がそのまま URL になります。
docs/
├── index.md → /
├── guide/
│ ├── index.md → /guide
│ └── getting-started.md → /guide/getting-started
├── reference/api.html → /reference/api
└── images/diagram.svg → ドキュメントの隣で配信される
実際に動いているものを見てもらうのが早いと思います。
このサイト自体も Tsumugu で配信しています。リポジトリを clone して pnpm docs を叩けば、npm で配布しているものと同じパイプラインがローカルで立ち上がります。
設定ファイルは、最初から作るつもりがありませんでした。面倒だからです。
docs を読みたいだけなのに、その前に設定ファイルの書き方を調べるところから始まるのが嫌でした。インストールして、README を読んで、config.ts を作って、プラグインを登録して、ようやくローカルが立ち上がる。そこに至るまでに気力を使い切ることが、わりとあります。叩いたら動いてほしい。
なので、設定になりそうなものは全部よそへ散らしました。
- ドキュメントルート → コマンドの引数。省略したら
./docs - タイトル、説明、並び順、非表示 → その文書自身の Front Matter
- サイト名 → トップページの title
- renderer や theme の構成 → preset。差し替えるなら TypeScript で書く
- ホスト、ポート、出力先、origin → それを使うコマンドのフラグ
あとで ADR に残すとき、念のため「じゃあ tsumugu.config.ts には何を書くんだ」を数えてみたのですが、本当に何も残りませんでした。強いて挙げれば composition API を文字列で書き直した器くらいで、それなら要らない。
副産物もあります。親ディレクトリから継承した見覚えのない設定ファイルのせいで挙動が変わる、という事故が起きません。探しにいかないものは、間違って見つけることもないので。
Markdown も HTML も OpenAPI も、同じ AST にする
Tsumugu はフォーマットの違いを入口で吸収して、そこから先は「その断片が何を意味するか」だけを扱います。Markdown も HTML も MDX も OpenAPI も、いったん Semantic AST に変換してから流しています。
パイプラインは一方向です。Scanner がファイルを見つけて Document を作り、Renderer が Semantic AST に変換し、Transformer が AST を書き換え、Theme が表示用の Virtual Tree を組み立て、Serializer が HTML を吐く。どのステージも上流へ戻りませんし、前のステージが読んだファイルを読み直すこともありません。
フォーマットを知っているのは Renderer までです。そこを抜けたファイルはどれも同じ Semantic AST になっていて、後続の処理は「元は Markdown だった」「これは OpenAPI だった」を意識しません。
Semantic AST に入っているのは、その断片が何を意味するのかという情報です。div や span、section のような見た目のための要素はありません。見た目は Theme の担当になります。
中間の木を置かず DOM をそのまま使う手もありましたが、それだと Markdown が HTML の表現へ押し潰されます。Markdown パーサの AST に寄せれば、今度は HTML が潰れる。どちらかに寄せた時点で片方が二級市民になるので、どちらでもないものを挟みました。
その恩恵がいちばん分かりやすいのが OpenAPI でした。tsumugu-renderer-openapi がやっているのは、
- tag を見出しへ
- operation を「HTTP メソッド + パス」の見出しとセクションへ
- パラメータとレスポンスをテーブルへ
- スキーマをコードブロックへ
という変換です。新しいノード型は足していません。その結果、API のエンドポイントが普通のドキュメントと同じように目次へ載り、検索に引っかかり、documents.json や llms.txt にも入りました。実装のどこにも書いていないのに、です。Theme は OpenAPI という概念を知りません。
情報を落とさないことも、この設計の条件にしています。意味へ変換できない HTML は raw-html として、まだ AST が対応していない構文は unsupported として、理由と元のテキストごと残します。黙って消すことはしません。Tsumugu が対応していないことと、書き手が間違えたことは別の話です。
この設計がいちばん効いてくるのは、実は機械向けの出力です。documents.json、llms.txt、search.json、sitemap.xml はすべて、Transformer 適用後の Semantic AST から生成しています。レンダリング済みの HTML をスクレイプすることはありません。
HTML を元にすると、テーマを差し替えただけで AI が読むテキストまで変わります。HTML になった時点で落ちる構造もある。人間向けと AI 向けに別々のデータ構造を用意したら、いずれどこかでズレるはずで、それが嫌でした。
hidden の扱いも、この延長にあります。hidden: true の文書は documents.json にはフラグ付きで入ります。「このプロジェクトに何があるのか」と聞かれたら、存在自体は正直に答えるべきだからです。一方、llms.txt と sitemap.xml、search.json からは外しています。こちらへ載ることは「読んでほしい」「インデックスしてほしい」という推薦であって、hidden の意図と逆になるので。ちなみに hidden はアクセス制御ではありません。一覧に出さないという意味で、URL を知っていれば読めます。
拡張できるポイントは 5つです。renderer、transformer、theme、serializer、plugin。ライフサイクルフックのような、何でもできる入口は用意していません。
Core は OpenAPI にも Mermaid にも検索にもビルドにも依存していません。新しいフォーマットを足すときに増えるのはパッケージだけで、Core は変わらない。Renderer を境界にした分離がまだ効いているかどうかは、これで判断しています。
コンテンツは実行しない
Tsumugu のセキュリティモデルは、一文で説明できます。
Content does not execute.
この方針だけは最初から変えていません。関わる人間を 3者に分けて、それぞれ何を信用するかを決めています。
| Who | What |
|---|---|
tsumugu を実行した人 |
すべて。自分のマシンで自分が打ったコマンドなので |
| ドキュメントの書き手 | 文章だけ。マークアップやスクリプトは信用しない |
| ポートへ到達できる誰か | 何も |
重要なのは真ん中です。
ドキュメントを書くのは自分だけではありません。コントリビューターが書いたもの、vendoring したファイル、生成物、そして AI が書いたもの。ドキュメントを実行するツールは、それを書いた全員を Code Owner にしてしまいます。AI がドキュメントを書く時代になって、この境界は前より重くなりました。だから Tsumugu では、文章を書く権限とコードを実行する権限を分けています。
実装もそれに従います。HTML は Semantic AST へ変換され、意味に対応できないマークアップはエスケープ済みのテキストとして保持されます。<script> は Diagnostic を出したうえで中身を落とします。
Serializer が生の HTML を出せる入口は一つしかありません。trustedHtml です。しかもこの API は、呼ぶときに「なぜ信用できるのか」という理由を文字列で渡さないと通りません。いま実際にここを通っているのは、次の 3つです。
- Tsumugu 自身のスタイルシート
- Tsumugu 自身のスクリプト
--trustが指定されたときの、書き手のマークアップ
ブラウザにも同じことを伝えます。すべてのレスポンスに default-src 'none' を基本とした CSP が付いていて、デフォルトの script-src に並ぶのは Tsumugu 自身が生成したスクリプトの SHA-256 ハッシュだけです。
default-src 'none';
script-src
'sha256-…(page client)'
'sha256-…(dev live reload)';
connect-src 'self'
ページクライアントが常に 1本、開発サーバーのときだけライブリロード用がもう 1本。それだけです。
nonce も 'self' も使っていません。nonce はサーバーが印を付けたスクリプトなら何でも通してしまいますし、'self' だと同じオリジンの JavaScript が全部許可されるので、docs ディレクトリに置かれた .js まで実行対象に入ります。どちらも、書き手を信用しないという前提と噛み合いませんでした。
ハッシュなら 1バイト違うだけで実行されません。書き手が置いたスクリプトも、途中で差し替えられたスクリプトも、Tsumugu 自身のスクリプトを書き換えたものも、全部ブラウザが拒否します。サーバー側の防御が仮に全部抜かれても、最後にブラウザが止める。
MDX もこの上にあります。MDX は Markdown の拡張ではなく、Markdown に JavaScript を混ぜたプログラミング言語です。import は実行され、式は評価され、コンポーネントは動きます。MDX をそのままレンダリングするというのは、ドキュメントをコードとして実行するということでした。
なので Tsumugu は、.mdx を本物の MDX 構文としてパースしたうえで、実行しません。
| MDX の構文 | Tsumugu での扱い |
|---|---|
{expression} |
そのまま表示し、評価しない |
<Component /> |
そのまま表示し、描画しない |
import / export |
そのまま表示し、実行しない |
Markdown として書かれた部分は .md とまったく同じです。見出しもアンカーもシンタックスハイライトも検索もエクスポートも変わりません。動的な部分が、整形済みのソースコードとして表示されるだけです。情報を落とさず、実行もしない。実行を断った文書の見せ方としては、これがいちばん正直だと思っています。
そのうえで用意したのが --trust です。これは機能を有効にするフラグではなく、「このドキュメントルートの中身は自分のものだから、コードとして信用する」という Operator 自身の宣言です。デフォルトは常に OFF、推測もしませんし、起動時に何を信用したかターミナルへ表示します。スコープはドキュメントルートの中だけで、ネットワークには広がりません。
指定したときに変わるのは 3点です。
- 保持していた生のマークアップをそのまま出力する
- 書き手の JavaScript を実行できるようにする
.mdxをビルド時に評価する
評価された MDX は静的な HTML として Semantic AST に取り込まれます。だから検索も目次も documents.json も llms.txt も、評価後の文書を見ます。読者へ React や MDX のランタイムを配ることはありません。最後まで、返すのは静的なドキュメントです。
Front Matter に trust: true のような設定を用意しなかったのも、同じ理由です。信用されていない側が「自分を信用してほしい」と宣言できてしまったら、境界が逆転します。信頼を決めるのは書き手ではなく Operator です。
図は自分で描くことにした
Markdown に図を入れるなら mermaid とタグを付けたフェンスです。最初の Tsumugu はこれをコードブロックのまま表示していました。ソースは残るけれど、図は出ない。
Mermaid のスクリプトをブラウザへ配る手は、前の章の都合で取れません。数 MB の JavaScript がドキュメントの中身を読んで実行することになるので。残るのはサーバー側で SVG まで描いてしまう方法で、jsdom の上で Mermaid を動かしている例は実際いくつもあります。まずそれを試しました。
| 図 | 結果 |
|---|---|
sequenceDiagram |
450×226の正しい SVG。9〜25ms |
graph LR |
5ノードのフローチャートが、計算上の幅 41216px |
stateDiagram-v2 |
同上 |
| インストール | Mermaid 83MB + jsdom 8.3MB、解決後 177MB |
フローチャートが崩れる原因は foreignObject でした。Mermaid はラベルを HTML として foreignObject の中に置き、レイアウトを決めるために DOM へテキストの寸法を問い合わせます。ブラウザなら当たり前にできることで、jsdom にはできない。だから計算が破綻します。テキスト計測の shim を用意してみましたが、エラーの出る場所が変わっただけでした。flowchart.htmlLabels: false、トップレベルの同等指定、文書内の %%{init}%% も試して、foreignObject は残りました。
これは Mermaid の側の問題ではなく、ブラウザで動く前提のものをブラウザの無いところで動かそうとしている、私の要件のほうが特殊です。とはいえ npx 一行で立ち上がってほしいツールに、177MB とヘッドレスブラウザを足す気にはなれませんでした。
なので、描ける範囲だけ自分で描くことにしました。tsumugu-transformer-mermaid は Mermaid 構文のサブセットをパースして、レイアウトして、SVG を吐きます。package.json の dependencies に入っているのは tsumugu-core だけです。
これは Mermaid の代わりになるものではありません。描けるのはフローチャート(graph / flowchart の TD TB LR RL BT)とシーケンス図の 2種類で、クラス図も状態遷移図もガントも円グラフも ER もジャーニーも描けません。subgraph、classDef、style、%%{init}%% も受け付けません。本家のごく一部を、自分の制約に合わせて作り直した小さなものです。
サブセットの外に当たったときは、コードブロックのまま表示して、何が描けなかったかを Diagnostic で伝えます。図が一つ描けないせいでページごと読めなくなるのは、いちばん避けたい失敗なので。
SVG は <img> ではなくインラインで埋めています。そうすると図の色が currentColor に従うので、読者のライト / ダークに勝手についてきます。図の中の文字も選択できますし、ブラウザの検索にも引っかかります。
図には role="img" と aria-label を付けて、説明は視覚的に隠した figcaption に置きました。SVG 自身の <title> と <desc> を使っていないのは、Serializer が title をエスケープ禁止の raw-text 要素として扱っていて、HTML の title と SVG の title を見分けられないからです。エスケープを判断している最後の場所に名前空間の概念を持ち込むより、普通の HTML で書くほうが安全だと考えました。
説明文は accTitle / accDescr が書いてあればそれを使い、無ければ図の中身から生成します。既存の Mermaid ブロックを貼っただけの人に、いきなり警告を出すのは違うと思いました。
図のソースはノードの中に残しています。だから検索も documents.json も llms.txt も、図をテキストとして読めます。図が見えない読者と、コーパスを読むモデルが、同じものを受け取ります。
サブセットで足りないときの逃げ道も、2つあります。
一つは、図を .svg として書き出して docs に置き、Markdown から  と参照する方法です。アセットはドキュメントの隣でそのまま配信されるので、図の複雑さに上限はありません。Mermaid CLI でも Excalidraw でも Figma のエクスポートでも構いませんし、SVG の中で fill="currentColor" を使っておけばダークモードにもついてきます。ただし本文に図のテキストは残らないので、検索や llms.txt に載せたい内容は alt や前後の文で補うことになります。
もう一つは、--trust を出したうえで HTML か MDX で書く方法です。自分の docs だと宣言してしまえば、本物の Mermaid をページで読み込んでも、コンポーネントで描いても構いません。MDX はビルド時に評価されて静的な HTML になるので、そこで出た図のテキストは検索にも目次にも載りますし、読者へランタイムが配られることもありません。図を主役にしたページを書きたいなら、こちらのほうが素直です。
自分で描いたのが 2種類にとどまっているのは、npx tsumugu dev docs に何も足さずに図が出る状態を優先した結果です。凝ったことがしたくなった時点で、上の 2つに移ってもらうのが正しいと思っています。
検索は、依存を足さずに書いた
検索は静的出力でも動いてほしい、というのが出発点でした。tsumugu build が吐くのはホストに置かれるファイルの木で、キーストロークごとに答えてくれるサーバーは居ません。クエリごとにフォームを送る手ならどこでも動きますが、検索するたびにページが飛ぶものを検索とは呼びたくない。となると、インデックスを一度だけ取ってきて、あとはブラウザで絞り込むことになります。
ただし、ここで前章の CSP に当たりました。許すハッシュは 2本と決めてしまったので、検索のスクリプトはその 1本に収める必要があります。
書き上がったクライアントは 2.6KB、46行でした。フレームワークもバンドラもビルドステップもありません。書いたものがそのまま配信されて、そのままハッシュの対象になります。view-source で見えるものと、リポジトリに置いてあるものが同じです。検索ライブラリを入れなかったのは、どれもインデックス形式とバージョン追従が付いてくるうえ、このクライアント全体より大きかったからでした。
マッチングは部分一致で、大文字小文字とアクセントを無視します(小文字化して NFKD、結合文字を落とす)。あいまい検索にはしていません。ドキュメントの検索が推測を始めると、名指しで探しているページが下に埋もれます。
ランキングはこうです。
- クエリを空白で割って、全部の語が一致すること。2語入れたら絞り込まれる、広がらない
- セクションの見出しでの一致 > 文書タイトル > 本文。語頭での一致 > 語中
- 同点なら文書順。1つの文書は 12件中 3件まで。長いページが一覧を埋めない
スコアリング関数は、ユニットテストが呼んでいる TypeScript からそのままスクリプトへ埋め込んでいます。ブラウザで動くランキングと、テストが見たランキングを揃えておきたかったからです。
JavaScript が無い環境では、検索ボックスは <form method="get" action="/search"> として /search へ飛びます。このページはクエリには答えず、全文書を並べます。マッチングの実装を 2つ持つと、いつか 2つの検索が食い違うので。JavaScript があれば検索が即座になり、無ければ検索がページになる。どちらにしても、押しても何も起きないコントロールは残りません。
ただ、いまの実装で満足しているわけではありません。部分一致は語形の変化に弱く、日本語のように単語が空白で区切られない言語では、狙ったところで切れてくれないことがあります。インデックスもドキュメント数に比例して素直に太ります。このリポジトリのドキュメントで /search.json は約 145KB。一度取ってキャッシュされるので実用上は困っていませんが、規模が一桁上がったら通用しません。
検索は、ドキュメントサイトでいちばん大事な機能だと思っています。目次もナビゲーションも、結局は探しているページへ辿り着くための補助です。読む体験が壊れるのは、たいていそこで失敗したときでした。
次に手を入れたいのもここです。サーバーを要求せず、クライアント側で完結する全文検索インデックスを、ビルド時にどう速く作るか。まだ RFC も書いていないので、形も方式も決まっていません。「サーバーが要らない」「日本語でまともに引ける」「インデックスが太りすぎない」を同時に満たす線はあるはずで、いまはそれを探しているところです。
保存してから、画面が変わるまで
書いている最中の体験は、保存してから画面が変わるまでの時間でほぼ決まります。ここは計測しています。
| ドキュメント数 | 初回ビルド | 何も変えずに再ビルド | 1ファイル編集 |
|---|---|---|---|
| 200 | 約 490ms | 約 20ms | 約 20ms |
| 1000 | 約 3.9s | 約 200ms | 約 140ms |
再ビルドが安いのは、キャッシュを 3層に分けて、無効化のキーをそれぞれ決めているからです。読み込んだ文書はサイズと更新時刻で、テーマ適用後の本文とアウトラインはコンテンツハッシュで、シリアライズ済みのページは「その文書の外側でページが依存しているもの」の署名で無効化します。
最後の 1層が入るまで、1000ドキュメントのプロジェクトは 1回の保存に 2.8秒かかっていました。全ページがナビゲーションを持っている都合で、どこか 1ファイルを直すたびに全ページを組み直していたからです。触っていて「なんとなく遅いな」とは思っていましたが、原因に気づいたのはベンチマークを書いたあとでした。
1行も自分で書いていない
このプロジェクトのコードは、1行も自分で書いていません。実装はすべて Claude Code と Codex に任せました。
とはいえ「AI が全部作ってくれた」という話でもなくて、実際に時間を使ったのは仕様を考え、設計を見直し、意思決定をすることでした。以前はコードを書く時間が大半でしたが、その時間が設計とレビューに置き換わった感覚が近いです。
いまはだいたいこの流れで進めています。
grill-with-docsで要件を洗い出すto-specで仕様へ落とすto-ticketsで Issue に分解するimplementで実装する
Matt Pocock さんの Skills を自分用のオーケストレーションスキルでつないでいるだけで、特別なことはしていません。今回はドキュメントサーバーだったので使いませんでしたが、普段の仕事ではデザインやフロントエンド向けの Skills も一緒に読み込ませています。
- ui-ux-pro-max(nextlevelbuilder)
- frontend-design(anthropics/skills)
- high-end-visual-design(leonxlnx/taste-skill)
- web-design-guidelines(vercel-labs/agent-skills)
- writing-guidelines(vercel-labs/agent-skills)
コードレビューに使う時間は、はっきり減りました。部分的に読んでリファクタリングを指示したり、設計について議論したりはしますが、何百行もの実装を頭から追うことはほとんどありません。
代わりにテストへ時間をかけるようになりました。いま Tsumugu には 930を超えるテストがあります。ユニットと統合はもちろん、examples/ の 2つのサンプルは毎コミット実際に配信されますし、docs/designs/diagnostics.md に載っていない Diagnostic コードが実装に生えるとテストが落ちます。Web アプリケーションなら、最後は実際のブラウザで触って違和感がないかを自分の目で見ます。
レビューの対象がコードから成果物へ移っただけで、品質の責任まで AI へ渡せるようになったわけではない、というのが今のところの実感です。
おわりに
Tsumugu はまだ pre-alpha です。バージョンは 0から始まっていて、リリースごとに公開 API が変わる可能性があります。
自分が毎日読むものを自分のツールで配信していると、遅いところも読みにくいところも、放っておけないところに出てきます。しばらくはこれを繰り返しながら磨いていくつもりです。




