概要
まず始めに、自分はClaude Codeのスキルとかエージェントとかを単独のTauriで作ったアプリで見れるようにしていて、それをネイティブのアプリとして配布したら誰でも使えて楽じゃねーかと言うのを思いついた。

そういうのを相談していたら、なんか盛り上がってきて、んじゃRustでSSGするヤツを作るのがいっかなーってことになって、その経過が自分的にはこれまでの振り返りみたいになって面白かったのでその会話ログ。
なお、会話に出てくるzudo-docというのは自分が作ってるDocusaurusクローンみたいなやつで、ちょいちょい更新してるけどまとまってはいませんみたいなモノです。
CCResDocをどう配るか
This is the new repo. see ~/.claude/doc/
it includes doc by zudo-doc https://takazudomodular.com/pj/zudo-doc it’s a astro doc, and it’s tweaked for Tauri app. the Tauri source is there too. with that Tauri, we hav CCResDoc.app. I’m using this as the viewer of Claude Code Resrouces. I’m planning to release this as OSS. this repo is for that
But I want to make this more simple. We are using it as the node app under ~/.claude/. we have node setup there. and Tauri is almost as a wrapper of that. So… is it possible for new user as lilke.
- just install CCResDoc.app from .dmg -> /Applications/
- launch it -> it launches doc by seeing ~/.claude/
Refer /tauri-wisdom /zudo-doc-wisdom too
これは新しいリポジトリ。~/.claude/doc/を見て。
中身はzudo-docのドキュメント https://takazudomodular.com/pj/zudo-doc Astroドキュメントで、Tauriアプリ用に調整されている。Tauriのソースもそこに入っている。 そのTauriで作っているのがCCResDoc.app。Claude Codeリソースのビューワとして使っている。 これをOSSとして公開したい。このリポジトリはそのためのもの。
ただ、もっとシンプルにしたい。今は~/.claude/配下のNodeアプリとして動いていて、Nodeのセットアップもそこにある。Tauriはほぼそのラッパー。なので、新規ユーザーがこんな風にできないか。
- .dmgからCCResDoc.appをインストールするだけ → /Applications/
- 起動する →
~/.claude/を見てドキュメントが立ち上がる
/tauri-wisdom と /zudo-doc-wisdom も参照して。
I have a clear picture of the current setup. Here’s my read and a recommendation.
Current shape
~/.claude/doc/= Astro source + Tauri wrapper, all in one repoclaude-resourcesintegration scans~/.claude/at Astro build time to generate MDX pages- Tauri main.rs has two paths: sidecar mode (system node runs
dev-stable.js→ live build) or axum fallback (serves pre-builtdist/)
The blocker for your OSS goal: a prebuilt dist/ only contains your Claude resources, not a new user’s. So a fresh .app install can’t ship a usable dist/ — the build must happen on the user’s machine against their ~/.claude/.
Two realistic paths
Path A — Bundle source inside .app, keep node dependency (small refactor)
Move all of ~/.claude/doc/ (Astro source + node_modules) into the .app bundle’s Resources/. Tauri’s sidecar still runs dev-stable.js, but from the bundled source. ~/.claude/ becomes pure read-only input. New user flow: install .app → launch → if node found, builds against their ~/.claude/; if not, show “install node” message. Tradeoff: still requires node on the user’s machine, and the .app gets fat (~hundreds of MB with node_modules).
Path B — Drop node entirely, render in Rust (bigger refactor)
Pre-build the Astro shell (layout, navigation, search index logic) without the claude-resources integration. Inside the .app, axum serves the static shell + dynamic routes that read ~/.claude/ files at request time and render markdown in Rust (pulldown-cmark + syntect for syntax highlighting). Tradeoff: lose Astro’s MDX features for claude-resources pages, need to reimplement remark/rehype plugins (admonitions, mermaid, code titles, heading links) in Rust. Bigger upfront cost but truly self-contained .app.
Recommendation
Start with Path A. It’s a relocation + small Tauri config change, not a rewrite. You can ship OSS quickly, validate the user flow, then incrementally move toward Path B (or a hybrid) if node_modules bloat or “install node” friction becomes a real problem. Path B is tempting for purity but the MDX feature surface is non-trivial to port.
Which way do you want to go? (Or do you want me to load /tauri-wisdom and /zudo-doc-wisdom first to see if they change the calculus?)
現在のセットアップは把握できた。読み取りと推奨を述べる。
現状
~/.claude/doc/= Astroソース + Tauriラッパーがひとつのリポジトリに入っているclaude-resourcesインテグレーションがAstroビルド時に~/.claude/をスキャンしてMDXページを生成する- Tauri main.rsには2つのパスがある: サイドカーモード(システムのnodeが
dev-stable.jsを実行 → ライブビルド)または axum フォールバック(事前ビルド済みのdist/を配信)
OSS化のブロッカー: 事前ビルド済みのdist/はあなた自身のClaudeリソースしか含まないので、新規ユーザーには使えない。したがってフレッシュな.appインストールでは使えるdist/を同梱できない — ビルドはユーザーのマシン上でそのユーザーの~/.claude/に対して行う必要がある。
現実的な2つの道
Path A — .appの中にソースを同梱、Node依存は残す(小さなリファクタ)
~/.claude/doc/の中身全部(Astroソース + node_modules)を.appバンドルのResources/に移す。Tauriのサイドカーは引き続きdev-stable.jsを実行するが、同梱したソースから実行する。~/.claude/は純粋に読み取り専用の入力になる。新規ユーザーのフロー: .appをインストール → 起動 → nodeが見つかれば~/.claude/に対してビルド、見つからなければ「nodeをインストールしてください」と表示。トレードオフ: ユーザーのマシンに依然としてNodeが必要、.appが太る(node_modules込みで数百MB)。
Path B — Nodeを完全に捨て、Rustでレンダリング(大きなリファクタ)
claude-resourcesインテグレーションなしでAstroのシェル(レイアウト、ナビゲーション、検索インデックスロジック)を事前ビルドする。.appの中で、axumが静的シェル + リクエスト時に~/.claude/のファイルを読んでRustでマークダウンをレンダリングする動的ルートを配信する(pulldown-cmark + シンタックスハイライト用のsyntect)。トレードオフ: claude-resourcesページではAstroのMDX機能を失う、remark/rehypeプラグイン(注記、mermaid、コードタイトル、見出しリンク)をRustで再実装する必要がある。前払いコストは大きいが、本当に自己完結する.appになる。
推奨
Path Aから始めよう。 書き換えではなく、配置換えと小さなTauri設定変更だけ。OSSをすぐに出せて、ユーザーフローを検証できる。node_modulesの肥大化や「nodeをインストール」の摩擦が実際に問題になってきたら、Path B(またはハイブリッド)へ段階的に移れる。Path Bは純粋さの観点からは魅力的だが、MDX機能面の移植は簡単ではない。
どちらの道で行く?(あるいは先に/tauri-wisdomと/zudo-doc-wisdomをロードして、判断材料が変わるかチェックする?)
A would B fine. But B is interesting, you mean that we drop Astro, and do the similar thing on Rust, are you saying? we are depply rely on remark. is that kind of thing possible? by the way, we migrated markdown formatter from TypeScript to Rust, and it went well. it’s on /refer-another-project mdx-formatter
Aで良さそう。でもBが気になる。AstroをやめてRustで似たようなことをやる、ということ?我々はremarkに深く依存している。それでも可能なのか?ところで、マークダウンフォーマッタをTypeScriptからRustに移植したことがあって、うまくいった。/refer-another-project mdx-formatter で見て。
Yes — and the mdx-formatter precedent is directly relevant. They both lean on markdown-rs for mdast parsing, which is the same AST shape remark uses. The hybrid pattern (AST analysis → line/HTML operations) ports cleanly.
What maps well to Rust
| Current (JS) | Rust replacement | Risk |
|---|---|---|
@astrojs/mdx parsing | markdown-rs (CommonMark + GFM + MDX support) | Low — proven in mdx-formatter |
remark-directive + remark-admonitions | mdast visitor in Rust (tree walk) | Low — same pattern as your formatter plugins |
rehype-code-title, rehype-heading-links, rehype-strip-md-extension | hast visitors or post-render string ops | Low |
| Shiki + transformers | syntect or inkjet (tree-sitter) | Medium — theme parity not 1:1, dual light/dark needs work |
gray-matter (frontmatter) | serde_yaml + simple split | Trivial |
minisearch index (build-time JSON) | Generate JSON in Rust at runtime, ship existing JS client | Low |
rehype-mermaid | Keep client-side rendering (mermaid.js already runs) | None |
What’s trickier
- MDX-with-JSX in hand-authored pages.
src/content/docs/may have actual JSX components inline. markdown-rs parses MDX expressions, but you’d need to evaluate them — which means either (a) shipping a JS runtime just for those pages, (b) auditing and converting them to plain markdown + a small set of “shortcodes” you reimplement in Rust, or (c) hybrid: pre-build the static MDX pages with Astro at .app build time, render only the~/.claude/-derived pages dynamically in Rust. - Shiki theme matching.
syntectuses TextMate grammars + Sublime themes;shikiuses VS Code themes (some shared). Visually close but not identical. If theme fidelity matters,inkjet(tree-sitter based) is closer in feel. - The Astro shell (layouts, navigation, color schemes, search UI). You don’t have to rewrite this — pre-build it once at .app build time without the
claude-resourcesintegration, ship the static shell in the .app, and have Rust serve dynamic pages that wrap content in the same layout HTML.
Suggested shape for Path B
- Crate
ccresdoc-renderer— markdown-rs + custom mdast/hast passes +syntect/inkjet. Pure library. - Tauri Rust binary embeds it. At launch, scan
~/.claude/, render each resource to an HTML fragment, cache in memory. - axum routes:
/claude/CLAUDE.md,/claude/commands/*, etc. → return prebuilt shell HTML with the rendered fragment slotted in. Static assets (CSS/JS/Astro shell) served from bundled resources. - Search index regenerated in Rust at scan time, served at
/search-index.json. Existing minisearch client-side JS keeps working unchanged. - File-watcher in Rust (
notifycrate) re-renders on~/.claude/changes; SSE endpoint pushes reload events. (Replacesdev-stable.jsfor live-reload.)
Honest sizing
Given your mdx-formatter pattern is proven, the mdast/hast plugin ports are the smallest part. The real time sinks: (1) auditing what JSX/MDX features the hand-authored pages actually use and deciding the policy, (2) syntax-highlighting theme work, (3) replicating the Astro shell’s bundling so Rust can serve it 1:1.
Realistic estimate: a focused 1–2 week effort for a working Path B if MDX-with-JSX usage is light, longer if hand-authored pages use rich JSX. Worth scoping that audit before committing.
Want me to start by auditing JSX usage in src/content/docs/ and the custom plugins so we can see how much surface really needs porting?
そう — そしてmdx-formatterの前例はまさに直接関係する。両方ともmdastパースにmarkdown-rsを使っており、これはremarkと同じAST形状。ハイブリッドパターン(AST解析 → 行/HTML操作)はクリーンに移植できる。
Rustにうまく対応できるもの
| 現在(JS) | Rustでの置き換え | リスク |
|---|---|---|
@astrojs/mdxのパース | markdown-rs(CommonMark + GFM + MDXサポート) | 低 — mdx-formatterで実証済み |
remark-directive + remark-admonitions | Rustのmdastビジター(ツリー走査) | 低 — フォーマッタプラグインと同じパターン |
rehype-code-title、rehype-heading-links、rehype-strip-md-extension | hastビジターまたはレンダリング後の文字列操作 | 低 |
| Shiki + transformers | syntectまたはinkjet(tree-sitter) | 中 — テーマの完全一致は難しい、ライト/ダークの両対応に工夫が要る |
gray-matter(frontmatter) | serde_yaml + 単純な分割 | 些細 |
minisearchインデックス(ビルド時JSON) | RustでJSONをランタイム生成、既存のJSクライアントをそのまま | 低 |
rehype-mermaid | クライアント側レンダリングを維持(mermaid.jsはすでに動作中) | なし |
厄介なもの
- 手書きページ内のMDX-with-JSX。
src/content/docs/には実際のJSXコンポーネントがインラインで含まれているかもしれない。markdown-rsはMDX式をパースするが、それを評価する必要がある — つまり (a) これらのページのためだけにJSランタイムを同梱する、(b) 監査して、プレーンマークダウン + Rustで再実装した少数の「ショートコード」に変換する、(c) ハイブリッド: 静的MDXページは.appビルド時にAstroで事前ビルドし、~/.claude/由来のページだけRustで動的レンダリングする、のいずれか。 - Shikiのテーママッチ。
syntectはTextMate文法 + Sublimeテーマを使い、shikiはVS Codeテーマを使う(一部共有)。見た目は近いが同一ではない。テーマ忠実度が重要なら、inkjet(tree-sitterベース)の方が感触が近い。 - Astroシェル(レイアウト、ナビゲーション、カラースキーム、検索UI)。これは書き直さなくていい —
claude-resourcesインテグレーションなしで.appビルド時に1度事前ビルドし、静的シェルを.appに同梱し、Rustが同じレイアウトHTMLでコンテンツをラップする動的ページを配信する。
Path Bの提案形状
- クレート
ccresdoc-renderer— markdown-rs + カスタムmdast/hastパス +syntect/inkjet。純粋なライブラリ。 - TauriのRustバイナリがそれを埋め込む。起動時に
~/.claude/をスキャンし、各リソースをHTMLフラグメントにレンダリングしてメモリにキャッシュ。 - axumルート:
/claude/CLAUDE.md、/claude/commands/*など → 事前ビルドされたシェルHTMLにレンダリング済みフラグメントを差し込んで返す。静的アセット(CSS/JS/Astroシェル)は同梱リソースから配信。 - 検索インデックスはスキャン時にRustで再生成し、
/search-index.jsonで配信。既存のminisearchクライアント側JSは変更不要で動作。 - Rustのファイルウォッチャー(
notifyクレート)が~/.claude/の変更で再レンダリング、SSEエンドポイントがリロードイベントをプッシュ。(dev-stable.jsによるライブリロードを置き換える)
正直な規模感
mdx-formatterパターンが実証済みなので、mdast/hastプラグインの移植は最小の部分。実際の時間泥棒は: (1) 手書きページが実際にどのJSX/MDX機能を使っているかの監査と方針決定、(2) シンタックスハイライトのテーマ作業、(3) RustがAstroシェルのバンドルを1:1で配信できるよう複製すること。
現実的な見積: MDX-with-JSXの使用が軽ければ、動くPath Bに集中して1-2週間。手書きページがリッチなJSXを使っているならそれより長い。コミットする前に監査の範囲を決めるのに値する。
src/content/docs/とカスタムプラグインのJSX使用状況を監査して、どれくらいの面が実際に移植される必要があるかを見るところから始める?
ファイル更新の扱い
with this strategy, we have pre-built (by Astro) HTML assets inside our .app. and the axum combine the parsed HTML with it, you mean? can it handle updates on Claude Code resources? or do we need JavaScript handling around it?
we are doing tricky thing around this. watching the filex -> invoke Astro build. so this is pretty heavy thing for us now.
この戦略だと、.appの中に(Astroで)事前ビルドされたHTMLアセットがあって、axumがパースされたHTMLをそれと組み合わせる、ということ?Claude Codeリソースの更新は扱えるのか?それともJavaScriptで何か処理が必要?
我々は今この辺で結構トリッキーなことをしている。ファイルを監視 → Astroビルドを起動。これはかなり重い処理になっている。
Yes — Rust handles it cleanly, and it’s actually much lighter than your current setup. Let me lay out how it differs.
Current pain (full Astro rebuild)
dev-stable.js watches → astro build runs over the entire site → new dist/ swapped → SSE reload. The Astro build is the heavy part: hundreds of files, MDX compilation, plugin pipeline, etc., even when only one file changed.
Path B update flow (incremental, in Rust)
notifycrate watches~/.claude/(and your shell’s content dir if you want)- Change event for one file → Rust re-renders just that file to an HTML fragment → updates the in-memory cache
- If it was a structural change (file added/removed/renamed), rebuild the affected navigation/index entries (still microseconds, not a full site build)
- SSE pushes a
reloadevent for the affected route(s) - Browser receives event and reloads — or, since the per-file render is sub-ms, you can
fetchthe new HTML fragment and swap it in-place with no flash
Rebuild time goes from ~1–3s (Astro) to ~1–10ms (Rust per file). The “spinner overlay while building” pattern you have now becomes unnecessary — there’s nothing slow enough to need it.
The JS you DO need (tiny)
Just the SSE client in the shell HTML — basically what dev-stable.js already emits to the browser, ported as a static snippet. Roughly:
new EventSource("/__reload").onmessage = (e) => {
if (e.data === "reload") location.reload();
// optional: if (e.data.startsWith('swap:')) { fetch + swap the <main> }
};That’s all. Not “JavaScript handling” in any heavy sense — it’s a single <script> block bundled into the prebuilt shell. Mermaid diagrams keep using their existing client-side JS, search keeps using the existing minisearch client. Nothing else needs JS.
Net comparison
| Aspect | Current | Path B |
|---|---|---|
| Watch trigger | full Astro build (~1–3s) | per-file Rust render (~1–10ms) |
| Build dependency on user machine | node + pnpm + node_modules | none |
| Process to manage | sidecar node process | none (in-process Rust) |
| Live-reload mechanism | SSE from dev-stable.js | SSE from axum |
| Spinner overlay needed? | yes (visible build delay) | no (instant) |
.app size | hundreds of MB if bundling node | tens of MB |
The “watch → invoke Astro build” complexity disappears entirely. You’d be replacing dev-stable.js + watch-dirs.js + the entire sidecar node lifecycle with ~50 lines of Rust using notify + tokio::sync::broadcast + axum SSE.
Want me to do the JSX-usage audit next so we can see if there’s anything in src/content/docs/ that would block Path B?
そう — Rustはクリーンに扱える。実際、現在のセットアップよりもずっと軽い。どう違うか説明する。
現在の痛み(フルAstroリビルド)
dev-stable.jsが監視 → astro buildがサイト全体で走る → 新しいdist/にスワップ → SSEリロード。Astroビルドが重い部分: 数百ファイル、MDXコンパイル、プラグインパイプラインなど、たった1ファイルの変更でも全部走る。
Path Bの更新フロー(インクリメンタル、Rust)
notifyクレートが~/.claude/を監視(必要ならシェルのコンテンツディレクトリも)- 1ファイルの変更イベント → RustがそのファイルだけHTMLフラグメントに再レンダリング → メモリ内キャッシュを更新
- 構造的な変更(ファイル追加/削除/リネーム)の場合は、影響を受けるナビゲーション/インデックスエントリを再構築(それでもマイクロ秒オーダー、サイト全体のビルドではない)
- SSEが影響を受けるルートに
reloadイベントをプッシュ - ブラウザがイベントを受信してリロード — あるいはファイル単位のレンダリングがサブミリ秒なので、新しいHTMLフラグメントを
fetchしてフラッシュなしでインプレースで差し替えることもできる
リビルド時間が約1-3秒(Astro)から約1-10ミリ秒(Rust、ファイル単位)になる。 今ある「ビルド中スピナーオーバーレイ」のパターンは不要になる — それを必要とするほど遅いものがない。
必要なJS(わずか)
シェルHTMLのSSEクライアントだけ — 基本的にdev-stable.jsがすでにブラウザに送信しているもので、静的スニペットとして移植する。だいたい:
new EventSource("/__reload").onmessage = (e) => {
if (e.data === "reload") location.reload();
// オプション: if (e.data.startsWith('swap:')) { fetch + swap the <main> }
};これだけ。重い意味での「JavaScript処理」ではない — 事前ビルドされたシェルにバンドルされる単一の<script>ブロック。Mermaid図は既存のクライアントサイドJSを使い続け、検索は既存のminisearchクライアントを使い続ける。それ以外にJSは必要ない。
全体比較
| 観点 | 現在 | Path B |
|---|---|---|
| 監視トリガ | Astroフルビルド(約1-3秒) | Rustによるファイル単位のレンダリング(約1-10ms) |
| ユーザーマシンへのビルド依存 | node + pnpm + node_modules | なし |
| 管理プロセス | サイドカーnodeプロセス | なし(インプロセスRust) |
| ライブリロード機構 | dev-stable.jsからのSSE | axumからのSSE |
| スピナーオーバーレイは必要? | はい(ビルド遅延が可視) | いいえ(即時) |
.appサイズ | nodeをバンドルすると数百MB | 数十MB |
「監視 → Astroビルド起動」の複雑さは完全に消える。dev-stable.js + watch-dirs.js + サイドカーnodeのライフサイクル全体を、notify + tokio::sync::broadcast + axum SSEを使ったRust 約50行で置き換えることになる。
次にsrc/content/docs/とカスタムプラグインのJSX使用状況を監査して、Path Bをブロックするものがあるか見てみる?
サイドバーをRustで作れるか
the tricky thing about our doc base is sidebar. it’s listing the doc metainfo, and making index. is it possible with axus? maybe it’s needed to be coded as another script?
我々のドキュメントベースで難しいのはサイドバー。ドキュメントのメタ情報をリストして、インデックスを作っている。axumで可能なのか?別のスクリプトとして書く必要があるかもしれない。
I see exactly what you’re doing — buildNavTree walks docs + frontmatter, sidebar config in sidebars.ts either auto-generates or follows explicit ordering, and SidebarTree is already a client:load React component. Good news: this is straightforward in axum, and you don’t really need a separate script.
Where the sidebar logic actually lives
| Piece | Where it runs now | What it does |
|---|---|---|
buildNavTree(docs, lang, categoryMeta) | Astro build time | Walks docs collection → tree of NavNode |
buildSidebarForSection | Astro build time | Applies sidebars.ts overrides or auto-generates |
<Sidebar /> astro component | Astro build time, per page | Inlines initial HTML + React hydration props |
<SidebarTree client:load /> | Browser | Renders the actual interactive UI (collapse/expand, current) |
The sidebar is already client-rendered after hydration. Astro just provides the initial HTML and seed props.
Recommended shape in Path B
axum serves a single /api/nav-tree.json endpoint, your existing React component consumes it.
Rust (in-memory)
├── walk_docs() → NavNode tree (port of buildNavTree)
├── apply_sidebar_config() → port of sidebars.ts resolution
└── notify watcher → re-walk on change → broadcast SSE
axum routes
├── GET /api/nav-tree.json → serves the cached tree
├── GET /__reload (SSE) → fires "reload" or "nav-changed"
└── GET /<page> → static shell + content fragmentOn the client:
- The bundled
SidebarTreeReact component (compiled by Astro into the prebuilt shell) fetches/api/nav-tree.jsonon mount instead of receiving it as Astro props - SSE event → refetch + re-render (no page reload needed for sidebar-only changes)
Why this is simpler than your current setup
- Today: every Astro build re-walks all docs, re-serializes sidebar props, embeds them on every page → full rebuild = heavy
- Path B: tree is built once at startup in Rust (sub-millisecond), kept in memory, served as JSON. Single file change = update one node, broadcast event. No rebuild.
Porting cost
The Rust port is roughly:
walk_docs(): directory walk + frontmatter parse (gray_matter-like →serde_yaml) → ~80 LOCbuildNavTree+findNode+getNavSubtreeequivalents → ~100 LOC (all pure tree manipulation, no fancy deps)sidebars.tsresolution: if you keep the config as TS, you need to either (a) port it to asidebars.toml/sidebars.jsonRust reads, or (b) eval JS — option (a) is cleaner. ~50 LOC for the resolver.
Total: ~200–300 LOC of pure Rust. No external service, no extra script. axum handles serving.
Updates flow end-to-end
- User edits
~/.claude/skills/foo/SKILL.md notifywatcher fires- Rust re-parses that one file → updates content cache + nav tree node
- axum SSE broadcasts:
{"type": "content", "slug": "claude-skills/foo"}and{"type": "nav"} - Client: refetches
/api/nav-tree.jsonand replaces the open page’s content fragment via fetch — no full reload, no flash, no spinner
Net: the sidebar isn’t a blocker for Path B — it actually gets cleaner than what you have now, because the tree becomes a long-lived in-memory structure instead of being recomputed on every Astro build.
何をしているかちゃんと見えた — buildNavTreeがドキュメント + frontmatterを走査し、sidebars.tsのサイドバー設定が自動生成または明示的な順序に従う、そしてSidebarTreeはすでにclient:loadなReactコンポーネント。良いニュース: これはaxumで簡単にできて、別のスクリプトは要らない。
サイドバーロジックが実際にどこにあるか
| 部分 | 現在どこで動くか | 何をするか |
|---|---|---|
buildNavTree(docs, lang, categoryMeta) | Astroビルド時 | docs collectionを走査 → NavNodeのツリー |
buildSidebarForSection | Astroビルド時 | sidebars.tsのオーバーライドを適用または自動生成 |
<Sidebar /> astroコンポーネント | Astroビルド時、ページごと | 初期HTMLとReactハイドレーション用propsをインライン化 |
<SidebarTree client:load /> | ブラウザ | 実際のインタラクティブUIをレンダリング(開閉、現在地) |
サイドバーはハイドレーション後にすでにクライアント側でレンダリングされている。Astroは初期HTMLとシードpropsを提供しているだけ。
Path Bでの推奨形状
axumは/api/nav-tree.jsonという単一エンドポイントを配信し、既存のReactコンポーネントがそれを消費する。
Rust(メモリ内)
├── walk_docs() → NavNodeツリー(buildNavTreeの移植)
├── apply_sidebar_config() → sidebars.tsの解決ロジックの移植
└── notifyウォッチャー → 変更で再走査 → SSEでブロードキャスト
axumルート
├── GET /api/nav-tree.json → キャッシュされたツリーを配信
├── GET /__reload (SSE) → "reload"または"nav-changed"を発火
└── GET /<page> → 静的シェル + コンテンツフラグメントクライアント側:
- バンドルされた
SidebarTreeReactコンポーネント(Astroが事前ビルドしたシェルにコンパイルされている)は、Astroのpropsとして受け取る代わりに、マウント時に/api/nav-tree.jsonをfetchする - SSEイベント → 再fetch + 再レンダリング(サイドバーのみの変更でページリロード不要)
なぜ現在のセットアップより単純か
- 今日: すべてのAstroビルドがすべてのドキュメントを再走査し、サイドバーpropsを再シリアライズし、すべてのページに埋め込む → フルリビルド = 重い
- Path B: ツリーはRustで起動時に一度構築され(サブミリ秒)、メモリに保持され、JSONとして配信される。1ファイル変更 = 1ノード更新、イベントブロードキャスト。リビルドなし。
移植コスト
Rust移植はおおよそ:
walk_docs(): ディレクトリ走査 + frontmatterパース(gray_matter相当 →serde_yaml) → 約80 LOCbuildNavTree+findNode+getNavSubtree相当 → 約100 LOC(純粋なツリー操作、特殊な依存なし)sidebars.tsの解決: 設定をTSのまま保つなら、(a)sidebars.toml/sidebars.jsonに移植してRustで読む、(b) JSをevalする、のいずれか — オプション(a)の方がクリーン。リゾルバで約50 LOC。
合計: 純粋なRust 約200-300 LOC。外部サービス不要、追加スクリプト不要。axumが配信を担う。
エンドツーエンドの更新フロー
- ユーザーが
~/.claude/skills/foo/SKILL.mdを編集 notifyウォッチャーが発火- Rustがその1ファイルを再パース → コンテンツキャッシュとnavツリーノードを更新
- axum SSEがブロードキャスト:
{"type": "content", "slug": "claude-skills/foo"}と{"type": "nav"} - クライアント:
/api/nav-tree.jsonを再fetchし、開いているページのコンテンツフラグメントをfetch経由で差し替える — フルリロードなし、フラッシュなし、スピナーなし
要するに: サイドバーはPath Bのブロッカーではない — むしろ今より_クリーン_になる。なぜなら、ツリーがAstroビルドごとに再計算される代わりに、長寿命のメモリ内構造になるから。
Astro代替をRustで作れるか
All right, as for me, I’m already feeling the Path B is the way to go. our CCResDoc.app, it’s enought. But let me ask extra question. It sounds that it might be also possible that we can make alternative of Astro by using Rust. Is that kind of things possible?
Astro has many good features, but for the complicated things on build time, it seems that we can do it with Rust, and it will be faster. If we use Cloudflare, it’s also possible. And the maybe complicated thing is about mdx. JSX in markdown was developed in Node.js base world. But is it possible for Rust?
OK、自分としてはもうPath Bで行く感触。CCResDoc.appはこれで十分。でも追加の質問を1つ。RustでAstroの代替を作れる可能性もありそうに聞こえる。そういうことは可能?
Astroには良い機能がたくさんあるが、ビルド時の複雑な処理についてはRustでできて、その方が速そう。Cloudflareを使うならそれも可能。複雑になりそうなのはmdx。マークダウン中のJSXはNode.jsベースの世界で開発されたものだが、Rustで可能?
Short answer: yes, technically feasible, and the ecosystem is moving that way already — but for your specific case (CCResDoc) it’d be over-investment. Let me unpack.
What’s already Rust-based in the JS toolchain
The “Rust replacing JS build tools” trend is well past theoretical:
| Tool | Replaces | Status |
|---|---|---|
| SWC | Babel (JSX/TS/JS transforms, including MDX) | Used by Next.js, Vercel — production |
| Turbopack | Webpack | Vercel’s bundler, written in Rust |
| Rspack | Webpack | ByteDance, Rust |
| Biome | ESLint + Prettier | Rust |
| Lightning CSS | PostCSS + cssnano | Rust |
| Oxc | ESLint + Babel + Prettier (full toolchain) | Rust |
| Farm | Vite | Rust + Node |
So an Astro-equivalent in Rust isn’t a moonshot — most of the heavy machinery already exists in Rust crates.
MDX in Rust — the real question
MDX has two parts: parsing and executing.
Parsing (markdown + JSX into an AST):
markdown-rshas experimental MDX support — produces mdast nodes includingMdxJsxFlowElementandMdxJsxTextElementswc_ecma_parserparses the JS/JSX expressions inside- This part is solved — you can parse
<Tabs><Tab label="...">cleanly in Rust
Executing (JSX → HTML, with React-style component composition):
- This is where it gets philosophical. JSX is defined by the JavaScript ecosystem —
<Foo />means “call the Foo function with these props.” That implies a JS runtime. - Three real approaches:
| Approach | How | Tradeoff |
|---|---|---|
| Embed JS engine | deno_core or rusty_v8 evaluates compiled JSX at build time | Works for any React/Preact component, but ships V8 (~30MB) |
| Static component registry | Define a fixed set of components implemented in pure Rust. JSX in MDX → match name → call render | No JS runtime, very fast, but only predefined components |
| AST-driven shortcodes | Treat <Component> as a structured directive, render via templates | Same constraint as above, but template-based instead of code-based |
For docs sites, approach 2 or 3 is usually enough — you don’t actually need arbitrary user JSX, you need a known set of doc primitives. Hugo/Zola/mdbook all work this way and they’re not crippled.
If you want true “any React component works” MDX, you need the embedded JS engine. SWC + deno_core makes that achievable but the binary size and complexity climb fast.
Cloudflare angle
Doesn’t really change the calculus:
- Cloudflare Pages: static hosting. Build whatever you want (Rust SSG, Astro, Hugo) → upload
dist/. The build tool is irrelevant to Pages. - Cloudflare Workers: V8 isolates. Rust compiles to WASM via
wasm-bindgen+workers-rs. You can run a Rust SSG as a Worker, generating HTML on each request (or use Worker KV/D1 for data).
So Cloudflare is a deploy target, not a constraint. Rust → WASM on Workers is well-trodden (the workers-rs ecosystem is solid).
Honest read for CCResDoc
You don’t need to replace Astro. You need a focused Rust SSG that handles your use case:
- File-based routing for
~/.claude/content - Markdown rendering with your specific custom plugins (admonitions, code titles, heading links, mermaid passthrough)
- Frontmatter parsing
- Sidebar tree generation
- Search index generation
- Static shell layout (one or two templates)
- File watch + SSE live-reload
That’s ~1500–3000 LOC of Rust — a focused project, weeks not months. You’d be writing roughly the same code Astro runs internally, minus the parts you don’t use (no Vite, no JSX runtime if you skip it, no asset pipeline beyond copying the prebuilt shell).
If you ever want to extract this and make a “Rust Astro for docs sites” later, the focused CCResDoc renderer is a great seed. But starting with “build an Astro alternative” before having one app working would be premature.
短い答え: はい、技術的には実現可能、エコシステムはすでにそちらに動いている — ただし、あなたの具体的なケース(CCResDoc)にとっては過剰投資。順を追って説明する。
JSツールチェーンですでにRustベースになっているもの
「RustがJSビルドツールを置き換える」トレンドは理論上の話をはるかに超えている:
| ツール | 置き換え先 | 状況 |
|---|---|---|
| SWC | Babel(JSX/TS/JS変換、MDX含む) | Next.js、Vercelで使用 — 本番 |
| Turbopack | Webpack | Vercelのバンドラ、Rustで書かれている |
| Rspack | Webpack | ByteDance、Rust |
| Biome | ESLint + Prettier | Rust |
| Lightning CSS | PostCSS + cssnano | Rust |
| Oxc | ESLint + Babel + Prettier(フルツールチェーン) | Rust |
| Farm | Vite | Rust + Node |
なので、Astro相当をRustで作るのは月面着陸ではない — 重い機械の大部分はすでにRustクレートとして存在する。
Rust上のMDX — 本当の質問
MDXには2つのパートがある: パースと実行。
パース(マークダウン + JSXからAST):
markdown-rsは実験的なMDXサポートを持つ —MdxJsxFlowElementとMdxJsxTextElementを含むmdastノードを生成swc_ecma_parserが中のJS/JSX式をパース- ここは解決済み —
<Tabs><Tab label="...">をRustでクリーンにパースできる
実行(JSX → HTML、Reactスタイルのコンポジションあり):
- ここが哲学的になる。JSXはJavaScriptエコシステムによって_定義_されている —
<Foo />は「Foo関数をこれらのpropsで呼ぶ」という意味。これはJSランタイムを暗示する。 - 現実的な3つのアプローチ:
| アプローチ | やり方 | トレードオフ |
|---|---|---|
| JSエンジンを埋め込む | deno_coreやrusty_v8がコンパイル済みJSXをビルド時に評価 | あらゆるReact/Preactコンポーネントが動くが、V8を同梱(約30MB) |
| 静的コンポーネントレジストリ | 純粋なRustで実装された固定のコンポーネントセットを定義。MDXのJSX → 名前マッチ → renderを呼ぶ | JSランタイム不要、非常に高速、ただし定義済みコンポーネントのみ |
| AST駆動ショートコード | <Component>を構造化ディレクティブとして扱い、テンプレート経由でレンダリング | 上と同じ制約、ただしコードベースではなくテンプレートベース |
ドキュメントサイトには、アプローチ2または3で通常は十分 — 任意のユーザーJSXは実際には必要なく、既知のドキュメントプリミティブのセットが必要なだけ。Hugo/Zola/mdbookはすべてこの方式で動作しており、それで困っていない。
「あらゆるReactコンポーネントが動く」真のMDXが欲しいなら、埋め込みJSエンジンが必要。SWC + deno_coreで実現可能だが、バイナリサイズと複雑さが急速に増す。
Cloudflareの観点
判断に大きな影響はない:
- Cloudflare Pages: 静的ホスティング。何でもビルドしてOK(Rust SSG、Astro、Hugo) →
dist/をアップロード。ビルドツールはPagesと無関係。 - Cloudflare Workers: V8アイソレート。Rustは
wasm-bindgen+workers-rs経由でWASMにコンパイルされる。Rust SSGをWorkerとして動かし、リクエストごとにHTMLを生成できる(あるいはWorker KV/D1をデータに使う)。
なのでCloudflareはデプロイ先であって制約ではない。Rust → WASM on Workersはよく踏まれた道(workers-rsエコシステムは堅実)。
CCResDocについての正直な所感
Astroを置き換える必要はない。あなたのユースケースを扱うフォーカスされたRust SSGが必要:
~/.claude/コンテンツのファイルベースルーティング- 特定のカスタムプラグイン付きのマークダウンレンダリング(注記、コードタイトル、見出しリンク、mermaidパススルー)
- frontmatterパース
- サイドバーツリー生成
- 検索インデックス生成
- 静的シェルレイアウト(1-2テンプレート)
- ファイル監視 + SSEライブリロード
それはRust 約1500-3000 LOC — フォーカスされたプロジェクト、月単位ではなく週単位。Astroが内部で実行しているのとほぼ同じコードを書くことになるが、使わない部分を除く(Vite不要、JSXランタイムをスキップするなら不要、事前ビルドされたシェルをコピーする以外のアセットパイプライン不要)。
これを後で抽出して「ドキュメントサイト用のRust製Astro」にしたいなら、フォーカスされたCCResDocレンダラはよい種になる。ただし、1つのアプリも動く前から「Astro代替を作る」と始めるのは早計。
zudo-doc視点で考えるとどうか
Ah yes. it’s OK. I understood very well. what I was thining is zudo-doc. /refer-another-project zudo-doc
It’s my working doc base project. And it’s using astro. Maybe it’s enough. but if we can change the base to Rust for it, it sounds insteresing. in mdx import is not necessary in general case. putting React based hydration system inside mdx is enough for most cases in general dev. And it sounds more flexible that we seem to use SSR more easily without Astro.
ああそうそう。OK、よくわかった。考えていたのはzudo-doc。/refer-another-project zudo-docで見て。
これは自分のドキュメント基盤プロジェクト。Astroを使っている。これで十分かもしれない。でも、ベースをRustに変えられるなら面白そう。一般的なケースではmdxのimportは必要ない。mdxの中にReactベースのハイドレーションシステムを置けば、一般開発のほとんどのケースに十分。Astroなしの方がSSRをもっと簡単に使えそうで柔軟に思える。
Looked at zudo-doc. Your instinct is correct — and the data backs it up.
Real MDX-with-JSX usage in zudo-doc
I scanned all 89 MDX files. The pattern is striking:
- Globally-registered components (
<Info>,<Note>,<Tabs>,<TabItem>,<Details>,<CategoryNav>,<CategoryTreeNav>): used everywhere, no imports needed — they’re injected by the doc page wrapper - Actual
import X fromstatements in MDX: only 3 occurrences total across 89 files (and 2 of those are inside docs about importing — i.e., showing the syntax as content). Real arbitrary imports: ~1. - All custom remark/rehype plugins: TypeScript, in
packages/md-plugins/— about 10 plugins, all relatively small
So your “MDX import is not necessary in the general case” is empirically true for your work. The component model that matters is a fixed registry of doc primitives, not arbitrary JSX evaluation.
Preact islands — the real question
Your concern is valid: interactive islands need JS in the browser regardless of who renders the static shell. But Rust doesn’t need to understand Preact. The split is clean:
At build time (one-off bundling step):
- Bundle all Preact islands (
toc.tsx,sidebar-tree.tsx,theme-toggle.tsx, etc.) into a single JS bundle using SWC (Rust!) or esbuild. SWC hasswc_ecma_parser+swc_ecma_transforms_react— it can compile JSX/TSX to JS in pure Rust. - Output:
dist/assets/islands.js— a single bundle containing all islands and a tiny hydration runtime - This is essentially what Astro does, just with a faster bundler
At render time (Rust):
-
When MDX has
<Toc client:load />(or equivalent), Rust emits a placeholder:<div data-island="toc" data-props='{"...":"..."}' data-strategy="load"></div> -
Plus a
<script type="module" src="/assets/islands.js"></script>in the page
At client time (browser, plain JS):
-
The bundled
islands.jsruns:document.querySelectorAll("[data-island]").forEach((el) => { const Component = registry[el.dataset.island]; const props = JSON.parse(el.dataset.props); render(<Component {...props} />, el); });
So Rust never sees Preact. It just emits HTML placeholders + ships a JS bundle. The bundle is built once per release using SWC.
SSR angle — you’re right that it gets easier
- Cloudflare Workers: Compile zudo-doc-rs to WASM via
wasm-bindgen+workers-rs. The whole renderer runs in a Worker. Each request → render markdown → return HTML. Edge SSR with sub-10ms render times. - Self-hosted axum: Same crate, native binary. Long-running server, optional incremental cache, file watcher for content changes.
- Static SSG: Same crate, write-to-disk mode →
dist/→ upload to Pages.
One Rust crate, three deploy modes. No adapter ecosystem to maintain.
Honest scope
For zudo-doc specifically — you’re an experienced markdown-rs user from mdx-formatter — I’d estimate:
| Phase | Time | Risk |
|---|---|---|
| Markdown parser + 10 md-plugin ports | 2–3 weeks | Low (pattern proven in mdx-formatter) |
| Component registry (admonitions, tabs, details, nav components) | 1 week | Low |
| Content collections + Zod-equivalent validation (serde + JSON Schema) | 3–5 days | Low |
| Sidebar/nav tree + i18n routing | 3–5 days | Low |
| Search index generator | 2 days | Low |
| Static shell HTML + Tailwind v4 build (keep Tailwind CLI) | 2 days | Low |
| Preact island bundling via SWC | 1 week | Medium — SWC API has a learning curve |
| Dev server (axum + notify + SSE + island rebundle on change) | 1 week | Low |
| Cloudflare Workers (WASM) target | 1 week | Medium — first-time WASM target |
create-zudo-doc-rs scaffold tool | 3–5 days | Low |
| Total focused effort | 2–3 months for one experienced person |
A reasonable middle path: build the Rust SSG for CCResDoc first (smaller scope, no monorepo, no i18n, no scaffold tool, no Tauri-Astro coupling). Use that as the proving ground. If it feels right after a few weeks of using it, port the patterns to zudo-doc-rs. If it feels like too much owned surface area, you’ve still solved CCResDoc.
zudo-docを見た。あなたの直感は正しい — そしてデータがそれを裏付ける。
zudo-docにおける実際のMDX-with-JSX使用状況
89のMDXファイルすべてをスキャンした。パターンは明確:
- グローバル登録されたコンポーネント(
<Info>、<Note>、<Tabs>、<TabItem>、<Details>、<CategoryNav>、<CategoryTreeNav>): あらゆる場所で使われるが、importは不要 — ドキュメントページラッパーが注入している - MDX内の実際の
import X from文: 89ファイル全体でわずか3回(うち2つはimportに_ついて_のドキュメント内 — 構文をコンテンツとして示している)。本物の任意import: 約1。 - すべてのカスタムremark/rehypeプラグイン: TypeScript、
packages/md-plugins/内 — 約10個のプラグイン、いずれも比較的小さい
なので「一般的なケースではMDX importは不要」というあなたの主張は、あなたの仕事に関しては実証的に正しい。重要なコンポーネントモデルは、任意JSXの評価ではなく、ドキュメントプリミティブの固定レジストリ。
Preactアイランド — 本当の問題
あなたの懸念は妥当: インタラクティブなアイランドは、誰が静的シェルをレンダリングするかに関係なく、ブラウザでJSが必要。しかしRustはPreactを_理解_する必要はない。分離はクリーン:
ビルド時(1回だけのバンドリングステップ):
- すべてのPreactアイランド(
toc.tsx、sidebar-tree.tsx、theme-toggle.tsxなど)を単一のJSバンドルにバンドルする。SWC(Rust!)またはesbuildを使う。SWCはswc_ecma_parser+swc_ecma_transforms_reactを持つ — 純粋なRustでJSX/TSXをJSにコンパイルできる。 - 出力:
dist/assets/islands.js— すべてのアイランドと小さなハイドレーションランタイムを含む単一バンドル - これは本質的にAstroがやっていることと同じで、ただバンドラがより速い
レンダリング時(Rust):
-
MDXに
<Toc client:load />(または同等のもの)があるとき、Rustはプレースホルダを出力する:<div data-island="toc" data-props='{"...":"..."}' data-strategy="load"></div> -
加えてページに
<script type="module" src="/assets/islands.js"></script>を入れる
クライアント時(ブラウザ、プレーンJS):
-
バンドルされた
islands.jsが実行される:document.querySelectorAll("[data-island]").forEach((el) => { const Component = registry[el.dataset.island]; const props = JSON.parse(el.dataset.props); render(<Component {...props} />, el); });
なのでRustはPreactを見ない。HTMLプレースホルダを出力してJSバンドルを配信するだけ。バンドルはリリースごとに1回SWCを使ってビルドされる。
SSRの観点 — 確かに簡単になる
- Cloudflare Workers: zudo-doc-rsを
wasm-bindgen+workers-rs経由でWASMにコンパイル。レンダラ全体がWorkerで動作。リクエストごとにマークダウンをレンダリング → HTMLを返す。10ms未満のエッジSSR。 - セルフホストaxum: 同じクレート、ネイティブバイナリ。長寿命サーバー、オプションのインクリメンタルキャッシュ、コンテンツ変更用ファイルウォッチャー。
- 静的SSG: 同じクレート、ディスク書き込みモード →
dist/→ Pagesにアップロード。
1つのRustクレート、3つのデプロイモード。維持すべきアダプタエコシステムなし。
正直なスコープ
zudo-doc特化で見ると — mdx-formatterからmarkdown-rsの経験者であるあなたなら — 見積もりは:
| フェーズ | 時間 | リスク |
|---|---|---|
| マークダウンパーサ + 10個のmd-plugin移植 | 2-3週間 | 低(mdx-formatterで実証済み) |
| コンポーネントレジストリ(注記、tabs、details、ナビコンポーネント) | 1週間 | 低 |
| Content collections + Zod相当のバリデーション(serde + JSON Schema) | 3-5日 | 低 |
| サイドバー/ナビツリー + i18nルーティング | 3-5日 | 低 |
| 検索インデックス生成 | 2日 | 低 |
| 静的シェルHTML + Tailwind v4ビルド(Tailwind CLI維持) | 2日 | 低 |
| SWC経由のPreactアイランドバンドリング | 1週間 | 中 — SWC APIに学習曲線がある |
| 開発サーバー(axum + notify + SSE + 変更時アイランド再バンドル) | 1週間 | 低 |
| Cloudflare Workers(WASM)ターゲット | 1週間 | 中 — 初めてのWASMターゲット |
create-zudo-doc-rsスキャフォールドツール | 3-5日 | 低 |
| 集中作業の合計 | 経験者1人で2-3か月 |
合理的な中間パス: まずCCResDoc用にRust SSGを作る(小さいスコープ、モノレポなし、i18nなし、スキャフォールドツールなし、Tauri-Astro結合なし)。それを実証の場として使う。数週間使ってみて感触が良ければ、パターンをzudo-doc-rsに移植する。所有面が大きすぎると感じたら、それでもCCResDocは解決できている。
zfbの設計、ファイルフォーマット
awesome. how about this. I made zudo-front-builder repo /Users/takazudo/repos/myoss/zfb Let’s call this zfb. This is the markdown base basically SSG, but can do something else too like framework. But with this idea, how can we handle .astro like file?
My idea is handle them like JSX with frontmatter. Astro has scoped HTML + CSS + JS combined format. but honestly I don’t like that. My favorite is all in one Next.js style. but Next.js is almost overkill for most websites nowadays. so my idea is… frontmatter based SSG centric build system. But how should we handle dynamic routing like pager based dynamic content that made from data? It seems that we need to think more deeply, but how about this idea? Or is there any good ones for this?
すばらしい。こういうのはどう。zudo-front-builderというリポジトリを作った(個人OSS下)。zfbと呼ぶことにする。これは基本的にマークダウンベースのSSGだが、フレームワーク的な他のこともできる。このアイデアで、.astroのようなファイルはどう扱う?
自分のアイデアはfrontmatter付きJSXとして扱うこと。AstroにはスコープされたHTML + CSS + JSが組み合わさったフォーマットがあるが、正直好きではない。自分の好みはNext.jsのオールインワン形式。ただ、Next.jsは今やほとんどのウェブサイトには過剰。なので自分のアイデアは…frontmatterベースのSSG中心ビルドシステム。ただ、データから作るページネーション系の動的コンテンツみたいな動的ルーティングはどう扱うべき?もっと深く考える必要がありそうだが、このアイデアどう思う?それとも何かいいものがある?
zfb is empty so we’re designing from scratch. Good place to be. Let me unpack each part.
”JSX with frontmatter” as a file format
This is a sensible direction. The trick is just deciding where the metadata lives:
Option 1: YAML frontmatter on .tsx (your idea) — needs custom parser preprocessing.
Option 2: export const meta = {...} on .tsx — standard TS, no preprocessing needed:
export const meta = {
title: "About",
layout: DefaultLayout,
draft: false,
};
export default function About() {
return <h1>About</h1>;
}Option 3: Separate file like about.tsx + about.meta.ts.
For zfb, Option 2 is the cleanest — TypeScript already parses it, no special handling, IDE understands it. Astro and Next both moved this direction over time.
Closest existing reference: Iles (Vite + Vue/Preact + frontmatter on .mdx, exports on .vue/.tsx). Worth a look if you haven’t seen it.
For dynamic routing from data: a paths() function pattern works well — page exports paths(), framework calls it during build, generates one HTML per returned entry.
zfbは空なのでゼロから設計する。良い場所にいる。各部分を順に解説する。
ファイルフォーマットとしての「frontmatter付きJSX」
これは合理的な方向。トリックはメタデータをどこに置くかを決めるだけ:
オプション1: .tsxにYAML frontmatter(あなたのアイデア) — カスタムパーサの前処理が必要。
オプション2: .tsxにexport const meta = {...} — 標準TS、前処理不要:
export const meta = {
title: "About",
layout: DefaultLayout,
draft: false,
};
export default function About() {
return <h1>About</h1>;
}オプション3: 別ファイル、たとえばabout.tsx + about.meta.ts。
zfbには、オプション2が一番クリーン — TypeScriptがすでにパースする、特別な処理不要、IDEが理解する。AstroもNextも時間とともにこの方向に動いてきた。
最も近い既存リファレンス: Iles(Vite + Vue/Preact + .mdxにfrontmatter、.vue/.tsxにexports)。見たことがなければ価値がある。
データからの動的ルーティングについて: paths()関数パターンがうまく動く — ページがpaths()をエクスポートし、フレームワークがビルド中にそれを呼び、返された各エントリに対して1つのHTMLを生成する。
TSX全振りでいく
Ah I’m feeling what I feel by myself. And I checked Iles, thanks. By checking Iles, I felt clearly. I want to write all by tsx… What I feel on Astro is that I don’t need so wide variety of templating things. Everything on TSX is easy, and understandable. and React or Preact is very enought. very…
And about CSS, my personal opinion is all people should rely on Tailwind, but there are many cases that we need global CSS, for those cases, CSS Modules is almost must-have in nowadays development. If we don’t have it, we must go BEM way in old days.
And about format, as you say, we should not go our own format. and about frontmatter + tsx, I feel now we can do it on tsx. when we want to write the page with tsx, I feel we rarely needs frontmatter. it can be written by general exports. And about the contnet, we’re sure that we’ll use md or mdx. so there’s no way to go .zfb format. and maybe we also don’t need .zfb.tsx too. What do you think?
ああ、自分で感じていることを感じている。Ilesを見た、ありがとう。Ilesを見てはっきりした。全部tsxで書きたい…。Astroについて感じるのは、そんなに幅広いテンプレート機能は要らないということ。TSXで全部やるのは簡単で理解しやすい。ReactかPreactで十分すぎる。とても…。
CSSについては、個人的にはみんなTailwindに頼るべきと思っているが、グローバルCSSが必要なケースは多い。そのケースにはCSS Modulesが今の開発ではほぼマストハブ。それがないと、昔のBEM方式に戻ることになる。
フォーマットについては、おっしゃる通り、自前フォーマットは作るべきでない。frontmatter + tsxについては、tsxの上でできると思う。tsxでページを書きたいとき、frontmatterはほとんど要らないと感じる。一般のexportsで書ける。コンテンツについては、md か mdxを使うことは確か。なので.zfbフォーマットに行く道はない。たぶん.zfb.tsxも要らない。どう思う?
Yes, I agree — this is a coherent design and it sidesteps the real pain points of Astro. Let me confirm the implications and flag the few remaining forks.
What you’ve decided (and why it works)
| Decision | Why it’s right |
|---|---|
| All TSX, no custom format | Standard tooling everywhere — TS server, Prettier, ESLint, VS Code — zero custom plugins to maintain. Onboarding is “do you know React/Preact?” |
| Preact (or React) for everything | One mental model. JSX everywhere. Server-rendered components are just functions returning JSX. Client-side islands are just functions returning JSX. |
meta export on TSX, frontmatter on MD/MDX | Matches the actual file-type division: prose files use YAML (writer-friendly), code files use exports (type-safe, no preprocessing). |
| Tailwind + CSS Modules | Tailwind for 95% of styling. CSS Modules for the cases Tailwind doesn’t fit (animations, complex selectors, third-party overrides, true globals). |
No .zfb / .zfb.tsx | Custom extensions only pay off if you change the language semantics meaningfully. Frontmatter-on-tsx wasn’t worth that cost. |
Concrete shape this implies
zfb-project/
├── content/ # md/mdx with YAML frontmatter
│ └── blog/post-1.md
├── pages/ # tsx with `meta` export
│ ├── index.tsx → /
│ ├── about.tsx → /about
│ └── blog/
│ ├── index.tsx → /blog (listing)
│ └── [slug].tsx → /blog/:slug (template for a post)
├── layouts/default.tsx
├── components/
├── styles/global.css # @import "tailwindcss"; + your globals
└── zfb.config.tsA representative TSX page:
// pages/blog/[slug].tsx
import styles from "./post.module.css";
import DefaultLayout from "@/layouts/default";
import { getCollection } from "zfb";
export const meta = {
layout: DefaultLayout,
};
export async function paths() {
const posts = await getCollection("blog");
return posts.map((p) => ({
params: { slug: p.slug },
props: { post: p },
}));
}
export default function Post({ post }: { post: BlogPost }) {
return (
<article className={styles.article}>
<h1>{post.title}</h1>
<post.Body />
</article>
);
}そう、同意する — これは一貫した設計で、Astroの本当の痛点を回避している。含意を確認し、残るいくつかの分岐を指摘する。
あなたが決めたこと(そしてなぜ正しいか)
| 決定 | なぜ正しいか |
|---|---|
| 全部TSX、カスタムフォーマットなし | あらゆる場所で標準ツール — TSサーバー、Prettier、ESLint、VS Code — メンテすべきカスタムプラグインがゼロ。オンボーディングは「React/Preactを知っている?」だけ。 |
| Preact(またはReact)ですべて | 単一のメンタルモデル。あらゆる場所でJSX。サーバーレンダリングコンポーネントは単にJSXを返す関数。クライアント側アイランドも単にJSXを返す関数。 |
TSXにmetaエクスポート、MD/MDXにfrontmatter | 実際のファイルタイプの分割と一致する: prose系はYAMLを使う(writer-friendly)、コード系はexportsを使う(型安全、前処理なし)。 |
| Tailwind + CSS Modules | スタイリングの95%にTailwind。Tailwindが合わないケース(アニメーション、複雑なセレクタ、サードパーティのオーバーライド、本物のグローバル)にCSS Modules。 |
.zfb / .zfb.tsxなし | カスタム拡張子は言語のセマンティクスを意味のある形で変えるときだけ報われる。frontmatter-on-tsxはそのコストに見合わなかった。 |
これが含意する具体的な形
zfb-project/
├── content/ # YAML frontmatter付きのmd/mdx
│ └── blog/post-1.md
├── pages/ # `meta`エクスポート付きのtsx
│ ├── index.tsx → /
│ ├── about.tsx → /about
│ └── blog/
│ ├── index.tsx → /blog(一覧)
│ └── [slug].tsx → /blog/:slug(投稿のテンプレート)
├── layouts/default.tsx
├── components/
├── styles/global.css # @import "tailwindcss"; + あなたのグローバル
└── zfb.config.ts代表的なTSXページ:
// pages/blog/[slug].tsx
import styles from "./post.module.css";
import DefaultLayout from "@/layouts/default";
import { getCollection } from "zfb";
export const meta = {
layout: DefaultLayout,
};
export async function paths() {
const posts = await getCollection("blog");
return posts.map((p) => ({
params: { slug: p.slug },
props: { post: p },
}));
}
export default function Post({ post }: { post: BlogPost }) {
return (
<article className={styles.article}>
<h1>{post.title}</h1>
<post.Body />
</article>
);
} データ参照、Eleventy的な扱い
awesome. agreed for those. The implicit layout is really a pain. it’s almost hard to recognize for the new users. And your Vite + something is close to my feeling. For example, I create many apps with AIs, most of those cases are Vite + React, almost Preact is enough, but I choose React because it’s just for me app mostly. the zfb is almost similar. Ah I don’t need rich way that Next.js has, I want just SSG + Markdown + something expandable.
with this case, if we want to use data from JSON or API, is that possible with eleventy way? The eleventy’s data ref way was niece. But my dislike point of eleventy was the data ref is also implicit. We have no idea what var we can use on templates.
すばらしい、それでは同意。暗黙のレイアウトは本当に痛い。新規ユーザーには認識しづらい。あなたのVite + 何々という感覚は自分の感覚に近い。たとえば、自分はAIで多くのアプリを作っているが、ほとんどのケースはVite + Reactで、ほぼPreactで十分だが、Reactを選ぶのはほぼ自分用アプリだから。zfbもほぼ同じ。Next.jsが持つリッチな道は要らない、ただSSG + マークダウン + 何か拡張できるものが欲しい。
このケースで、JSONやAPIからデータを使いたいとき、eleventy的なやり方で可能?eleventyのデータ参照のやり方は良かった。ただ嫌な点は、データ参照も暗黙だったこと。テンプレート上でどの変数が使えるのかわからない。
Yes — and the “explicit but not boilerplate-y” sweet spot is one of the cleanest things TypeScript gives you. The Eleventy data cascade is genuinely nice, and you can keep the spirit while losing the magic.
The Eleventy idea, made explicit
The good part of Eleventy’s data cascade: “data is just stuff in files; the SSG figures out which pages get which data.”
The bad part: “which variables are bound in this template? Read the docs, the directory structure, the frontmatter, and three .11tydata.js files.”
In zfb, the cure is the simplest possible: plain TS imports. Data files are TS modules. Pages import what they need. TypeScript shows you exactly what’s available.
Static JSON data
// data/products.json
[{ "id": "p1", "name": "Foo" }, { "id": "p2", "name": "Bar" }]// pages/products/[id].tsx
import products from "@/data/products.json";
export async function paths() {
return products.map((p) => ({
params: { id: p.id },
props: { product: p },
}));
}Build-time API fetching (top-level await)
// data/posts.ts
const res = await fetch("https://cms.example.com/api/posts");
export const posts: Post[] = await res.json();Top-level await runs once per build, results cached in the module graph.
Directory-level defaults (the cascade equivalent)
Eleventy’s directory data file → in zfb, a colocated _data.ts and explicit import:
pages/blog/
├── _data.ts # defaults for blog pages
├── [slug].tsx
└── index.tsxThe cascade is composition via imports. No new mechanism, no magic — just modules.
Pagination + tags + listings
// pages/blog/page/[page].tsx — pagination
import { paginate } from "zfb";
import { posts } from "@/data/posts";
export const paths = paginate({ items: posts, pageSize: 10 });// pages/tags/[tag].tsx — tag listings
import { posts } from "@/data/posts";
export async function paths() {
const tags = new Set(posts.flatMap((p) => p.tags ?? []));
return [...tags].map((tag) => ({
params: { tag },
props: { tag, posts: posts.filter((p) => p.tags?.includes(tag)) },
}));
}Same pattern — data import + JSX. Sitemap, RSS, llms.txt, llms-full.txt — all just TSX pages.
What you lose vs Eleventy
Honestly, very little. The only real cost is one extra import line per page. The win is enormous: every variable in scope is traceable, type-checked, and refactor-friendly.
そう — 「明示的だがボイラープレートではない」のスイートスポットは、TypeScriptが与えてくれる中で最もクリーンなものの1つ。Eleventyのdata cascadeは本当に良くて、マジックを失いつつそのスピリットを残せる。
明示的にしたEleventyのアイデア
Eleventyのdata cascadeの良い部分: 「データはファイル内のスタッフでしかない、どのページがどのデータを得るかはSSGが解決する」。
悪い部分: 「このテンプレート上でどの変数がバインドされている?ドキュメントを読み、ディレクトリ構造を読み、frontmatterを読み、3つの.11tydata.jsファイルを読め」。
zfbでの治療は可能な限り単純: プレーンなTS import。データファイルはTSモジュール。ページは必要なものをimport。TypeScriptは何が利用可能かをそのまま示す。
静的JSONデータ
// data/products.json
[{ "id": "p1", "name": "Foo" }, { "id": "p2", "name": "Bar" }]// pages/products/[id].tsx
import products from "@/data/products.json";
export async function paths() {
return products.map((p) => ({
params: { id: p.id },
props: { product: p },
}));
}ビルド時のAPI fetching(トップレベルawait)
// data/posts.ts
const res = await fetch("https://cms.example.com/api/posts");
export const posts: Post[] = await res.json();トップレベルawaitはビルドごとに1回走り、結果はモジュールグラフにキャッシュされる。
ディレクトリレベルのデフォルト(cascade相当)
Eleventyのディレクトリデータファイル → zfbでは、コロケートされた_data.tsと明示的なimport:
pages/blog/
├── _data.ts # blogページのデフォルト
├── [slug].tsx
└── index.tsxcascadeはimports経由のコンポジション。新しいメカニズムなし、マジックなし — モジュールだけ。
ページネーション + タグ + 一覧
// pages/blog/page/[page].tsx — ページネーション
import { paginate } from "zfb";
import { posts } from "@/data/posts";
export const paths = paginate({ items: posts, pageSize: 10 });// pages/tags/[tag].tsx — タグ一覧
import { posts } from "@/data/posts";
export async function paths() {
const tags = new Set(posts.flatMap((p) => p.tags ?? []));
return [...tags].map((tag) => ({
params: { tag },
props: { tag, posts: posts.filter((p) => p.tags?.includes(tag)) },
}));
}同じパターン — データimport + JSX。サイトマップ、RSS、llms.txt、llms-full.txt — すべてただのTSXページ。
Eleventyに対して何を失うか
正直、ほとんど何も。実際のコストはページごとに1行のextra importだけ。勝ちは巨大: スコープ内のすべての変数が追跡可能、型チェックされる、リファクタしやすい。
Vite不要、Rustだけで
good. let’s do Rust way. focus on Rust first. but if we go the way, we need vite too? it bunldes JS and handles regenrated html files on the npm run dev mode?
良い。Rust方式でいこう。まずRustに集中。ただ、その道を行くとVite も必要?npm run devモードでJSをバンドルして、再生成されたHTMLファイルを扱うために?
No, you don’t need Vite if you commit to Rust from day one. Vite is doing a bunch of things, and each has a Rust-native replacement that’s good enough for zfb’s scope.
What Vite actually does, and Rust equivalents
| Vite responsibility | Rust equivalent | Maturity |
|---|---|---|
| TSX/JSX/TS → JS | SWC | Production-grade (used by Next.js, Turbopack) |
| CSS Modules + nesting + autoprefixing | lightningcss | Production-grade (Vite uses it under the hood already) |
| Tailwind processing | @tailwindcss/cli (shell out — Tailwind v4’s core is already Rust internally) | Production-grade |
| Asset imports (images, JSON) | Custom — file reads + hashing + copy. Maybe 100 LOC. | Trivial |
| Bundling islands (production) | esbuild as subprocess (recommended) or swc_bundler (Rust crate, less polished) | Both work; esbuild is bulletproof |
| Dev server | axum + notify + SSE | You’re already building this |
| HMR | Custom — but mostly unnecessary for SSG (see below) | N/A |
HMR — the question that decides the architecture
This is the one spot where Vite earns its keep for general-purpose web apps. But for an SSG, HMR is largely overkill, and you can replace it with “rebuild affected pages → reload” without users noticing.
| Dev iteration | What you need |
|---|---|
Edit content/blog/foo.md | Re-render that page → reload browser |
Edit pages/blog/[slug].tsx (template) | Re-render all blog posts → reload |
Edit components/counter.tsx (island) | Rebundle islands → reload |
Edit styles/global.css | Re-process CSS → swap <link> href, no reload |
Everything is “rebuild + full page reload,” which at Rust speeds (sub-50ms total) feels indistinguishable from HMR for content work.
The dev pipeline you’d actually build
zfb dev (Rust binary)
├── Watcher (notify)
│ ├── content/ → mark page dirty
│ ├── pages/ → mark page dirty (and dependents if it's a layout)
│ ├── data/ → mark all dependent pages dirty
│ ├── styles/ → mark CSS dirty
│ └── components/ → mark islands bundle dirty
│
├── Build pipeline (per dirty group)
│ ├── Pages: SWC compiles TSX → JS module → load → call default() → render to HTML
│ ├── Markdown: markdown-rs → mdast → custom plugins → hast → HTML fragment → wrap in template
│ ├── CSS: lightningcss processes globals + modules; tailwind CLI processes utilities
│ └── Islands: SWC compiles → esbuild bundles to dist/islands.js
│
├── axum server
│ ├── Serves static + rendered HTML from in-memory cache
│ ├── /islands.js + /styles.css from build outputs
│ └── /__reload SSE endpoint
│
└── Tiny browser script (~30 lines)
├── On "reload" event → location.reload()
└── On "css" event → swap <link> hrefThe honest scope of “no JS toolchain”
Pure Rust crates (compiled into the zfb binary):
swc_ecma_parser,swc_ecma_transforms_react,swc_ecma_minifiermarkdown_rs(orcomrak)lightningcssaxum,tokio,notifyserde,serde_yaml,serde_json
Subprocess calls (binaries shipped alongside or expected on user PATH):
esbuild— for the islands bundle. Single 8MB binary.tailwindcss(v4 CLI) — single binary.
So users don’t need Node installed. They run zfb build and Rust orchestrates the two embedded helper binaries.
What the user experience looks like
$ cargo install zfb # or download binary from release
$ zfb new my-site # scaffolds project
$ cd my-site
$ zfb dev # localhost:3000, file watching, SSE reload
$ zfb build # writes dist/That’s it. Tools that already do exactly this kind of all-Rust orchestration: Trunk, Astro (uses lightningcss + tailwindcss-oxide), Bun, Tailwind standalone CLI.
いいえ、初日からRustにコミットするならViteは要らない。Viteは多くのことをやっているが、それぞれにzfbのスコープに対して十分なRustネイティブの置き換えがある。
Viteが実際にやること、そしてRust相当
| Viteの責務 | Rust相当 | 成熟度 |
|---|---|---|
| TSX/JSX/TS → JS | SWC | プロダクショングレード(Next.js、Turbopackで使用) |
| CSS Modules + ネスト + autoprefixing | lightningcss | プロダクショングレード(Viteも内部ですでに使っている) |
| Tailwind処理 | @tailwindcss/cli(シェルアウト — Tailwind v4のコアはすでに内部的にRust) | プロダクショングレード |
| アセットimports(画像、JSON) | カスタム — ファイル読み込み + ハッシング + コピー。100行程度。 | 些細 |
| アイランドのバンドリング(プロダクション) | esbuildをサブプロセスとして(推奨)または swc_bundler(Rustクレート、よりラフ) | 両方動く、esbuildは堅牢 |
| 開発サーバー | axum + notify + SSE | すでに構築中 |
| HMR | カスタム — ただしSSGにはほとんど不要(下記参照) | N/A |
HMR — アーキテクチャを決める質問
これは汎用ウェブアプリでViteが価値を稼ぐ唯一の点。だがSSGでは、HMRは大体オーバーキルで、ユーザーが気づかない形で「影響を受けるページを再ビルド → リロード」に置き換えられる。
| Devイテレーション | 必要なもの |
|---|---|
content/blog/foo.mdを編集 | そのページを再レンダリング → ブラウザリロード |
pages/blog/[slug].tsx(テンプレート)を編集 | すべてのブログ投稿を再レンダリング → リロード |
components/counter.tsx(アイランド)を編集 | アイランドを再バンドル → リロード |
styles/global.cssを編集 | CSSを再処理 → <link>のhrefをスワップ、リロードなし |
すべて「再ビルド + フルページリロード」、Rustの速度(合計50ms未満)では、コンテンツ作業に関してはHMRと区別がつかない。
実際に構築する開発パイプライン
zfb dev(Rustバイナリ)
├── ウォッチャー(notify)
│ ├── content/ → ページをダーティとマーク
│ ├── pages/ → ページをダーティとマーク(レイアウトなら依存先も)
│ ├── data/ → 依存するすべてのページをダーティとマーク
│ ├── styles/ → CSSをダーティとマーク
│ └── components/ → アイランドバンドルをダーティとマーク
│
├── ビルドパイプライン(ダーティグループごと)
│ ├── ページ: SWCがTSXをコンパイル → JSモジュール → ロード → default()を呼ぶ → HTMLにレンダリング
│ ├── マークダウン: markdown-rs → mdast → カスタムプラグイン → hast → HTMLフラグメント → テンプレートでラップ
│ ├── CSS: lightningcssがグローバル + modulesを処理、tailwind CLIがユーティリティを処理
│ └── アイランド: SWCがコンパイル → esbuildが dist/islands.js にバンドル
│
├── axumサーバー
│ ├── 静的 + レンダリングされたHTMLをメモリ内キャッシュから配信
│ ├── /islands.js + /styles.css をビルド出力から
│ └── /__reload SSEエンドポイント
│
└── 小さなブラウザスクリプト(約30行)
├── "reload"イベントで → location.reload()
└── "css"イベントで → <link>のhrefをスワップ「JSツールチェーンなし」の正直なスコープ
純粋なRustクレート(zfbバイナリにコンパイルされる):
swc_ecma_parser、swc_ecma_transforms_react、swc_ecma_minifiermarkdown_rs(またはcomrak)lightningcssaxum、tokio、notifyserde、serde_yaml、serde_json
サブプロセス呼び出し(一緒に配布されるか、ユーザーのPATHにあると期待されるバイナリ):
esbuild— アイランドバンドル用。単一の8MBバイナリ。tailwindcss(v4 CLI) — 単一バイナリ。
なのでユーザーはNodeをインストールする必要なし。zfb buildを実行すれば、Rustが2つの埋め込みヘルパーバイナリをオーケストレートする。
ユーザー体験はこんな感じ
$ cargo install zfb # またはリリースからバイナリをダウンロード
$ zfb new my-site # プロジェクトをスキャフォールド
$ cd my-site
$ zfb dev # localhost:3000、ファイル監視、SSEリロード
$ zfb build # dist/に書き出しこれだけ。すでにこの種の全Rustオーケストレーションをやっているツール: Trunk、Astro(lightningcss + tailwindcss-oxideを使用)、Bun、Tailwindスタンドアロン CLI。
Tailwindのビルド形式
Can I ask about Tailwind? Does tailwind bakes all CSS in every page? or does it make global one CSS file, then load it?
Tailwindについて聞いていい?Tailwindは全ページのCSSを全部焼き込む?それともグローバルなCSSファイルを1つ作って、それをロードする?
One global CSS file, loaded on every page. That’s the model.
How it works
Tailwind scans your source files (pages/, components/, MDX content) and finds every class name you actually use (flex, text-red-500, md:grid-cols-3, etc.). It outputs a single CSS file containing only those utilities — typically 10–50KB gzipped for a real site. Every page links the same file:
<link rel="stylesheet" href="/assets/styles-abc123.css" />So your dist/ looks like:
dist/
├── assets/
│ └── styles-abc123.css ← one global CSS, used by all pages
├── index.html ← links it
├── about/index.html ← links it
└── blog/post-1/index.html ← links itWhy one global file (not per-page)
The shared-cache argument wins:
- First page visit → browser downloads the 30KB CSS once
- Every subsequent page → CSS is in cache, zero bytes transferred
- Total bytes for a 10-page browsing session: 30KB
- Per-page CSS would be ~5KB × 10 pages = 50KB downloaded and zero cache reuse across pages
Where CSS Modules fit
Same story. CSS Modules generate scoped class names (page_container__a3f5b2) — those styles get bundled into the same global CSS file. Since names are hashed, there are no collisions.
/* dist/assets/styles-abc123.css */
/* Tailwind utilities */
.flex {
display: flex;
}
.text-red-500 {
color: rgb(239 68 68);
}
/* Your global styles */
:root {
--brand: #0066cc;
}
/* CSS Modules from each component, hashed */
.post_article__a3f5b2 {
max-width: 65ch;
}
.counter_button__b9d4c1 {
background: var(--brand);
}One file. Browser caches once. Done.
In zfb specifically
Build flow:
- Walk source files → collect all referenced class names + all
*.module.cssfiles - Run Tailwind CLI (subprocess) with your content config → emits utility CSS
- Run lightningcss (Rust crate) → processes CSS Modules, your
global.css, and merges with Tailwind output - Hash + write
dist/assets/styles-{hash}.css - Inject
<link>into every page’s<head>
In dev mode:
- Edit a TSX page that uses a new class → re-run Tailwind → swap CSS file via the SSE “css” event (no full reload)
- Edit a
.module.css→ re-run lightningcss → swap CSS file - Edit
global.css→ same, swap
The CSS hot-swap is just a <link> href replacement (or appending a cache-buster). React/Preact island state is preserved because no JS reloads.
TL;DR
- Tailwind output: one global CSS file with only the utilities you actually use
- CSS Modules output: bundled into the same global file (scoped class names prevent collisions)
- Loaded once, cached: browser fetches
styles-{hash}.csson first visit, cache hit on every subsequent page
1つのグローバルCSSファイル、すべてのページでロードされる。それがモデル。
どう動くか
Tailwindはソースファイル(pages/、components/、MDXコンテンツ)をスキャンし、実際に使われているすべてのクラス名(flex、text-red-500、md:grid-cols-3など)を見つける。それらのユーティリティだけを含む単一のCSSファイルを出力する — 実サイトで通常はgzip 10-50KB。すべてのページが同じファイルをリンクする:
<link rel="stylesheet" href="/assets/styles-abc123.css" />なのでdist/はこんな感じ:
dist/
├── assets/
│ └── styles-abc123.css ← 全ページが使う1つのグローバルCSS
├── index.html ← それをリンク
├── about/index.html ← それをリンク
└── blog/post-1/index.html ← それをリンクなぜページごとではなく1つのグローバルファイルか
共有キャッシュの議論が勝つ:
- 最初のページ訪問 → ブラウザが30KBのCSSを1度ダウンロード
- 以降のすべてのページ → CSSはキャッシュにある、転送ゼロバイト
- 10ページのブラウジングセッションの合計バイト数: 30KB
- ページごとのCSSなら 約5KB × 10ページ = 50KBダウンロード、かつページ間でキャッシュ再利用ゼロ
CSS Modulesはどこに収まるか
同じ話。CSS Modulesはスコープされたクラス名(page_container__a3f5b2)を生成する — それらのスタイルは同じグローバルCSSファイルにバンドルされる。名前はハッシュされているので衝突しない。
/* dist/assets/styles-abc123.css */
/* Tailwindユーティリティ */
.flex {
display: flex;
}
.text-red-500 {
color: rgb(239 68 68);
}
/* グローバルスタイル */
:root {
--brand: #0066cc;
}
/* 各コンポーネントからのCSS Modules、ハッシュ済み */
.post_article__a3f5b2 {
max-width: 65ch;
}
.counter_button__b9d4c1 {
background: var(--brand);
}1つのファイル。ブラウザは1回キャッシュ。終わり。
zfb特化で
ビルドフロー:
- ソースファイルを走査 → 参照されているすべてのクラス名 + すべての
*.module.cssファイルを集める - コンテンツ設定でTailwind CLIを実行(サブプロセス) → ユーティリティCSSを出力
- lightningcssを実行(Rustクレート) → CSS Modules、
global.cssを処理し、Tailwindの出力とマージ - ハッシュ + 書き込み
dist/assets/styles-{hash}.css - すべてのページの
<head>に<link>を注入
Devモードで:
- TSXページに新しいクラスを使うように編集 → Tailwindを再実行 → SSEの”css”イベント経由でCSSファイルをスワップ(フルリロードなし)
.module.cssを編集 → lightningcssを再実行 → CSSファイルをスワップglobal.cssを編集 → 同じ、スワップ
CSSホットスワップは単に<link>のhrefを差し替えるだけ(あるいはキャッシュバスターを付けるだけ)。JSがリロードされないので、React/Preactアイランドのstateは保持される。
TL;DR
- Tailwind出力: 実際に使ったユーティリティだけを含む1つのグローバルCSSファイル
- CSS Modules出力: 同じグローバルファイルにバンドル(スコープされたクラス名が衝突を防ぐ)
- 1度ロード、キャッシュ: ブラウザは初回訪問で
styles-{hash}.cssをfetch、以降のページはキャッシュヒット
感想
はーNext.jsのSSGだけできる(けどなんか拡張も出来るよ)みたいなのが多分欲しかったんだなーみたいのを感じた。
あとはClaude Codeと会話してるとこういう流れで話が広がりまくるのが楽しいですね。それで無限に開発している感じになってるんですがまぁ……。