zudo-paper

RustでSSGするやつ作ってる会話ログ

Author: Takazudo | 作成: 2026/04/26

概要

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

CCResDoc.appのスクリーンショット。Skillsページが表示されている

そういうのを相談していたら、なんか盛り上がってきて、んじゃRustでSSGするヤツを作るのがいっかなーってことになって、その経過が自分的にはこれまでの振り返りみたいになって面白かったのでその会話ログ。

なお、会話に出てくるzudo-docというのは自分が作ってるDocusaurusクローンみたいなやつで、ちょいちょい更新してるけどまとまってはいませんみたいなモノです。

CCResDocをどう配るか

Takazudo
Takazudo
Takazudo

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 も参照して。

Claude Code
Claude Code
Claude Code

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 repo
  • claude-resources integration 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-built dist/)

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をロードして、判断材料が変わるかチェックする?)

Takazudo
Takazudo
Takazudo

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 で見て。

Claude Code
Claude Code
Claude Code

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 replacementRisk
@astrojs/mdx parsingmarkdown-rs (CommonMark + GFM + MDX support)Low — proven in mdx-formatter
remark-directive + remark-admonitionsmdast visitor in Rust (tree walk)Low — same pattern as your formatter plugins
rehype-code-title, rehype-heading-links, rehype-strip-md-extensionhast visitors or post-render string opsLow
Shiki + transformerssyntect or inkjet (tree-sitter)Medium — theme parity not 1:1, dual light/dark needs work
gray-matter (frontmatter)serde_yaml + simple splitTrivial
minisearch index (build-time JSON)Generate JSON in Rust at runtime, ship existing JS clientLow
rehype-mermaidKeep client-side rendering (mermaid.js already runs)None

What’s trickier

  1. 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.
  2. Shiki theme matching. syntect uses TextMate grammars + Sublime themes; shiki uses VS Code themes (some shared). Visually close but not identical. If theme fidelity matters, inkjet (tree-sitter based) is closer in feel.
  3. 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-resources integration, 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 (notify crate) re-renders on ~/.claude/ changes; SSE endpoint pushes reload events. (Replaces dev-stable.js for 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-admonitionsRustのmdastビジター(ツリー走査)低 — フォーマッタプラグインと同じパターン
rehype-code-titlerehype-heading-linksrehype-strip-md-extensionhastビジターまたはレンダリング後の文字列操作
Shiki + transformerssyntectまたはinkjet(tree-sitter) — テーマの完全一致は難しい、ライト/ダークの両対応に工夫が要る
gray-matter(frontmatter)serde_yaml + 単純な分割些細
minisearchインデックス(ビルド時JSON)RustでJSONをランタイム生成、既存のJSクライアントをそのまま
rehype-mermaidクライアント側レンダリングを維持(mermaid.jsはすでに動作中)なし

厄介なもの

  1. 手書きページ内のMDX-with-JSX。 src/content/docs/には実際のJSXコンポーネントがインラインで含まれているかもしれない。markdown-rsはMDX式をパースするが、それを評価する必要がある — つまり (a) これらのページのためだけにJSランタイムを同梱する、(b) 監査して、プレーンマークダウン + Rustで再実装した少数の「ショートコード」に変換する、(c) ハイブリッド: 静的MDXページは.appビルド時にAstroで事前ビルドし、~/.claude/由来のページだけRustで動的レンダリングする、のいずれか。
  2. Shikiのテーママッチ。 syntectはTextMate文法 + Sublimeテーマを使い、shikiはVS Codeテーマを使う(一部共有)。見た目は近いが同一ではない。テーマ忠実度が重要なら、inkjet(tree-sitterベース)の方が感触が近い。
  3. 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使用状況を監査して、どれくらいの面が実際に移植される必要があるかを見るところから始める?

ファイル更新の扱い

Takazudo
Takazudo
Takazudo

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ビルドを起動。これはかなり重い処理になっている。

Claude Code
Claude Code
Claude Code

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)

  1. notify crate watches ~/.claude/ (and your shell’s content dir if you want)
  2. Change event for one file → Rust re-renders just that file to an HTML fragment → updates the in-memory cache
  3. If it was a structural change (file added/removed/renamed), rebuild the affected navigation/index entries (still microseconds, not a full site build)
  4. SSE pushes a reload event for the affected route(s)
  5. Browser receives event and reloads — or, since the per-file render is sub-ms, you can fetch the 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

AspectCurrentPath B
Watch triggerfull Astro build (~1–3s)per-file Rust render (~1–10ms)
Build dependency on user machinenode + pnpm + node_modulesnone
Process to managesidecar node processnone (in-process Rust)
Live-reload mechanismSSE from dev-stable.jsSSE from axum
Spinner overlay needed?yes (visible build delay)no (instant)
.app sizehundreds of MB if bundling nodetens 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)

  1. notifyクレートが~/.claude/を監視(必要ならシェルのコンテンツディレクトリも)
  2. 1ファイルの変更イベント → RustがそのファイルだけHTMLフラグメントに再レンダリング → メモリ内キャッシュを更新
  3. 構造的な変更(ファイル追加/削除/リネーム)の場合は、影響を受けるナビゲーション/インデックスエントリを再構築(それでもマイクロ秒オーダー、サイト全体のビルドではない)
  4. SSEが影響を受けるルートにreloadイベントをプッシュ
  5. ブラウザがイベントを受信してリロード — あるいはファイル単位のレンダリングがサブミリ秒なので、新しい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からのSSEaxumからの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で作れるか

Takazudo
Takazudo
Takazudo

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で可能なのか?別のスクリプトとして書く必要があるかもしれない。

Claude Code
Claude Code
Claude Code

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

PieceWhere it runs nowWhat it does
buildNavTree(docs, lang, categoryMeta)Astro build timeWalks docs collection → tree of NavNode
buildSidebarForSectionAstro build timeApplies sidebars.ts overrides or auto-generates
<Sidebar /> astro componentAstro build time, per pageInlines initial HTML + React hydration props
<SidebarTree client:load />BrowserRenders the actual interactive UI (collapse/expand, current)

The sidebar is already client-rendered after hydration. Astro just provides the initial HTML and seed props.

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 fragment

On the client:

  • The bundled SidebarTree React component (compiled by Astro into the prebuilt shell) fetches /api/nav-tree.json on 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 LOC
  • buildNavTree + findNode + getNavSubtree equivalents → ~100 LOC (all pure tree manipulation, no fancy deps)
  • sidebars.ts resolution: if you keep the config as TS, you need to either (a) port it to a sidebars.toml/sidebars.json Rust 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

  1. User edits ~/.claude/skills/foo/SKILL.md
  2. notify watcher fires
  3. Rust re-parses that one file → updates content cache + nav tree node
  4. axum SSE broadcasts: {"type": "content", "slug": "claude-skills/foo"} and {"type": "nav"}
  5. Client: refetches /api/nav-tree.json and 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のツリー
buildSidebarForSectionAstroビルド時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 LOC
  • buildNavTree + findNode + getNavSubtree相当 → 約100 LOC(純粋なツリー操作、特殊な依存なし)
  • sidebars.tsの解決: 設定をTSのまま保つなら、(a) sidebars.toml/sidebars.jsonに移植してRustで読む、(b) JSをevalする、のいずれか — オプション(a)の方がクリーン。リゾルバで約50 LOC。

合計: 純粋なRust 約200-300 LOC。外部サービス不要、追加スクリプト不要。axumが配信を担う。

エンドツーエンドの更新フロー

  1. ユーザーが~/.claude/skills/foo/SKILL.mdを編集
  2. notifyウォッチャーが発火
  3. Rustがその1ファイルを再パース → コンテンツキャッシュとnavツリーノードを更新
  4. axum SSEがブロードキャスト: {"type": "content", "slug": "claude-skills/foo"}{"type": "nav"}
  5. クライアント: /api/nav-tree.jsonを再fetchし、開いているページのコンテンツフラグメントをfetch経由で差し替える — フルリロードなし、フラッシュなし、スピナーなし

要するに: サイドバーはPath Bのブロッカーではない — むしろ今より_クリーン_になる。なぜなら、ツリーがAstroビルドごとに再計算される代わりに、長寿命のメモリ内構造になるから。

Astro代替をRustで作れるか

Takazudo
Takazudo
Takazudo

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で可能?

Claude Code
Claude Code
Claude Code

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:

ToolReplacesStatus
SWCBabel (JSX/TS/JS transforms, including MDX)Used by Next.js, Vercel — production
TurbopackWebpackVercel’s bundler, written in Rust
RspackWebpackByteDance, Rust
BiomeESLint + PrettierRust
Lightning CSSPostCSS + cssnanoRust
OxcESLint + Babel + Prettier (full toolchain)Rust
FarmViteRust + 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-rs has experimental MDX support — produces mdast nodes including MdxJsxFlowElement and MdxJsxTextElement
  • swc_ecma_parser parses 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:
ApproachHowTradeoff
Embed JS enginedeno_core or rusty_v8 evaluates compiled JSX at build timeWorks for any React/Preact component, but ships V8 (~30MB)
Static component registryDefine a fixed set of components implemented in pure Rust. JSX in MDX → match name → call renderNo JS runtime, very fast, but only predefined components
AST-driven shortcodesTreat <Component> as a structured directive, render via templatesSame 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ビルドツールを置き換える」トレンドは理論上の話をはるかに超えている:

ツール置き換え先状況
SWCBabel(JSX/TS/JS変換、MDX含む)Next.js、Vercelで使用 — 本番
TurbopackWebpackVercelのバンドラ、Rustで書かれている
RspackWebpackByteDance、Rust
BiomeESLint + PrettierRust
Lightning CSSPostCSS + cssnanoRust
OxcESLint + Babel + Prettier(フルツールチェーン)Rust
FarmViteRust + Node

なので、Astro相当をRustで作るのは月面着陸ではない — 重い機械の大部分はすでにRustクレートとして存在する。

Rust上のMDX — 本当の質問

MDXには2つのパートがある: パース実行

パース(マークダウン + JSXからAST):

  • markdown-rsは実験的なMDXサポートを持つ — MdxJsxFlowElementMdxJsxTextElementを含むmdastノードを生成
  • swc_ecma_parserが中のJS/JSX式をパース
  • ここは解決済み — <Tabs><Tab label="...">をRustでクリーンにパースできる

実行(JSX → HTML、Reactスタイルのコンポジションあり):

  • ここが哲学的になる。JSXはJavaScriptエコシステムによって_定義_されている — <Foo />は「Foo関数をこれらのpropsで呼ぶ」という意味。これはJSランタイムを暗示する。
  • 現実的な3つのアプローチ:
アプローチやり方トレードオフ
JSエンジンを埋め込むdeno_corerusty_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視点で考えるとどうか

Takazudo
Takazudo
Takazudo

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をもっと簡単に使えそうで柔軟に思える。

Claude Code
Claude Code
Claude Code

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 from statements 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 has swc_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.js runs:

    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:

PhaseTimeRisk
Markdown parser + 10 md-plugin ports2–3 weeksLow (pattern proven in mdx-formatter)
Component registry (admonitions, tabs, details, nav components)1 weekLow
Content collections + Zod-equivalent validation (serde + JSON Schema)3–5 daysLow
Sidebar/nav tree + i18n routing3–5 daysLow
Search index generator2 daysLow
Static shell HTML + Tailwind v4 build (keep Tailwind CLI)2 daysLow
Preact island bundling via SWC1 weekMedium — SWC API has a learning curve
Dev server (axum + notify + SSE + island rebundle on change)1 weekLow
Cloudflare Workers (WASM) target1 weekMedium — first-time WASM target
create-zudo-doc-rs scaffold tool3–5 daysLow
Total focused effort2–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.tsxsidebar-tree.tsxtheme-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の設計、ファイルフォーマット

Takazudo
Takazudo
Takazudo

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中心ビルドシステム。ただ、データから作るページネーション系の動的コンテンツみたいな動的ルーティングはどう扱うべき?もっと深く考える必要がありそうだが、このアイデアどう思う?それとも何かいいものがある?

Claude Code
Claude Code
Claude Code

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: .tsxexport 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全振りでいく

Takazudo
Takazudo
Takazudo

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も要らない。どう思う?

Claude Code
Claude Code
Claude Code

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)

DecisionWhy it’s right
All TSX, no custom formatStandard tooling everywhere — TS server, Prettier, ESLint, VS Code — zero custom plugins to maintain. Onboarding is “do you know React/Preact?”
Preact (or React) for everythingOne 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/MDXMatches the actual file-type division: prose files use YAML (writer-friendly), code files use exports (type-safe, no preprocessing).
Tailwind + CSS ModulesTailwind for 95% of styling. CSS Modules for the cases Tailwind doesn’t fit (animations, complex selectors, third-party overrides, true globals).
No .zfb / .zfb.tsxCustom 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.ts

A 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的な扱い

Takazudo
Takazudo
Takazudo

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のデータ参照のやり方は良かった。ただ嫌な点は、データ参照も暗黙だったこと。テンプレート上でどの変数が使えるのかわからない。

Claude Code
Claude Code
Claude Code

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.tsx

The 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.tsx

cascadeは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だけで

Takazudo
Takazudo
Takazudo

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ファイルを扱うために?

Claude Code
Claude Code
Claude Code

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 responsibilityRust equivalentMaturity
TSX/JSX/TS → JSSWCProduction-grade (used by Next.js, Turbopack)
CSS Modules + nesting + autoprefixinglightningcssProduction-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 serveraxum + notify + SSEYou’re already building this
HMRCustom — 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 iterationWhat you need
Edit content/blog/foo.mdRe-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.cssRe-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> href

The honest scope of “no JS toolchain”

Pure Rust crates (compiled into the zfb binary):

  • swc_ecma_parser, swc_ecma_transforms_react, swc_ecma_minifier
  • markdown_rs (or comrak)
  • lightningcss
  • axum, tokio, notify
  • serde, 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 → JSSWCプロダクショングレード(Next.js、Turbopackで使用)
CSS Modules + ネスト + autoprefixinglightningcssプロダクショングレード(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_parserswc_ecma_transforms_reactswc_ecma_minifier
  • markdown_rs(またはcomrak
  • lightningcss
  • axumtokionotify
  • serdeserde_yamlserde_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オーケストレーションをやっているツール: TrunkAstro(lightningcss + tailwindcss-oxideを使用)、BunTailwindスタンドアロン CLI

Tailwindのビルド形式

Takazudo
Takazudo
Takazudo

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つ作って、それをロードする?

Claude Code
Claude Code
Claude Code

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 it

Why 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:

  1. Walk source files → collect all referenced class names + all *.module.css files
  2. Run Tailwind CLI (subprocess) with your content config → emits utility CSS
  3. Run lightningcss (Rust crate) → processes CSS Modules, your global.css, and merges with Tailwind output
  4. Hash + write dist/assets/styles-{hash}.css
  5. 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}.css on first visit, cache hit on every subsequent page

1つのグローバルCSSファイル、すべてのページでロードされる。それがモデル。

どう動くか

Tailwindはソースファイル(pages/components/、MDXコンテンツ)をスキャンし、実際に使われているすべてのクラス名(flextext-red-500md: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特化で

ビルドフロー:

  1. ソースファイルを走査 → 参照されているすべてのクラス名 + すべての*.module.cssファイルを集める
  2. コンテンツ設定でTailwind CLIを実行(サブプロセス) → ユーティリティCSSを出力
  3. lightningcssを実行(Rustクレート) → CSS Modules、global.cssを処理し、Tailwindの出力とマージ
  4. ハッシュ + 書き込み dist/assets/styles-{hash}.css
  5. すべてのページの<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と会話してるとこういう流れで話が広がりまくるのが楽しいですね。それで無限に開発している感じになってるんですがまぁ……。