zpaper-draft

Type to search...

to open search from anywhere

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 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?)

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 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
, 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

.

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)

  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?

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

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.

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:

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.

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

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)

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>
  );
}

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

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:

  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

余談

CCResDocのOSS化という話から始めて、Astro代替のRust SSGの話、最終的にzfbという新規プロジェクトの設計まで一気に広がった会話。Path Aの選択肢があったのに、mdx-formatterのRust移植経験があったため、Path Bが現実的という判断に着地した。

zfbの設計についてはまだ決まっていることの方が少なく、TSX全振り、meta export、frontmatterはMD/MDXのみ、Tailwind + CSS Modules、Vite不要、というところまでの会話。実装するかどうかも含めて、まだ何も手を動かしていない。とりあえず会話だけ残しておくためのもの。