CCResDocのOSS化からRust製SSG「zfb」設計までを話したClaude Codeログ
概要
CCResDocは自分が個人的に使っているClaude Codeリソース閲覧用のTauriアプリ。中身はzudo-doc(Astroベースのドキュメントサイト)で、~/.claude/を読みに行ってローカルで表示するという作り。これをOSSとして配布したいというところから話が始まった。
事前ビルドのdist/は自分自身のClaudeリソースしか含まないため、新規ユーザーにそのまま配ることができない。そこで、Astro + Node.jsを残すか、Rustに置き換えるかという選択肢の検討に入った。話しているうちに、Astro代替をRustで作る話になり、最終的には自分用のSSGであるzfb(zudo-front-builder)の設計を固めるところまで広がった。
会話のログそのものを残しておきたかったのでそれ。後で見返したい、設計判断の過程を保存したい、という動機。会話は英語のままなので、そのまま貼っておく。
会話ログ
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
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?)
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
Claude
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, 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
.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?
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.
Claude
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?
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?
Claude
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 fragment
On 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.
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?
Claude
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.
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.
Claude
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.
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?
Claude
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.
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?
Claude
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.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>
);
}
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.
Claude
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.
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?
Claude
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> 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_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.
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?
Claude
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:
- 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
余談
CCResDocのOSS化という話から始めて、Astro代替のRust SSGの話、最終的にzfbという新規プロジェクトの設計まで一気に広がった会話。Path Aの選択肢があったのに、mdx-formatterのRust移植経験があったため、Path Bが現実的という判断に着地した。
zfbの設計についてはまだ決まっていることの方が少なく、TSX全振り、meta export、frontmatterはMD/MDXのみ、Tailwind + CSS Modules、Vite不要、というところまでの会話。実装するかどうかも含めて、まだ何も手を動かしていない。とりあえず会話だけ残しておくためのもの。