# zfb > The Rust engine under your content-site framework — router, renderer, content pipeline. Author in TypeScript/JSX, runs as a single binary. --- # Choosing zfb > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/choosing-zfb Whether zfb is the right tool for your project — including an honest look at what it is not good at. Cross-linked to the [architecture overview](/architecture/build-engine), the [getting-started guide](/getting-started), and the scaling note in the [README](https://github.com/Takazudo/zudo-front-builder#limits). zfb is a focused tool. It solves a specific problem well, and it deliberately leaves other problems to other tools. This page maps that out so you can make the call before you invest time. ## When zfb fits well zfb is a strong match if most of the following describe your project: **Content-heavy, statically generated site.** Your source of truth is Markdown or MDX files in a repository. You want a build step that turns those files into static HTML, and you want that build to be fast and deterministic. zfb's entire architecture is shaped around this case. **Per-page dependency graph.** You have hundreds of pages and you do not want a full rebuild on every edit. zfb tracks which source files each page depends on and rebuilds only the affected pages when something changes. The dependency graph (`crates/zfb-graph`) is a first-class part of the engine, not a bolt-on. **Workerd-shaped output bundle.** Your target is Cloudflare Workers or Pages. zfb's build-time JS host is V8-based (via embedded V8), the same engine family as workerd. The content snapshot and island bundles are sized for that environment. If you deploy to Workers, you get a bundle shape that actually fits. **Cloudflare Pages–friendly deployment.** Static output from `zfb build` drops directly into a Pages deployment. There is no server runtime to manage, no serverless function to configure, and no adapter layer. The built `dist/` is the deployment artifact. **Light interactive islands.** Your pages are mostly static documents with a small number of interactive components — a search box, a counter, a modal trigger. zfb's islands model (`"use client"`) ships exactly the JS for the islands each page uses and nothing else. A page with no islands ships zero bytes of client JS. ## Comparison | | **zfb** | **Astro** | **Next.js** | |---|---|---|---| | CLI language | Rust | Node.js | Node.js | | TSX compile | SWC (embedded) | Vite/Rollup | SWC/Webpack | | Bundler | esbuild (islands) | Vite | Webpack/Turbopack | | Build-time JS host | Embedded V8 | Node.js | Node.js | | Client framework | Any TSX (`"use client"`) | Any (adapters) | React | The most meaningful difference is the **build-time JS host**. zfb uses embedded V8 directly — the same engine family as Cloudflare's workerd. Astro and Next.js both build in a full Node.js process. For Workers-targeted deployments, zfb's host is closer to the target runtime, which means fewer surprises around global availability and module resolution. ## When to pick something else zfb is the wrong choice in several cases — be honest with yourself before committing. **Most or every page on your site is dynamic.** zfb is SSG-by-default: every page is computed at build time into static HTML. Routes that need request-time behavior can opt out with `prerender = false` and be served by an adapter (see [SSR and Cloudflare bindings](/guides/ssr-and-cloudflare-bindings)). That covers a sprinkle of dynamic routes on an otherwise-static site. If *every* page is dynamic — per-user, per-request — zfb is the wrong tool, and a full SSR framework is the right one. **You need broad multi-framework support.** Astro's adapter ecosystem lets you swap between React, Vue, Svelte, Solid, and others inside the same site. zfb's client side is TSX (React or Preact). If your team owns components in multiple frameworks and you want them all in one site, zfb is not the right host. **You have 100 000+ pages.** zfb builds an in-memory content snapshot and embeds it into the V8 host for the duration of the render pass. For hundreds or low thousands of MDX files this fits comfortably in default workerd memory. For very large corpora — tens of thousands of entries, or entries with multi-megabyte bodies — V8 RSS grows linearly with snapshot size. At that scale the snapshot design needs rethinking (see the [Scaling sweet spot](#scaling-sweet-spot) section below), and zfb does not solve that problem today. **You want a proven, widely-adopted tool with a large ecosystem.** zfb is a focused, opinionated engine. If you need a large community, a rich plugin ecosystem, or battle-tested production scale today, [Astro](https://astro.build) is the right answer. ## Scaling sweet spot The engine is designed for **hundreds to low thousands of MDX pages**. That covers: - A typical documentation site (50–500 pages) - A developer blog with a years-long archive (100–2000 posts) - A `zudo-doc`-scale content set At those sizes the content snapshot is small, cold-start build time is measured in seconds, and the dependency graph keeps incremental rebuilds fast regardless of total page count. **Beyond roughly 10 000 pages**, the snapshot-in-memory approach becomes the bottleneck. The right answer at that scale is not to build a larger monolith — it is to shard the content into multiple zfb projects (one per section, one per locale, one per content type) and compose them at the CDN layer. That composition story is on the roadmap but is not implemented today. To inspect the actual snapshot footprint of your build: ```sh ZFB_DEBUG_SNAPSHOT=1 pnpm exec zfb build ``` This prints a single line to stderr: ``` content snapshot: 187 entries / 412 KB ``` `KB` here is the byte size of the deterministic JSON serialization — a reliable proxy for V8 heap cost. Watch this number as your content grows. If it climbs past a few hundred megabytes, you are approaching the edge of the sweet spot. The full explanation of the memory model lives in the [README scaling note](https://github.com/Takazudo/zudo-front-builder#limits). ## Where to go next - [Getting started](/getting-started) — scaffold your first site. - [Design philosophy](/concepts/design-philosophy) — the "narrow on purpose" stance and the recipes-over-plugins framing that explain why this set of trade-offs. - [Architecture: Build engine](/architecture/build-engine) — how the Rust crates, the JS host, and the snapshot fit together. - [Engine vs Framework](/concepts/engine-vs-framework) — the six primitives zfb commits to, and what lives outside the engine. --- # Design Philosophy > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/design-philosophy Two design principles that run through every decision in zfb: the deliberate choice to stay narrow rather than grow the surface area, and why — in an era where LLMs are in every workflow — recipes beat plugins for nearly everything outside a small universal core. zfb's surface area is intentionally small. That choice has a cost, and it has benefits. This page makes both explicit, then explains why the LLM-in-loop shift makes the recipe path even more attractive than it was before. ## Narrow on purpose zfb is the SSG + SSR version of esbuild: a fast, focused engine that does exactly what it commits to and stops there. Just as esbuild deliberately avoids becoming a full application bundler with its own plugin ecosystem for every possible transformation, zfb deliberately avoids growing a thick API surface to cover every site-building pattern. The [six engine primitives](/concepts/engine-vs-framework) are the complete public contract of zfb v1. Adding a seventh primitive — even a useful one — means a new API to design, document, maintain, and version across future releases. It means answering "is this possible?" questions about edge cases on the new surface. And it means that framework authors building on top of zfb must now reason about one more axis. **The cost of staying narrow** is real: the consumer carries the glue. When zfb does not ship sidebar generation or i18n routing or a built-in search hook, the framework author — or the consumer directly — writes that code. In a pre-generated ecosystem, that would mean searching for recipes, reading through them, and adapting them to the project's shape. That adaptation cost was genuine. **The benefit** is the absence of an uncertainty tax. When the surface is narrow and stable, the question "does zfb support this?" has a fast answer: look at the six primitives. If the feature maps onto them, it works. If it does not, it belongs in a framework sitting on top of zfb, not inside the engine. There is no thick API to audit, no plugin point that might break in the next minor version, and no dependency on the engine team keeping up with every downstream use case. [Astro](https://astro.build) and [Next.js](https://nextjs.org) make a different trade. They offer broad multi-framework support, rich plugin ecosystems, and first-party adapters for many patterns. That is the right call for a general-purpose framework aimed at the widest possible audience. zfb's bet is the opposite: that a narrower, faster engine with a smaller stable contract is a better foundation for the specific case of content-heavy statically generated sites targeting Cloudflare's edge. The engine-promotion test captures the boundary precisely: `"if two frameworks built on zfb would do this differently, it's not engine."` If there is any legitimate reason two downstream frameworks would make different choices, the feature does not belong in zfb's surface area. It belongs in the frameworks. ## Recipes over plugins in the AI era Before LLMs were part of every workflow, the recipe vs. plugin choice had a real asymmetry. **Pre-LLM recipe cost.** Finding a recipe meant a search, a scan through Stack Overflow answers or blog posts, a judgment call about whether the recipe matched the current project's version and structure, and then manual adaptation. Each of those steps took time and attention. A well-packaged plugin that solved the same problem was genuinely faster to reach for. **The LLM-in-loop shift.** With an LLM available, the adaptation cost collapses. A recipe that would have taken thirty minutes to understand and adapt now takes a prompt. More importantly, the recipe is now *transparent*: it runs in the consumer's project, it uses the consumer's imports, it ages alongside the consumer's codebase, and it is debuggable without peering into a dependency's internals. A plugin, by contrast, hides the implementation behind a version-pinned package, requires the plugin author to keep pace with the engine, and adds an indirection layer the LLM has to reason around. This does not mean plugins are obsolete. It means the bar for promoting something into a plugin has risen. The right question is no longer "would a recipe here be annoying to find and adapt?" — the LLM handles that. The right question is "is this something 100% of zfb consumers would use the same way, with no legitimate reason to deviate?" **The narrow plugin API zfb ships today** — the four lifecycle hooks, virtual modules, import aliases, and dev-only injected routes documented in [Plugins](/concepts/plugins) — is exactly that narrow exception. The `setup` hook's virtual module and alias registrations, the `preBuild` / `postBuild` file-generation hooks, and the `devMiddleware` handler cover patterns that are structurally inseparable from the build pipeline: things a consumer cannot implement as a recipe without forking the engine itself. The surface is deliberately closed elsewhere — no `addRemarkPlugin`, no `addModuleTransform`, no `onModuleLoad` — because those patterns belong in recipes, not in a stable engine API. The plugin-promotion test states the threshold: `"don't extract until the same recipe has been written by hand in three different zfb consumer projects."` One project's convenience is a recipe. Three independent projects converging on the same solution is evidence of a universal need. At that point, and only at that point, the extraction into a plugin API earns its maintenance cost. ## The opt-in extras catalog Between "engine primitive" and "recipe" sits a third tier: the opt-in Markdown extras in `zfb-md-extras`. These are features that have cleared the three-consumer bar — Mermaid diagrams, GitHub-style alerts, link validation, transclusion, and others — but are deliberately **not** made universal defaults. They ship compiled into the binary and enabled per-project via `markdown.features.*` in `zfb.config.ts`. This is a curated v1 set, not a JS plugin point. The list closes when it closes. Future additions must clear the same three-consumer threshold before landing. The goal is that every item in the catalog is something a measurable plurality of zfb sites actually uses — not a long tail of one-off conveniences that inflate the engine's apparent surface without earning their maintenance cost. If you have a Markdown feature that does not clear the bar, the recipe path is the right answer. Write the visitor in-tree for your project. See [Markdown Features](/markdown-features) for the full opt-in catalog and [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) for the in-tree visitor path. ## Where to go next - [Engine vs Framework](/concepts/engine-vs-framework) — the six primitives zfb commits to, and the line between engine and framework concerns. - [Markdown Features](/markdown-features) — the opt-in extras catalog. - [Plugins](/concepts/plugins) — the narrow exception: the four lifecycle hooks and what they do and do not cover. - [Choosing zfb](/concepts/choosing-zfb) — when the narrow-on-purpose tradeoff is the right call for your project, and when it is not. --- # Architecture overview > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/architecture-overview The two-bundle model, a diagram of the full build-time pipeline, why the worker bundle is shaped as a Cloudflare Workers module, and the layering principle that keeps esbuild and the JS runtime swappable. For the step-by-step build pipeline, read [Build pipeline](/concepts/build-pipeline). zfb produces two kinds of JavaScript output, sends them to two different destinations, and uses two different execution environments to get there. Getting that split clear in your head first makes the rest of the architecture fall into place. ## The two bundles, two destinations **Worker bundle — build-time only, never shipped to users.** When you run `zfb build`, the first thing that happens is that esbuild compiles your TSX pages, layouts, and components into a single worker bundle. This bundle is shaped as a Cloudflare Workers module — it exports a `fetch` handler and nothing else: ```ts fetch(request: Request, env: Env, ctx: ExecutionContext): Response { ... } }; ``` The Rust orchestrator loads this bundle into an embedded V8 isolate and drives it with synthetic HTTP requests — one request per page URL. Each synthetic request produces an HTML `Response`. The orchestrator writes those responses to `dist/` as plain `.html` files. When the build finishes, the isolate is torn down. At build time the worker bundle never leaves your machine — it is the *tool* the Rust orchestrator drives to produce the static output. (The same bundle shape also deploys unchanged to Cloudflare Workers for any `prerender = false` routes; that production path is covered later in this page and in the [SSR guide](/guides/ssr-and-cloudflare-bindings).) **Island bundles — shipped to the browser, on-demand.** Components marked with `"use client"` are a different story. esbuild bundles each one as a separate ESM module, and those bundles land in `dist/` alongside the HTML files. The browser downloads them on-demand and hydrates the corresponding DOM nodes. Island bundles are the *only* JavaScript that ever reaches end users. The distinction matters because the two bundles have completely different lifetimes, different execution environments, and different optimization targets. Conflating them is the source of most confusion about how zfb works. See [Islands](/concepts/islands) for how to opt in with `"use client"`. ## What runs where ```mermaid flowchart TD src["TSX / MDX source files\n(pages/, components/, content/)"] subgraph build["zfb build — your machine"] esbuild_worker["esbuild\n(worker bundle)"] v8["embedded V8\n(synthetic HTTP requests → HTML)"] esbuild_islands["esbuild\n(island bundles)"] dist_html["dist/\nHTML files"] dist_js["dist/\nisland JS files"] end deploy["Static host\n(any CDN / Cloudflare Pages)"] browser["Browser\n(island bundles hydrate on-demand)"] src --> esbuild_worker esbuild_worker --> v8 v8 --> dist_html src --> esbuild_islands esbuild_islands --> dist_js dist_html --> deploy dist_js --> deploy deploy --> browser ``` Everything inside the `zfb build` box runs on your machine at build time and exits when the build finishes. Nothing in that box runs on a server at request time for static deployments. For the deeper story on how the build orchestrator coordinates these steps, read [Build engine](/architecture/build-engine). ## The Hono / Cloudflare Workers portability bet The worker bundle shape — `export default { fetch }` — is not accidental. It is the same contract a Cloudflare Workers deployment expects. That means the bundle the Rust orchestrator drives at build time can be deployed to Cloudflare Workers unchanged, and workerd will execute it in production for any route that opts out of static prerendering. The routing core (`createPageRouter` from `@takazudo/zfb-runtime`) follows the Hono adapter pattern: one router, multiple entry adapters. The build-time adapter feeds it synthetic `Request` objects and collects `Response` strings. The Cloudflare Workers adapter registers it as the worker's `fetch` handler. The router itself does not know which adapter is driving it. This is the portability bet: by fixing the bundle shape as the stable contract, zfb stays decoupled from which JS engine executes it. The build-time host is an embedded V8 isolate. The production host is workerd. The contract — `export default { fetch }` — is the same in both contexts. `packages/zfb-adapter-cloudflare` is the deployment adapter for Cloudflare Workers. It takes the bundle produced by `zfb build --target=cloudflare` and wraps it in the thin shell that `wrangler deploy` expects. For a deep dive into the two-file worker output and how requests are dispatched at runtime, see [SSR on a Worker (adapter mode)](/concepts/ssr-on-a-worker). The full rationale for this design is in [JS Runtime](/architecture/js-runtime): the embedded V8 host design and why the bundle contract is engine-agnostic. The SSG-first, Hono/Workers bundle shape was chosen to keep the build-time and production execution environments interchangeable, and the original in-process `deno_core` approach was superseded by the embedded V8 isolate once performance and isolation requirements became clear. ## Layering summary Three layers, each with a distinct job: | Layer | What it owns | What it does NOT own | |-------|-------------|----------------------| | **Your source** | Pages, components, content, styles | Build mechanics | | **zfb** | The build contract — route table, page props shape, island protocol, `dist/` layout | Which bundler, which JS runtime | | **Tools** | esbuild (bundling), embedded V8 (build-time evaluation) | Your code's semantics | zfb owns the contract — what inputs it accepts, what the output looks like, and what invariants hold across the build. esbuild and the embedded V8 runtime are implementation details that can be swapped behind the `RenderHost` trait without touching user code. The bundler is the cheap, fast saw. zfb is the carpenter. Downstream of the contract: - [Islands](/concepts/islands) — how `"use client"` opts a component into the island bundle - [Build engine](/architecture/build-engine) — how the Rust crates coordinate the pipeline - [Incremental rebuild](/concepts/incremental-rebuild) — how the dependency graph keeps dev rebuilds fast --- # Engine vs Framework > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/engine-vs-framework How zfb's surface area is split between **engine primitives** (the six things zfb commits to) and **framework concerns** (sidebars, search, blog conventions, theming) that live outside the engine. zfb is a static site **engine**. It scans `pages/`, walks content collections, runs an MDX pipeline, evaluates TSX, threads page meta through layouts, and writes files to disk. That's the engine. A **framework** — like a future zudo-doc-v2, or a blog kit, or whatever someone publishes downstream — is what turns a pile of engine primitives into a finished site experience. Sidebar generation, search, versioning UI, theme controls, blog routing, i18n routing: all framework concerns. This page exists because the line between the two is load-bearing. Knowing which side a feature lives on tells you where to look for it, where to extend it, and what your project's responsibility is when zfb itself doesn't ship the answer. ## The six engine primitives zfb v1 commits to exactly six primitives. Frameworks build on top of them; nothing else in zfb is part of the public surface contract. 1. **[Frontmatter](/concepts/frontmatter)** — unified contract across `.md`, `.mdx`, and `.tsx` sources. One JSON shape downstream. 2. **[Content collections](/concepts/content-collections)** — typed directories of markdown content, walked at build time. 3. **Build-time content query bridge** — a deterministic Rust→JS snapshot that powers synchronous `getCollection` / `getEntry` calls from your pages. The contract is stable and documented in [Content Collections](/concepts/content-collections). 4. **[Dynamic routes](/concepts/dynamic-routes)** — `paths()` enumerates the concrete URLs to build for `[slug].tsx` / `[...slug].tsx` pages. 5. **[Custom directives](/concepts/custom-directives)** — register container, leaf, and text MDX directives that map to your JSX components without writing any Rust. 6. **[Non-HTML pages](/concepts/non-html-pages)** — `pages/sitemap.xml.tsx`, `pages/llms.txt.tsx`, `pages/feed.rss.tsx`, etc. First-class outputs with their own extension and `Content-Type`. That's the engine. Stable, named, documented. ## What zfb does NOT do These are framework concerns. zfb gives you the substrate; you (or a framework you depend on) wire it up. - Sidebar / navigation tree generation. - Search index building (Pagefind, Lunr, etc.). - Blog conventions (tag pages, archives, RSS feed wiring, pagination). - i18n routing — `/ja/...` mirroring `/...`, locale negotiation. - Theme controls (light / dark / dim, design tokens, color palette UI). - Versioning UI — multi-version docs, version dropdown. - Layout components — header, footer, table of contents, card grids. - Site-level chrome — skip links, focus management, scroll-to-top. The principle: if the answer to *"would two frameworks built on zfb do this differently?"* is yes, it belongs in a framework, not in zfb. ## A small example: a layout consumes the engine's `meta` The engine threads page metadata through `PageMeta`. Layouts — which are *your* code, not the engine's — read `meta` and project it into the ``. Here's a tiny default layout: ```tsx // layouts/default.tsx const og = meta?.openGraph ?? {}; return ( {meta?.title ?? "Untitled"} {meta?.description && ( )} {og.image && } {children} ); } ``` The page itself just declares meta: ```tsx // pages/about.tsx title: "About", description: "Who we are.", openGraph: { image: "/og/about.png" }, }; return About us; } ``` Notice what the engine does and doesn't do here. The engine parses `meta`, resolves the layout, and wraps your page output in it. Whether to render `og:image`, what fallback to use, how to format the title — all framework-level decisions. zfb stops at "give the layout the typed `PageMeta`." ## Opt-in Markdown extras Not every Markdown capability is a primitive. The `zfb-md-extras` crate ships a catalog of opt-in features — Mermaid diagrams, GitHub-style alerts, reading-time injection, transclusion, and more. These are **framework-flavored**: a blog framework might enable reading time and ruby annotation, while a technical-reference framework might want link validation and code-block enrichment instead. Neither set is universal. By keeping them opt-in (enabled per-project in `zfb.config.ts`) rather than Core, zfb avoids baking one framework's opinion into the engine. The six engine primitives remain the full public contract; the extras catalog is additive and independently togglable. See [Markdown Features](/markdown-features) for the full list. ## Where to look next - For each engine primitive, the linked concept page above. - For the Markdown extras catalog, [Markdown Features](/markdown-features). - For the head/meta surface specifically, [`meta` export](/api/meta-export) documents which fields are first-class and how `extra` carries the rest. --- # Getting Started > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/getting-started Welcome to the Getting Started section. Choose a topic below to begin. --- # Markdown Features > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features zfb's Markdown pipeline is layered. A set of **Core** features runs unconditionally for every content collection, giving you heading anchors, server-side syntax highlighting, CJK-friendly emphasis, and more. On top of that, a catalog of **Opt-in** features can be enabled one-by-one in `zfb.config.ts` under `markdown.features.*`. This page is the map. Each feature has its own page with a usage example, config key, and ordering notes. ## Dependency graph ``` zfb-content — Core features live here; always compiled in └─ zfb-md-extras — Opt-in features; compiled in but gated at runtime └─ zfb-md-ast — Shared AST types (MdastNode, HastNode, visitors) ``` The `zfb-md-ast` crate defines the `MdastVisitor` and `HastVisitor` traits and the shared node types. Both Core and Opt-in features implement these traits. ## Tier convention - **Core** — active in every build. No config key. Lives in `zfb-content`. - **Opt-in** — inactive by default. Enable via `markdown.features.*` in `zfb.config.ts`. Lives in `zfb-md-extras`. Each feature page shows a `Core` or `Opt-in` badge near its title. ## Enabling Opt-in features ```ts title="zfb.config.ts" markdown: { features: { mermaid: true, admonitionsPreset: true, }, }, }); ``` Pass `true` (use defaults) or a config object (override specific options). Omitting a key means the feature is off; no extra bytes are emitted. ## Core features These always-on plugins ship as part of `zfb-content`'s default pipeline. - [CJK-friendly emphasis](/markdown-features/cjk-friendly) — re-tokenises bold/italic adjacent to CJK ideographs and kana. - [Heading links](/markdown-features/heading-links) — slugified `id` attributes and self-referencing anchor links on every heading. - [Code block title](/markdown-features/code-title) — renders a filename label above a fenced code block from `title="…"`. - [External links](/markdown-features/external-links) — adds `target` and `rel` attributes to outbound links (configurable). - [Resolve links](/markdown-features/resolve-links) — rewrites internal link targets using the content source map. - [Strip .md extension](/markdown-features/strip-md-ext) — removes `.md`/`.mdx` suffixes from internal link hrefs. - [Syntax highlighting](/markdown-features/syntax-highlighting) — server-side highlighting via syntect; always on, theme-configurable. - [Directives registry](/markdown-features/directives-registry) — maps `:::name`/`::name`/`:name` directive syntax to JSX components. ## Opt-in features Enable each in `markdown.features.*`. All live in `zfb-md-extras`. - [Admonitions preset](/markdown-features/admonitions-preset) — the seven built-in admonition directive types (`note`, `tip`, `warning`, `danger`, `info`, `details`, `caution`) plus project-specific directives. - [Mermaid diagrams](/markdown-features/mermaid) — renders Mermaid flowcharts from fenced code blocks. - [Heading-marker TOC](/markdown-features/heading-marker-toc) — auto-inserts a table of contents after a designated heading. - [GitHub alerts](/markdown-features/github-alerts) — renders `> [!NOTE]` / `> [!WARNING]` blockquotes as styled components. - [Reading time](/markdown-features/reading-time) — computes estimated reading time and injects it into frontmatter. - [GitHub autolinks](/markdown-features/github-autolinks) — links GitHub issue/PR/commit references (`#123`, `org/repo#456`, `abc1234`) automatically. - [Code-block enrichment](/markdown-features/code-enrichment) — adds diff markers and per-line highlighting to fenced code blocks. - [Code tabs](/markdown-features/code-tabs) — groups consecutive fenced code blocks into a tabbed switcher. - [Ruby annotation](/markdown-features/ruby) — renders `{漢字|かんじ}` syntax as HTML `` elements. - [TOC export](/markdown-features/toc-export) — exports a structured heading tree into the compiled JSX module for framework-side rendering. - [Image dimensions](/markdown-features/image-dimensions) — injects `width` and `height` attributes on `` elements from the actual file. - [Link validation](/markdown-features/link-validation) — treats broken internal links as hard build errors. - [Transclusion](/markdown-features/transclude) — inlines the content of another file using the `:::include` directive. ## See also - [Design Philosophy](/concepts/design-philosophy) — the three-consumer threshold for promoting a recipe to an Opt-in feature. - [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) — write a Rust visitor and wire it into the engine. - [Custom Directives](/concepts/custom-directives) — register new directive names without writing Rust. - [Recipe: Enlargeable Images](/recipes/enlargeable-images) — userland replacement for the removed `imageEnlarge` built-in, using the `img` component override. --- # Frontmatter > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/frontmatter How zfb extracts frontmatter from `.md`, `.mdx`, and `.tsx` sources; the literal-only restriction on TSX exports; and the precedence rule the engine uses to pick a TSX page's output extension. zfb's first engine primitive is a **unified frontmatter contract**. Markdown, MDX, and TSX pages all declare frontmatter. The on-disk syntax differs per source kind — YAML for markdown, a JS object literal for TSX — but downstream of `zfb-content::extract_frontmatter` they collapse to the same `serde_json::Value` shape. Schema validation and consumers don't branch on extension. ## YAML for `.md` and `.mdx` Markdown sources use the standard fenced YAML block at the top of the file: ```md --- title: Hello zfb description: A short post. date: 2026-04-27 tags: [intro, hello] draft: false --- The body of the post starts here. ``` The block is parsed once, converted to JSON, and exposed as the entry's `data` field everywhere downstream — `getCollection("blog")[i].data`, the page renderer, schema validation, all see the same object. ## TSX: `export const frontmatter` TSX pages declare frontmatter through a top-level `export const frontmatter` literal: ```tsx // pages/about.tsx title: "About", description: "Who we are.", draft: false, openGraph: { image: "/og/about.png" }, }; return About; } ``` The TSX extractor is **AST-only**. It parses your file with SWC and walks the literal — it never evaluates the module. That keeps frontmatter extraction cheap and side-effect-free, but it means the literal you write has to be statically resolvable. ### Literal-only contract Allowed inside the `frontmatter` object literal: - string, number (with `+` / `-` unary signs), boolean, `null`, - nested object literals, - array literals (no holes), - template strings **without** substitutions (`` `hello world` `` is fine; `` `hi ${x}` `` is not). Rejected: - identifiers (`title: SOME_CONST`), - function calls (`title: makeTitle()`), - member accesses (`title: site.title`), - spreads (`...defaults`), - computed keys, - regular expressions. Rejections point at the offending source location so the engine can show you exactly which line broke the rule. If you find yourself wanting to share frontmatter values across pages, that's a sign the value belongs in a config or in your layout — not in the page's frontmatter literal. The literal-only restriction is deliberate; computing frontmatter would force the engine to evaluate the module before the router knew what to do with it. ## The unified JSON shape After extraction, both branches produce the same shape on the Rust side: ```rust pub struct UnifiedFrontmatter { pub value: serde_json::Value, // the parsed frontmatter pub body: Option, // markdown body (None for .tsx) pub body_offset: Option, // byte offset (None for .tsx) pub extension: Option, // TSX-only sibling export pub content_type: Option, // TSX-only sibling export } ``` For the YAML example above, `value` deserializes to: ```json { "title": "Hello zfb", "description": "A short post.", "date": "2026-04-27", "tags": ["intro", "hello"], "draft": false } ``` For the TSX example, the resulting `value` is the same shape — your literal, converted to `serde_json::Value`, with `extension` and `content_type` populated only if you also declared the sibling exports. ## Output extension precedence (TSX only) `.tsx` pages can produce non-HTML output. Two mechanisms decide the output extension, with a fixed precedence: 1. `export const extension = "..."` — a sibling literal next to `frontmatter`. **Wins** if present. 2. The filename convention — the second-to-last `.`-separated segment in the source path. `pages/sitemap.xml.tsx` → `xml`, `pages/feed.rss.tsx` → `rss`, `pages/about.tsx` → no convention hint. 3. Default — `html`. ```tsx // pages/sitemap.xml.tsx // Filename hint says "xml". No frontmatter override → output is // /sitemap.xml. Content-Type: application/xml. return /* the XML body */; } ``` ```tsx // pages/raw.html.tsx // Filename hint says "html", but the frontmatter override wins: the // page emits /raw.txt instead. return "plain text body"; } ``` See [Non-HTML Pages](/concepts/non-html-pages) for the full filename-convention treatment, including what changes when the extension changes between builds. ## Where the contract lives - `crates/zfb-content/src/frontmatter.rs` — unified extractor entry point. - `crates/zfb-content/src/tsx_frontmatter.rs` — the TSX-side static walker (errors point at file + line:column). - `crates/zfb-router/src/route.rs` — the filename-convention side of the output-extension precedence. - `crates/zfb-render/src/meta.rs` — `derive_output_extension` / `derive_content_type` apply the rule to a final filename. --- # defineConfig > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/api/define-config ## Signature ```ts defineConfig(config: ZfbConfig): ZfbConfig ``` `defineConfig` is exported from `zfb/config`. The helper is identity-typed: it returns its argument unchanged. Its only job is to give your editor IntelliSense and type-checking against the `ZfbConfig` shape. The actual schema is enforced by Rust serde at config-load time, so the same rules apply whether you author your config in TypeScript or JSON. ## Config shape All keys are camelCase and mirror `crates/zfb/src/config.rs`. - `outDir?: string` — output directory. Default: `"dist"`. - `publicDir?: string` — static assets directory, copied verbatim. Default: `"public"`. - `host?: string` — dev/preview server bind host. - `port?: number` — dev/preview server port. - `framework?: "preact" | "react"` — JSX framework runtime. Default: `"preact"`. - `collections?: CollectionDef[]` — content collections. Each entry has `name` (identifier used in `getCollection` calls), `path` (directory relative to the project root), and optional `schema` (validated at config-load time; frontmatter is checked against the schema by `zfb check` and the build). - `tailwind?: { enabled?: boolean }` — Tailwind options. - `plugins?: PluginConfig[]` — user-supplied plugins. Each entry has `name` (npm specifier or `./`-relative path) and optional `options` (an arbitrary JSON object passed to the plugin's hooks). See [Plugins](/concepts/plugins) for the full hook contract — `setup`, `preBuild`, `postBuild`, `devMiddleware` — including virtual modules, import aliases, and dev-only injected routes. - `adapter?: string` — deploy-target adapter package name. Omit for a pure static build. A package like `"@takazudo/zfb-adapter-cloudflare"` wraps the SSR bundle into a deploy-ready entry (e.g. `dist/_worker.js` for Cloudflare Pages). - `site?: string` — canonical origin URL (e.g. `"https://example.com"`). When set, exposes `globalThis.__zfb.site` so layouts can build canonical `` tags, OpenGraph meta, sitemap absolute hrefs, and hreflang alternates. Must be an absolute HTTP/HTTPS URL; omit for builds that do not need server-side canonical URL construction. Distinct from `base` — see below. - `base?: string` — public URL prefix for asset URLs. Use when the site is deployed under a sub-path (e.g. `"/pj/my-site/"`). Distinct from `site`: `base` prefixes asset URLs; `site` is the full canonical origin used in metadata. - `stripMdExt?: boolean` — strip `.md`/`.mdx` extensions from internal link hrefs during MDX compilation and append a trailing `/`. Default: `false`. - `trailingSlash?: boolean` — append a trailing `/` to extensionless absolute hrefs when rewriting base paths. Default: `false`. - `resolveMarkdownLinks?: ResolveMarkdownLinksConfig` — markdown link resolver settings. Enable to rewrite `[label](./other.mdx)` links to their rendered route URLs. - `extraWatchPaths?: string[]` — extra absolute filesystem paths the dev watcher follows in addition to the in-project source roots. See [Watching paths outside the project root](#watching-paths-outside-the-project-root). ## Watching paths outside the project root `extraWatchPaths` lets `zfb dev` live-reload when files outside the project tree change — useful when a project reads content from a sibling repo, a `file:` dep that ships content alongside code, or a shared filesystem directory. ```ts extraWatchPaths: [ "/home/me/knowledge-base", "/srv/shared-content", ], }); ``` Semantics: - **Absolute paths only.** Each entry must be an absolute path. Relative paths are rejected at config-load with an `extraWatchPaths[N]: ... must be an absolute path` error — the dev watcher registers each entry verbatim, outside the project root, so it has no anchor to resolve a relative path against. - **Canonicalisation.** Each entry is canonicalised (`Path::canonicalize`) once when `zfb dev` boots. Symlinks are resolved; downstream events reach the rebuild logic with the canonical form, so the path the watcher emits matches the form you'd see by running `realpath` on the configured value. - **Missing-at-boot.** If a configured path does not exist at the moment `zfb dev` starts, it is skipped with a warning. The watcher does **not** poll for the path to appear later — if you create the directory after the dev server is already running, restart `zfb dev` to pick it up. - **Recursive.** Each entry is watched recursively. Sub-directories created after boot are picked up automatically by the OS-level recursive watch. - **Rebuild scope.** Events from these paths fall outside the dependency graph's coverage (the graph only tracks in-tree edges), so they conservatively trigger broader rebuilds than equivalent in-tree edits. The trade-off is intentional: correctness over precision for out-of-root sources. **Security note.** Opt-in only — do **not** point this at unbounded directories like `$HOME` or `/`. On Linux the recursive watcher registers every subdirectory and can quickly hit the inotify `max_user_watches` ceiling (default ~8192 on many distributions) on a large tree. If you need to watch a sprawling source, watch the narrowest sub-tree that contains the files you actually edit. This is a dev-mode feature. Production builds (`zfb build`) snapshot the filesystem once and do not rely on watcher events, so `extraWatchPaths` has no effect on shipped output. ## Examples ```ts // zfb.config.ts — the recommended form outDir: "dist", framework: "preact", collections: [ { name: "blog", path: "content/blog", }, ], tailwind: { enabled: true }, }); ``` The loader accepts `zfb.config.ts` (preferred) and `zfb.config.json` (legacy fallback). When only `zfb.config.json` is present it is read via `serde_json`; plugin paths declared as `./...`, `../...`, or absolute paths are resolved relative to the config file (npm specifiers like `"@takazudo/some-plugin"` work in both forms). ```json // zfb.config.json — legacy form, still supported { "outDir": "dist", "framework": "preact", "collections": [ { "name": "blog", "path": "content/blog" } ], "tailwind": { "enabled": true } } ``` ## Validation The loader enforces the following rules and reports errors with the file path plus `line:column` for JSON parse failures: - Collection names must be unique. - `path` cannot be an absolute path. - `path` cannot contain `..` segments that escape the project root. --- # Build engine > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/architecture/build-engine This page is about the *shape* of zfb's build engine. For a step-by-step walk of what happens when you save a file, read [Build pipeline](/concepts/build-pipeline); this page explains why the story is shaped this way. See also: [Architecture overview](/concepts/architecture-overview) · [Islands](/concepts/islands) · [Incremental rebuild](/concepts/incremental-rebuild) ## The crate split zfb is a Rust workspace. Each crate owns one slice of the build, and the data flowing between them is small and explicit. - **`zfb-router`** scans `pages/` and turns files into routes. It owns the file-name-to-URL convention and nothing else. - **`zfb-graph`** holds the dependency graph. Every page knows which sources it depends on (components, layouts, content, styles); every source knows which pages depend on it. This is the index that makes per-page rebuilds possible. - **`zfb-watcher`** wraps the `notify` crate, normalises the noisy native events into one `Change` per logical save, and emits them on a channel. - **`zfb-build`** is the orchestrator. It consumes `Change` values, classifies them, asks the graph which pages need rebuilding, and runs the asset pipeline. - **`zfb-render`** compiles TSX through SWC, hands the resulting JS to a `RenderHost`, and writes the HTML. - **`zfb-css`** processes CSS modules and global styles. - **`zfb-islands`** scans for `client:*` directives, bundles the per-island JS, and emits the hydration entry. - **`zfb-content`** parses Markdown / MDX content collections and runs the unified plugin chain. - **`zfb-server`** is the dev-only HTTP server: a page cache, a static-file route, the live-reload SSE stream, and a request-time SSR dispatcher for `prerender = false` pages. The crates depend downward: the orchestrator knows about render, css, islands, content. The renderer does not know about the orchestrator. The server only reads. ## Per-page rebuild, not per-bundle Bundler-based tools (Vite, esbuild) think in modules and chunks: a change invalidates a module, and the bundler walks the import graph to decide what to re-emit. zfb thinks in **pages**. When a file changes, `zfb-watcher` emits a `Change`, the orchestrator asks `zfb-graph` which pages depend on this path, and only those pages re-render. A leaf component used by three pages produces three page rebuilds — not a whole-site rebuild, not a bundler walk. The win is granularity. A 2,000-page site rebuilds the affected pages in milliseconds when a shared header changes. The cost is that the graph must be honest — `zfb-graph` is updated as part of every successful render so the next query has a current picture. ## Tools the engine uses (and why they're swappable) zfb is the orchestrator. It owns the dependency graph, the per-page rebuild contract, and the data flowing between crates. The external tools it calls are an implementation detail — each sits behind a Rust trait, so any one of them can be replaced without touching the rest of the pipeline. | Tool | Role | Trait boundary | | --- | --- | --- | | **esbuild** | Bundles the server-side worker bundle (`zfb-build/bundler.rs`) and the per-island client bundles (`zfb-islands`). Fast, handles TSX/JSX, MDX loaders, and tree-shaking. | `ClientBundler` (in `zfb-islands`) | | **deno_core (V8)** | The embedded V8 isolate that executes the server-side worker bundle for SSG and the dev preview server. Chosen because Tauri distribution requires a single binary with no Node dependency; see [JS runtime](/architecture/js-runtime) for the rationale. | `RenderHost` (in `zfb-render`) | | **SWC** | Compiles TSX source to JavaScript before handing it off to esbuild or the render host. Lives inside `zfb-render`. | Internal to `zfb-render`; swappable by replacing that crate's transform step | | **lol_html** | Streaming HTML rewriter used to inject hydration entry-point `` tags and island mount markers into the rendered HTML without a full parse. | Used inside `zfb-build/head_inject.rs`; no public trait needed — it is a low-level utility | ### How the layering works zfb owns orchestration, the dependency graph, and the per-page contract. The tools each appear in exactly one layer: - **esbuild** is called in two places: `zfb-build` (server worker bundle) and `zfb-islands` (per-island client bundles). Both use it for bundling TypeScript/JSX source trees; neither exposes it to the crates above. - **deno_core (V8)** is the JS engine behind `RenderHost`. The renderer in `zfb-render` calls `RenderHost::call_default`; the concrete host (`EmbeddedV8RenderHost`) runs the bundle in an in-process V8 isolate. The orchestrator in `zfb-build` never names the engine — it only receives rendered HTML. - **SWC** runs inside `zfb-render` before the module reaches the render host, or inside `zfb-build/bundler.rs` before esbuild sees the source. Either way it is invisible to the orchestrator. - **lol_html** runs after the render host returns HTML, inside `zfb-build/head_inject.rs`. The orchestrator sees plain strings in and plain strings out. This structure means the crates above each tool's trait boundary are unaffected when the tool changes. See [Islands](/concepts/islands) for the `ClientBundler` contract and [Incremental rebuild](/concepts/incremental-rebuild) for how the dependency graph feeds the per-page policy. ## V8-mode gate: `output` and auto-detection `zfb-render` exposes the embedded V8 host behind a default-on `embed_v8` cargo feature. The build engine decides at the start of every build whether the deploy artifact assumes a V8-bearing runtime is part of the shape — the `V8Mode` decision — and surfaces a clear error when the config and the route table disagree. The decision is driven by two inputs: - `Config.output` in `zfb.config.ts` (`"static"` / `"hybrid"` / `"auto"`, default `"auto"`). - The detected set of routes that export `prerender = false`. The same data the no-SSR-without-adapter precondition already uses — one walk over `prerender_map`, one decision point. | `output` | SSR routes present | result | | ---------- | ------------------ | ------------- | | `"static"` | no | `V8Mode::Off` | | `"static"` | yes | **error** | | `"hybrid"` | any | `V8Mode::On` | | `"auto"` | no | `V8Mode::Off` | | `"auto"` | yes | `V8Mode::On` | The error fires before the bundler runs, names both the `output` setting and the first offending route, and counts the rest if there are more. So a project that declares itself static can't accidentally pick up an SSR route as a result of a copy-paste — the build refuses the contradiction instead of silently flipping the route's deploy shape. The two manual overrides (`"static"`, `"hybrid"`) and the default (`"auto"`) cover three distinct intents: - `"auto"` — let the route table decide. Best default for projects that already match. - `"static"` — declare intent up front. Useful on SSG-only sites where the failure mode of "someone adds `prerender = false` to a page" should be loud, not silent. - `"hybrid"` — declare intent the other way. Useful for projects that will add SSR routes later and want the build topology stable in the meantime. What `V8Mode::Off` does today is the part to be honest about: it is observational on the shipping `zfb` binary. The build machine's `zfb` always boots V8 to render SSG pages — that's how the pipeline works — and `embed_v8 = off` is already a hard error at `zfb build`. The mode is wired so the future shipping path (Tauri sidecar, standalone SSR server, `cargo install`-as-deploy) can read the same decision without re-deriving it. The load-bearing user-visible role today is the `"static"` precondition error. ## Atomic writes Every file in `dist/` is written through `atomic_write_string` (in `zfb-build`'s `atomic.rs`): write to a sibling temp file in the same directory, then `rename` over the destination. `rename` is atomic on POSIX for same-disk files, and Windows has the same guarantee through `MoveFileExW`'s replace-existing semantics. Concretely: - A reader opening `dist/index.html` mid-build sees the old bytes or the new bytes — never half-written, never empty. - A crashed build leaves orphan `*.tmp--` files but never corrupts output. The naming is deliberate: `ls dist/` after a crash shows what was in flight. - The dev server can serve `dist/` while the orchestrator rewrites it. No coordination needed. This is the boring kind of correctness: not a feature, an invariant we never violate. ## Watcher debounce and change coalescing `zfb-watcher` debounces native events with a 50ms default window. Editor saves are messy — vim writes to a swap file then renames; vscode emits multiple metadata events; `git checkout` produces hundreds of events at once. The debounce collapses each burst into one `Change` per path. The orchestrator does a second pass: when a tick fires, it drains every `Change` already in the channel before invoking the pipeline. A fast save burst still produces one pipeline run per natural pause. The orchestrator does no extra time-based coalescing on top of the watcher — the watcher already did the right thing. Typing fast does not thrash the build. ## Relationship to the dev server The dev server (`zfb-server`) is a thin reader on top of the orchestrator's outputs. It owns a `PageCache` (URL path to rendered HTML, populated after every render), a `tokio::sync::broadcast` channel of `ReloadEvent` values, and an `axum` router that serves the cache, `dist/assets/`, `public/`, and an SSE endpoint at `/__zfb/reload`. The wiring point is `outcome_to_events` in `zfb-server`'s `livereload.rs`. Every non-noop `BuildOutcome` is translated into `ReloadEvent`s and broadcast. CSS-only changes emit a `css` event so the browser swaps stylesheets without losing client state; everything else emits a `page` event and triggers `location.reload()`. ### Request-time SSR for `prerender = false` routes The dev server also serves `prerender = false` routes through the **same** embedded V8 host that drives build-time SSG — not from a stamped static snapshot. The dev router's per-request precedence is: 1. plugin dev-middleware (longest-prefix match wins), 2. **request-time SSR** for any URL matched by an `SsrRouteSet`, 3. in-memory page cache (SSG output), 4. on-disk fallback to `dist/`, 5. on-disk fallback to `public/`, 6. dev 404. The SSR layer is wired via a small `SsrDispatcher` trait in `zfb-server`. The bin crate (`crates/zfb/src/commands/dev.rs`) provides the concrete implementation via `EmbeddedV8SsrAdapter`, which clones a handle to the renderer's `Arc>>` and dispatches through `EmbeddedV8Host::dispatch_fetch` on a `spawn_blocking` task. The V8 isolate stays on its dedicated OS thread; the adapter doesn't spawn a second thread. This is what makes "dev matches prod" a real guarantee: the dev preview's `prerender = false` output is **semantically equivalent** (same status, body, content-type — timestamps and request IDs may differ) to what the Cloudflare adapter produces from the same source. The shared V8 host means there is no second renderer to drift. The server is dev-only. Production emits static files for an edge CDN, plus a `_worker.js` from the Cloudflare adapter for `prerender = false` routes. The same atomic-write guarantee that makes the dev server safe makes any production deploy safe. ### Embed-as-library: host-supplied HTTP handlers A Rust host (a Tauri desktop app, a CLI tool, a containerised service) can run `zfb-server` in-process via the `Server::builder()` API. That builder also exposes `with_ssr_handler(pattern, handler)`, which registers a host-owned async function for a URL pattern. Registered handlers slot into the dev router's precedence chain **between plugin dev-middleware and the runtime SSR dispatcher**: 1. plugin dev-middleware, 2. **host-registered Rust handler** (new — embed-as-library only), 3. request-time SSR for any URL matched by an `SsrRouteSet`, 4. in-memory page cache (SSG output), 5. on-disk fallback to `dist/`, 6. on-disk fallback to `public/`, 7. dev 404. The host handler wins over any same-path runtime-SSR page — that is the whole point of the seam. See the [Embed-as-library guide](/guides/embed-as-library) for the builder shape, the handler signature, and the precedence contract in code. --- # Routing > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/routing zfb uses **file-system routing** under the `pages/` directory. The router scans `pages/` at build time (and on every change in dev), turning each page source file into a route. The conventions match what you might recognise from Next.js or Astro. ## File-to-route mapping | File | Route | Notes | | ---- | ----- | ----- | | `pages/index.tsx` | `/` | | | `pages/about.tsx` | `/about` | | | `pages/about.md` | `/about` | SSG-only; MDX pipeline | | `pages/about.html` | `/about` | SSG-only; static-asset copy | | `pages/blog/index.tsx` | `/blog` | | | `pages/blog/[slug].tsx` | `/blog/:slug` (dynamic) | | | `pages/docs/[...slug].tsx` | `/docs/:slug*` (catchall) | | | `pages/[lang]/[slug].tsx` | `/:lang/:slug` | | A few rules worth knowing up front: - Files starting with `_` (for example `_app.tsx`) are ignored. Use this prefix for shared helpers that live next to your routes but should not be exposed. - Accepted page extensions are `.tsx`, `.md`, and `.html`. Files with any other extension in `pages/` are skipped (a warning is logged), so README files and ad-hoc notes are safe to drop there. - Two files that resolve to the same route raise `RouterError::AmbiguousRoute` at build time, so the router never silently picks a winner. See [Markdown and HTML Pages](/concepts/md-html-pages) for the full contract and v1 limitations of `.md` and `.html` page entries. The scan is performed by `Router::scan` in the `zfb-router` crate. Results are sorted so that **static routes win over dynamic, and dynamic wins over catchall** — more specific routes are matched first. ## Static, dynamic, and catchall routes Static routes (`pages/about.tsx`) match a single concrete URL. Dynamic routes use `[param]` brackets in the file name to capture a single path segment, and catchall routes use `[...param]` to capture any number of trailing segments. ```tsx // pages/blog/[slug].tsx return Post for {slug}; } ``` ```tsx // pages/docs/[...slug].tsx return {slug.join("/")}; } ``` ## The `paths()` export Dynamic and catchall routes need to know which concrete URLs to render at build time. This is done by exporting a `paths()` function from the same file: ```tsx // pages/blog/[slug].tsx const posts = getCollection("blog"); return posts.map((p) => ({ params: { slug: p.slug } })); } return Post {params.slug}; } ``` `paths()` is evaluated by the embedded V8 host during the build. `zfb build` discovers static, dynamic, and catchall routes and evaluates `paths()` for each dynamic and catchall route to enumerate the concrete URLs to render. Static routes require no `paths()` export. --- # Introduction > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/getting-started/introduction zfb is a Rust-built static-site engine for people who already know Astro or Next.js and want millisecond rebuilds, a single-binary toolchain, and an opt-in island model. If you can read a `pages/` directory tree and write a TSX component, you already know 90 percent of what zfb requires. ## What zfb provides For the "why this shape" answer — narrow scope, recipes-over-plugins, no middleware layer — see [Design philosophy](/concepts/design-philosophy). - **Embedded V8 worker** — pages render through a Rust-embedded V8 runtime. No separate Node process is required at build time. - **Atomic writes** — the build never leaves the output directory in a partially-written state. Each `zfb build` run either completes fully or leaves `dist/` unchanged. - **Per-page incremental rebuild** — changing a shared layout touches only the pages that import it, not the whole site. Dev-loop latency stays in the tens of milliseconds even on large sites. - **Opt-in islands** — components are server-rendered by default. Add `"use client"` to a component file to opt into client-side hydration. Pages with no islands ship zero JavaScript. - **Frontmatter and content collections** — Markdown, MDX, and TypeScript data files in a named directory become a typed collection, queryable from pages via `getCollection()`. - **Dynamic routes** — `pages/blog/[slug].tsx` exports a `paths()` function; zfb evaluates it at build time to expand the route into one HTML file per slug. - **MDX components** — import any TSX component into `.mdx` content and use it as JSX. - **Non-HTML pages** — a page file can export a `contentType` to produce JSON, XML, RSS, or any other text format instead of HTML. - **Content-query bridge** — `getCollection()` is available inside MDX files and inside `paths()`, not only in page components. - **Single-binary distribution** — zfb ships as one Rust binary. No `node_modules` for the framework itself, no plugin registry to audit. ## Familiar from Astro | Astro concept | zfb equivalent | | --- | --- | | `src/pages/` file-system routing | `pages/` directory, same naming conventions | | `.astro` components with a frontmatter fence | TSX page files with a `getStaticProps` export | | Content Collections with typed schemas | `getCollection()` + schema declared in `zfb.config.ts` | | `` in layouts | React `children` prop — plain TSX composition | | `client:load` directive | `"use client"` directive at the top of a component file | | `public/` for static assets | `public/` — same semantics | | `astro.config.mjs` | `zfb.config.ts` (TypeScript, loaded directly) | What is different: zfb has no component syntax of its own — everything is TSX. There is no `.astro` file format to learn. The trade-off is that you lose Astro's per-component fence syntax and gain a single consistent authoring surface for pages, layouts, and components. ## Familiar from Next.js | Next.js concept | zfb equivalent | | --- | --- | | `pages/` directory routing (Pages Router) | `pages/` — same file naming and nesting rules | | `getStaticProps` / `getStaticPaths` | `getStaticProps` + `paths()` export, same two-function pattern | | Dynamic routes `[slug].tsx` | `[slug].tsx` — identical syntax | | Catch-all routes `[...slug].tsx` | `[...slug].tsx` — identical syntax | | `public/` for static assets | `public/` — same semantics | | Layout components wrapping pages | Layout components wrapping pages — plain TSX composition | What is different: zfb is **SSG by default** — every page is computed at build time into static HTML. Routes that genuinely need request-time behavior opt out with `prerender = false` and are served by a configured adapter (e.g. Cloudflare Pages via [`@takazudo/zfb-adapter-cloudflare`](/guides/ssr-and-cloudflare-bindings)). What zfb does not aim to be is a full app framework — there is no App Router, no React Server Components, no middleware layer, and no per-page automatic ISR. The runtime story is "SSG plus an adapter-shaped escape hatch," not "Next.js with `output: export` only." ## Client router and view transitions The `@takazudo/zfb-runtime` package ships a `` component that intercepts same-origin navigations and replaces page content without a full reload, with optional View Transitions API support. Opt in by mounting it in your root layout — pages that do not mount it get normal link behavior. ## Migration guides - [Migrating from Astro](/guides/migrating-from-astro) — concept-by-concept map for moving an existing Astro static site to zfb. ## What to read next [Installation](/getting-started/installation) covers building and installing the CLI. [Your first site](/getting-started/your-first-site) walks through scaffolding a project and running a build end to end. --- # Migrating from Astro > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/migrating-from-astro If you already maintain an Astro site, most of zfb will feel familiar. Both projects ship file-based routing, content collections, and partial hydration. The differences are mostly about scope: zfb is smaller, more opinionated, and Rust-backed end to end. ## Directory layout Astro puts everything under `src/`. zfb does not. | Astro | zfb | | -------------------- | -------------------- | | `src/pages/` | `pages/` | | `src/layouts/` | `layouts/` | | `src/components/` | `components/` | | `src/content/` | `content/` | | `astro.config.mjs` | `zfb.config.ts` | The flatter layout matches the smaller surface area. There is no `src/` namespace to remember. ## Components: one model, not two Astro's headline feature is the `.astro` file — a server-only template language with optional islands of framework code. zfb collapses that into a single component model: every component is a `.tsx` file. The `framework` field in `zfb.config` selects either Preact or React, and the same JSX flavor is used for layouts, pages, and components. ```tsx return Hello; } ``` There is no separate template syntax to learn, but if you relied on `.astro` for things like top-level `await` outside components, you will need to move that work into a `paths()` export or a data collection. ## Content collections Astro's `getCollection("blog")` from `astro:content` has a near-direct equivalent in zfb: ```ts const posts = await getCollection("blog"); ``` The return shape is similar — each entry exposes `slug`, `data` (frontmatter), and `body`. See /api/get-collection for the full surface. Schemas move from inline Zod to declarative config. Declare collections in `zfb.config.ts` using `defineConfig`: ```ts // zfb.config.ts collections: [ { name: "blog", path: "content/blog" }, ], }); ``` There is no Zod — front-matter field validation is expressed as string tags (`"string"`, `"number"`, `"date"`, with a trailing `?` for optional) inside the optional `schema` field. See [`defineConfig`](/api/define-config) for the full shape. ## Islands Astro uses `client:load`, `client:visible`, and friends as JSX attributes on imported framework components. zfb uses a file-level `"use client"` directive instead — the same pattern Next.js App Router popularized: ```tsx "use client"; const [n, setN] = useState(0); return setN(n + 1)}>{n}; } ``` Anything imported from a `"use client"` file becomes an island automatically. See /api/island. ## Slots and layouts Astro's `` becomes plain JSX `children`: ```tsx return {children}; } ``` ## What zfb ships differently A few Astro features have direct zfb equivalents under a different name: - **View transitions** — use `` from `@takazudo/zfb-runtime` (the same SPA router with view-transition animations that Astro's `` provides). - **Islands / client directives** — replaced by the `"use client"` file directive (the Next.js App Router pattern); see [Islands](/concepts/islands). Some features have no current equivalent: an integrations marketplace, Astro DB, and server endpoints. If your site relies on these, evaluate whether the zfb plugin system or an adapter covers your use case before committing to a port. ### Engine additions since v0 The following capabilities were not part of the initial zfb release but are available today. If your Astro site used the equivalent patterns, here is where to reach in zfb: **Canonical site URL (`site`)** Astro's `site` config key maps directly to zfb's top-level `site` option: ```ts // zfb.config.ts site: "https://example.com", }); ``` When set, `globalThis.__zfb.site` is available at render time so layouts can build canonical `` tags and OpenGraph meta. See [`defineConfig`](/api/define-config). **Plugin lifecycle: `setup`, `addAlias`, `addVirtualModule`, `injectRoute`** Astro integrations expose `updateConfig`, `addWatchFile`, and `injectRoute` during the `astro:config:setup` hook. zfb's equivalent is the `setup` hook in the plugin lifecycle: ```ts name: "my-plugin", setup({ command, addAlias, addVirtualModule, injectRoute }) { addAlias("@/components/foo", "./src/components/foo.tsx"); addVirtualModule("virtual:my-data", () => `export default ${JSON.stringify({ key: "value" })}`, ); if (command === "dev") { injectRoute("/api/dev/x", "./scripts/dev-x.ts"); } }, }); ``` `addAlias` performs exact-match import rewrites (prefix matching is a future revision). `addVirtualModule` exposes a synthetic ESM module by specifier. `injectRoute` is dev-only; calling it during `zfb build` is an error. See [Plugins](/concepts/plugins) for the full contract. **Route manifest in `postBuild` (`ctx.routes`)** Astro exposes a `injectRoute` + `astro:build:done` pattern for post-build work. In zfb, `postBuild` plugins receive `ctx.routes` — the complete manifest of every URL the build emitted: ```ts postBuild({ outDir, routes }) { const htmlRoutes = routes.routes.filter((r) => r.extension === "html"); // write sitemap, feed, etc. }, ``` See the [Plugins](/concepts/plugins) page for the full `ZfbRouteManifest` shape and a worked sitemap example. **Markdown: Table of Contents, external links, CJK-friendly emphasis** Astro users often reach for `remark-toc`, `rehype-external-links`, or CJK patches as integrations. In zfb, these are built-in opt-ins inside the `markdown` config block: ```ts // zfb.config.ts markdown: { toc: { heading: "TOC", maxDepth: 2 }, externalLinks: { target: "_blank", rel: ["noopener", "noreferrer"] }, cjkFriendly: true, // on by default }, }); ``` See [Customizing Markdown](/guides/customizing-markdown) for full details. **Custom theme files for syntax highlighting (`codeHighlight.themesDir`)** Astro's `shiki` integration accepts any Shiki theme name. zfb uses syntect (Sublime Text–compatible `.tmTheme` files). Point `codeHighlight.themesDir` at a directory containing your `.tmTheme` files: ```ts codeHighlight: { themesDir: "./themes", theme: "Dracula", }, }); ``` See [Syntax Highlighting](/guides/syntax-highlighting). ## Patterns that move to user space zfb deliberately omits some Astro features. This is not oversight — these patterns have clean userland solutions in a pure-JSX/Preact model that do not need engine support. The sections below show the recommended recipe for each. ### `client:media` — conditional hydration by media query Astro's `client:media="(max-width: 768px)"` defers hydration until a media query matches. zfb's `"use client"` directive always hydrates on load. The Preact equivalent is an early return inside the component itself: ```tsx "use client"; interface Props { children: preact.ComponentChildren; } /** Renders children only on viewports that match the query. */ const [matches, setMatches] = useState(false); useEffect(() => { const mq = window.matchMedia("(max-width: 768px)"); setMatches(mq.matches); const handler = (e: MediaQueryListEvent) => setMatches(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, []); if (!matches) return null; return <>{children}; } ``` Use this as a wrapper: ``. The wrapped component only mounts when the query matches; it unmounts when the query stops matching. ### Per-page `` additions (Astro's ``) Astro lets pages inject arbitrary `` elements via ``. zfb does not have a slot mechanism at the engine level. `PageMeta` currently rejects unknown fields; if that constraint relaxes later, this recipe will get simpler. The userland pattern is a **Preact context-based helmet**. The layout renders the page body first (populating the collector), then emits the collected head nodes. This avoids a second render pass: ```tsx // components/head-context.tsx interface HeadContextValue { nodes: preact.VNode[]; add(node: preact.VNode): void; } nodes: [], add() {}, }); return useContext(HeadContext); } ``` ```tsx // components/head.tsx — drop inside a page to register head nodes interface Props { children: preact.VNode | preact.VNode[]; } const { add } = useHead(); const registered = useRef(false); if (!registered.current) { registered.current = true; const nodes = Array.isArray(children) ? children : [children]; nodes.forEach(add); } return null; // renders nothing inline } ``` ```tsx // layouts/base.tsx const [nodes] = useState([]); const ctx = { nodes, add: (n: preact.VNode) => nodes.push(n) }; // Render children first so Head calls populate `nodes` before we emit . const body = {children}; return ( {nodes} {body} ); } ``` ```tsx // pages/blog/[slug].tsx return ( <> {post.data.title} {/* ... */} ); } ``` This pattern relies on Preact rendering children before siblings in the same pass. It works for zfb's static-build SSR (top-down rendering, children before parent `{nodes}` emission). If you add a streaming SSR adapter, verify that the head is flushed after body rendering completes. ### Preact-compat aliases (`@/components/svg`, `@/components/responsive-image`) Some Astro projects register aliases like `@/components/svg` and `@/components/responsive-image` to work around Astro's component model — for example, bridging `.astro` SVG components into JSX islands. In zfb every component is `.tsx` from the start; the bridging layer those aliases provided does not exist. During migration, **remove these aliases and pointing straight at the `.tsx` file): ```ts // Before (Astro workaround — remove): // addAlias("@/components/svg", "./src/components/astro-svg-bridge"); // After (zfb — import directly or use a simple alias): addAlias("@/svg", "./components/svg.tsx"); ``` If the aliased file was an Astro component (`.astro`) you will need to rewrite it as a `.tsx` component first. ### Custom hydration with `data-island` markers Astro islands serialize component props as JSON in `` tags next to the rendered HTML. Some projects extend this with custom `data-island` attributes for fine-grained control. This pattern ports **byte-for-byte** to zfb — you own the full hydration strategy. Ship your own `` that queries the markers and calls Preact's `hydrate()`: ```tsx "use client"; // components/island-hydrator.tsx — add to your root layout as a "use client" island const REGISTRY: Record = { "my-widget": MyWidget, // add more components here }; useEffect(() => { document.querySelectorAll("[data-island]").forEach((el) => { const name = el.dataset.island!; const Component = REGISTRY[name]; if (!Component) return; const props = JSON.parse(el.dataset.props ?? "{}"); hydrate(, el); }); }, []); return null; } ``` Then in server-rendered markup, emit ``. ### Custom remark / rehype plugins Astro accepts arbitrary remark and rehype plugins via `remarkPlugins` / `rehypePlugins` in `astro.config.mjs`. zfb's Markdown pipeline is Rust-backed. There is no npm-level plugin loader for remark or rehype. Markdown-level customisation ships as an in-tree Rust visitor — a `MdastVisitor` or `HastVisitor` implementation compiled into the binary. For most sites this is not a migration blocker: the common built-in remark plugins (TOC, external links, CJK fixing) are already available as first-class options in `zfb.config.ts`. If you relied on a custom plugin for something more specific, see [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) for the in-tree Rust visitor path. --- # Install without Node > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/install/node-free zfb ships as a self-contained Rust binary. **Node.js is not required to install or run it** — the build engine, esbuild, and Tailwind are all compiled into the binary or bundled as Go binaries. You only need Node as an opt-in escape hatch for specific features (see [When Node is still needed](#when-node-is-still-needed) below). The Node-free path supports the full feature set for content sites: Markdown, MDX, HTML pages, **and islands** — esbuild and Tailwind run as embedded Go binaries inside the zfb binary, so client-side hydration works with zero Node involvement. ## Install paths ### Linux / macOS — curl ```sh curl -fsSL https://raw.githubusercontent.com/Takazudo/zudo-front-builder/main/install.sh | sh ``` Installs `zfb` to `$HOME/.local/bin/zfb`. Add that directory to your `PATH` if it isn't already: ```sh ``` **Environment variables:** | Variable | Default | Purpose | | --- | --- | --- | | `ZFB_INSTALL` | `$HOME/.local` | Install prefix. Binary lands at `$ZFB_INSTALL/bin/zfb`. | | `ZFB_VERSION` | (latest stable) | Pin a release. Use `v0.X.Y` for a specific tag, or `latest-prerelease` to opt into pre-releases. | ### macOS — Homebrew ```sh brew install Takazudo/tap/zfb ``` The tap is hosted at [Takazudo/homebrew-tap](https://github.com/Takazudo/homebrew-tap). The formula is updated on each **stable** release by `scripts/update-homebrew-formula.sh`. Like the npm `latest` tag, the Homebrew formula follows **stable** releases only — it has no per-version pin or prerelease opt-in. To install a specific version or a prerelease, use the curl installer with `ZFB_VERSION` (e.g. `ZFB_VERSION=latest-prerelease`) instead. During the current pre-1.0 phase the tap may serve the latest prerelease as an early-access build until the first stable `vX.Y.Z` ships. To update to a newer version later: ```sh brew upgrade zfb ``` ### Windows — PowerShell ```powershell irm https://raw.githubusercontent.com/Takazudo/zudo-front-builder/main/install.ps1 | iex ``` Installs `zfb.exe` to `%LOCALAPPDATA%\zfb\bin\zfb.exe`. **Environment variables:** | Variable | Default | Purpose | | --- | --- | --- | | `ZFB_INSTALL` | `%LOCALAPPDATA%\zfb` | Install root. Binary lands at `$ZFB_INSTALL\bin\zfb.exe`. | | `ZFB_VERSION` | (latest stable) | Pin a release tag (e.g. `v0.2.0`) or `latest-prerelease`. | Add the binary to your `PATH` for the current session: ```powershell $env:PATH = "$env:LOCALAPPDATA\zfb\bin;$env:PATH" ``` Or permanently (open a new terminal after running): ```powershell [System.Environment]::SetEnvironmentVariable( 'PATH', "$env:LOCALAPPDATA\zfb\bin;" + [System.Environment]::GetEnvironmentVariable('PATH', 'User'), 'User' ) ``` ### Windows — Scoop A Scoop bucket (`Takazudo/scoop-bucket`) is planned but the bucket repository has not been created yet. Use the PowerShell script above in the meantime. ## Verify the install ```sh zfb --version ``` ## Config file: `zfb.config.json` vs `zfb.config.ts` Both config formats work without Node. `zfb.config.ts` is evaluated in-process via the embedded V8 isolate that ships with the default `zfb` binary — there is no runtime Node dependency. `zfb.config.ts` is a **data config** — `node:*` imports, `process.env`, and other Node-only APIs are not available inside the evaluator. Use `zfb.config.ts` when you want TypeScript types, comments, and computed values. Use `zfb.config.json` for a plain JSON alternative. ```jsonc // zfb.config.json — also Node-free compatible { "framework": "preact", "outDir": "dist", "collections": { "blog": { "dir": "content/blog", "schema": { "type": "object", "properties": { "title": { "type": "string" } } } } } } ``` If both `zfb.config.ts` and `zfb.config.json` are present, the `.ts` file wins. ## When Node is still needed The following features require Node in `PATH`: | Feature | Why | | --- | --- | | `zfb check` without `--skip-tsc` | TypeScript type-checking delegates to `tsc`, which is a Node package. Pass `--skip-tsc` to run the non-TS checks only. | | Cloudflare adapter (`@takazudo/zfb-adapter-cloudflare`) | The adapter depends on Wrangler, which is a Node CLI. SSR deployments to Cloudflare Pages always need Node in the deploy environment. | Everything else — building, dev server, islands, Tailwind, MDX, content collections, and `zfb.config.ts` evaluation — runs Node-free. ## Upgrading zfb An in-place `zfb upgrade` command is planned for a future release. For now, re-run the same install command you used initially — it overwrites the existing binary with the latest version. --- # Recipes > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/recipes A maintainer-curated set of small, focused patterns for composing zfb's primitives — `addVirtualModule`, `postBuild` plugins, `ctx.routes`, non-HTML pages, MDX components — into concrete solutions you can adapt. Each recipe is: - A single article on a single topic - Written as an explanation, not a code dump — the _why_ matters as much as the _how_ - Anchored to a specific zfb version range, so you know what state of the engine it assumes ## Recipes - [Enlargeable Images](/recipes/enlargeable-images) — reproduce the old `imageEnlarge` built-in in userland using the `img` component override. Covers the server-side (no JS) and islands variants, plus the `no-enlarge` opt-out. --- # Dynamic Routes > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/dynamic-routes How `paths()` enumerates the URLs a dynamic or catchall page should emit, the `{ params, props }` contract it returns, and how page components receive the data. For static-route fundamentals, see [Routing](/concepts/routing). A dynamic route — `pages/blog/[slug].tsx` — doesn't map to a single URL. The bracketed segment is a parameter, and zfb needs to know which concrete values to fill it with at build time. That's the job of `paths()`. Catchall routes — `pages/docs/[...slug].tsx` — work the same way, but their `slug` parameter captures one or more trailing segments instead of a single one. ## The `paths()` contract `paths()` is a synchronous export. It returns an array of `{ params, props }` objects. The router consumes the array; one entry becomes one rendered URL. ```ts type PathEntry> = { /** Values for the bracketed segments, keyed by parameter name. */ params: Record; /** Optional per-URL data threaded to the page component as `props`. */ props?: P; }; ``` - `params` keys must match the bracketed names in the filename. For `[slug].tsx`, the key is `slug`. For `[lang]/[slug].tsx`, you supply both `lang` and `slug`. For catchall `[...slug].tsx`, `slug` is a `string[]` of the trailing segments. - `props` is optional, opaque to the engine, and forwarded verbatim to the page component as the `props` prop. The engine never inspects it. ## A blog post page The canonical use of `paths()` is enumerating slugs from a content collection: ```tsx // pages/blog/[slug].tsx const posts = getCollection("blog"); return posts.map((post) => ({ params: { slug: post.slug }, props: { title: post.data.title }, })); } const post = getEntry("blog", params.slug); if (!post) return Not found.; return ( {props.title} ); } ``` A few things worth noting: - `getCollection` and `getEntry` are synchronous — the full content snapshot is pre-built in Rust before any TSX runs. `paths()` doesn't need `async`, and neither does the page component. - `params.slug` is what hits the URL. A post with `slug: "hello-zfb"` becomes `/blog/hello-zfb`. - `props.title` is opaque to the engine — it's just data threaded to the component. You can put anything serializable there. ## Catchall: a full-tree docs page Catchall routes capture any number of trailing segments, useful when the same template renders many depths under one prefix. The `slug` parameter arrives as `string[]`: ```tsx // pages/docs/[...slug].tsx const entries = getCollection("docs"); return entries.map((entry) => ({ // entry.slug looks like "guides/setup" or "concepts/routing" params: { slug: entry.slug.split("/") }, })); } const slugPath = params.slug.join("/"); const entry = getEntry("docs", slugPath); if (!entry) return Not found.; return ; } ``` `/docs/concepts/routing` matches with `params.slug === ["concepts", "routing"]`. `/docs/guides/setup` matches with `params.slug === ["guides", "setup"]`. The router rebuilds the slash-separated form (`slug.join("/")`) when you need to look up an entry by it. ## Static, dynamic, and catchall — how they fit together | Filename | Kind | Example URL | `params` shape | | -------- | ---- | ----------- | -------------- | | `pages/about.tsx` | static | `/about` | n/a | | `pages/blog/[slug].tsx` | dynamic | `/blog/hello-zfb` | `{ slug: string }` | | `pages/docs/[...slug].tsx` | catchall | `/docs/a/b/c` | `{ slug: string[] }` | | `pages/[lang]/[slug].tsx` | dynamic × 2 | `/ja/intro` | `{ lang: string, slug: string }` | When two patterns can match the same URL, the more specific one wins: **static beats dynamic; dynamic beats catchall.** The router enforces this when it builds the route table — see [Routing](/concepts/routing) for the full sort. ## Rules and gotchas - Every `params` key must have a value. Missing keys raise a build error before any HTML is written. - For catchall segments, `params.slug` must be a `string[]` (even with one element). Passing a plain string is a type error. - `paths()` is **synchronous**. The whole content snapshot is loaded before any page evaluates, so there's no async boundary to wait on. Keep it pure-data and deterministic — the router calls it during route enumeration, well before render. - Two routes that resolve to the same template raise `RouterError::AmbiguousRoute` at build time. The router never silently picks a winner. ## See also - [Routing](/concepts/routing) — static-route fundamentals. - [Content Collections](/concepts/content-collections) — the data source most `paths()` calls draw from; also documents the synchronous `getCollection` / `getEntry` API. --- # Markdown and HTML Pages > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/md-html-pages How `.md` and `.html` files in `pages/` become routes. Covers the frontmatter keys recognised for `.md` pages, the v1 layout limitation, the static-asset contract for `.html` pages, and the SSG-only constraint shared by both. zfb accepts three page source extensions: `.tsx`, `.md`, and `.html`. All three follow the same file-system routing conventions under `pages/` — `pages/about.md` and `pages/about.tsx` both produce the route `/about`. What differs is the render path each extension takes. ## `.md` pages `.md` files placed in `pages/` are compiled through the same MDX pipeline that drives content collections. The engine wraps the compiled body in a minimal HTML shell and writes the result to `dist/` like any other page. ### Frontmatter keys Only two frontmatter keys are read for `.md` pages (v1 contract): | Key | Type | Default | Effect | | --- | ---- | ------- | ------ | | `title` | string | URL slug | Sets the `` element in the HTML shell. | | `lang` | string | `"en"` | Sets ``. | All other keys are **silently ignored**. There is no schema validation and no error for unknown keys — they are simply not surfaced in the rendered output. ```md --- title: About this project lang: en --- ## What is this? A short description of the project. ``` The slug fallback for `title` is derived from the file stem — the last path segment without its extension. `pages/about.md` falls back to `"about"`, and `pages/docs/intro.md` falls back to `"intro"` (not `"docs/intro"`). Use frontmatter `title:` when you need a more specific title. ### Relative links Relative Markdown links go through the standard MDX pipeline and are resolved relative to the source file. ### v1 layout limitation There is **no layout system** for `.md` pages in v1. A `layout:` key in frontmatter has no effect. The engine always produces a bare HTML document wrapping the MDX body — no ``, no custom wrapper. If you need a shared layout (navigation, header, footer), write a `.tsx` page instead and import the Markdown content as a component, or use a content collection. `layout:` frontmatter is ignored for `.md` page entries. Use `.tsx` if you need a layout wrapper around Markdown content. ### SSG-only `.md` page entries are **SSG-only**. They are not supported in SSR mode (Cloudflare Worker runtime). Use `.tsx` if you need server-side rendering. ## `.html` pages `.html` files in `pages/` are copied verbatim to `dist/` without any JavaScript rendering or post-processing. The engine treats them as static assets that happen to live under the routing tree. ### Full-document requirement The file must be a **complete HTML document**. It must start with ` Static page Hello from a static .html page ``` ### No post-processing Because `.html` pages bypass the JS render pipeline entirely, the following do **not** apply: - **Base-path rewriting** — if your site uses a `base` prefix (e.g. `/pj/site/`), absolute href/src values in the file are not rewritten. - **Link normalisation** — the link normaliser that `.tsx` and `.md` pages go through is not run. - **Sitemap inclusion** — `.html` page routes are not automatically added to a generated `sitemap.xml`. If any of those are needed, use `.md` or `.tsx` instead. ### SSG-only `.html` page entries are **SSG-only**. They are not supported in SSR mode. ## Shared: SSG-only constraint Both `.md` and `.html` pages are build-time (SSG) only. Dynamic routes using `[param]` or `[...param]` brackets are valid for `.md` and `.html` file names — the router parses them the same way as `.tsx` — but the `paths()` export mechanism used to enumerate dynamic URLs at build time is a `.tsx`-only feature in v1. Static (non-parameterised) `.md` and `.html` page paths work correctly. ## Choosing the right extension | Situation | Recommended extension | | --------- | --------------------- | | Rich JSX page with a layout | `.tsx` | | Simple content-only page, no shared layout needed | `.md` | | Pre-authored static HTML file that needs no processing | `.html` | | Page that contributes to sitemap or uses base-path | `.tsx` or `.md` | | Server-side rendered page | `.tsx` | ## See also - [Routing](/concepts/routing) — the full file-to-route mapping table. - [Frontmatter](/concepts/frontmatter) — the unified frontmatter contract for `.tsx` pages (includes TSX literal-only rules and output-extension precedence). - [Non-HTML Pages](/concepts/non-html-pages) — emit XML, JSON, or plain text from `.tsx` pages. - [Content Collections](/concepts/content-collections) — the `getCollection()` API for `.md` / `.mdx` files under `content/`. --- # getCollection > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/api/get-collection ## Signature ```ts getCollection>(name: string): CollectionEntry[] ``` Call `getCollection` from a page module to load every entry in a named collection. The collection must be declared in your `zfb.config` under `collections`. The function is synchronous — it returns a plain array, not a Promise. Schema validation runs against each entry as it is read, so by the time the array reaches your component, frontmatter is already typed and verified. ## CollectionEntry shape Each entry has the following fields: - `slug: string` — the filename without its `.md` extension, normalized to forward-slash paths. Nested files produce path-based slugs (e.g. `"2024/hello"`). - `data: T` — the parsed frontmatter, typed by the generic parameter. - `body: string` — the raw markdown body with frontmatter stripped. - `module_specifier: string` — stable bridge key in the form `mdx:///`. Used internally by the renderer; pass to `Content` lookup if building a custom bridge. - `Content: (props: ContentProps) => ContentElement` — renderable component for this entry. Renders via the renderer bridge when available; falls back to a `` block in test and dev contexts where the bridge is absent. ## ContentProps ```ts type ContentProps = { components?: Record; }; ``` The `components` prop mirrors the Astro `` convention: a flat map of element name to override component. Use `defaultComponents` from `"@takazudo/zfb"` as a base: ```tsx ``` ## Example A typical use case is rendering a list of blog posts on an index page. ```tsx const posts = getCollection<{ title: string; date: string }>("blog"); return ( {posts.map((post) => ( {post.data.title} ))} ); } ``` Note that `getCollection` returns synchronously — no `await` needed. For dynamic per-entry pages, combine `getCollection` with a `paths()` export — see [paginate](/api/paginate) for the related pattern. --- # JS runtime > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/architecture/js-runtime zfb renders TSX server-side via an embedded V8 isolate — a Rust-hosted V8 engine running a Hono-style worker bundle built from `@takazudo/zfb-runtime`. ## How the runtime choice was reached The V8 embedding decision came through a measured evaluation of available JS hosts. Early iterations used an in-process `deno_core` isolate (ratified after spiking three candidates: `deno_core`, `ssr_rs`, and `rquickjs`), then moved to a Node-hosted miniflare worker to achieve runtime parity with the Cloudflare Workers production target. Tauri distribution ultimately reversed that choice: shipping zfb as a desktop app requires a single binary with no Node.js dependency on end-user machines, and miniflare is a Node package. V8 embedding via `deno_core` was re-adopted — accepting the binary-size and first-build-time costs — because it is the only path that satisfies the no-Node single-binary constraint. The stable production contract throughout has been the _bundle shape_ (`export default { fetch }`), not the build-time engine. The same bundle that zfb renders at build time deploys unchanged to Cloudflare Workers for runtime SSR. ## Why a JS engine at all — and why V8 specifically zfb uses a JS engine not because TSX requires one, but because *evaluating* the compiled output does. The distinction matters. ### Layer 1 — JSX is just syntax SWC, oxc, and esbuild all compile JSX to `h(...)` calls — none of them need a JS engine to do it. Parsing and transforming TSX is a pure syntax operation. So "we support TSX" does not logically imply "we need V8." The question is what happens after the transform. ### Layer 2 — The compiled JS still needs to run Once JSX becomes `h(...)`, something must _evaluate_ that code: walk the call tree to produce a VDOM, then invoke `renderToString` to produce HTML. Evaluation needs a JS engine somewhere — but not necessarily V8. Two lightweight alternatives exist: - **Boa** (pure Rust, ~30 s incremental build) — a JS engine written entirely in Rust with no C++ toolchain; tracks the ECMA spec but trails V8 on coverage of newer features. - **rquickjs** (Rust bindings to QuickJS, ~210 KB binary contribution) — fast to compile, minimal footprint. Both work well for SSR of a simple, self-contained Preact component. The problem is "any frontend dev's TSX with whatever npm packages they imported." The moment a user adds a calendar library, an i18n helper, or a component that uses a Proxy-based state store, Boa's ECMA gaps and QuickJS's limited ES2022+ surface start producing silent wrong output or hard panics — failure modes that are extremely hard to diagnose. V8 is the safe bet for arbitrary ecosystem code because it is the same engine Node.js and Chromium run against. Research is open on both lighter-engine paths — see [#344 — feature-gated V8 in the production runtime](https://github.com/Takazudo/zudo-front-builder/issues/344) and [#345 — Boa / QuickJS as the SSG-only JS engine](https://github.com/Takazudo/zudo-front-builder/issues/345). ### Layer 3 — Rust templating compiles out the JS entirely Leptos and Yew take JSX-like syntax (RSX) and compile it to Rust at build time. No JS engine is needed at runtime or build time. The catch: they borrowed the syntax, not the language. There is no `import` for arbitrary npm packages, no JS closures over runtime values, and no drop-in Preact component from GitHub. A Leptos project is a Rust project — you write Rust, you depend on Rust crates. That breaks the core audience promise: a frontend developer's existing TSX and npm knowledge would not transfer. ### The audience tradeoff zfb's target audience is frontend developers who already know TSX. V8 is the price of admission for that audience — it is what makes "your existing component just works" a true statement rather than a best-effort approximation. The [design philosophy](/concepts/design-philosophy) section explains this audience-first framing in full. Pure-Rust SSG (Boa/QuickJS) and no-V8-at-runtime (build-time-only V8) are real future directions — see [#344](https://github.com/Takazudo/zudo-front-builder/issues/344) and [#345](https://github.com/Takazudo/zudo-front-builder/issues/345) — but they are optimizations on top of the default, not the default itself. ## What runs today At `zfb build` / `zfb dev` / `zfb preview`, the Rust orchestrator creates an in-process V8 isolate via `deno_core`. It loads the workerd-shape bundle that `@takazudo/zfb-runtime` builds with esbuild. Per-route props are passed as JSON; the isolate invokes the bundle's `fetch` entry with a synthetic `Request`, collects the `Response` (HTML), and returns it. The orchestrator writes plain HTML to `dist/`. No subprocess is spawned; no Node.js is required. The same bundle (`export default { fetch }`) deploys unchanged to Cloudflare Workers for runtime SSR of `prerender = false` routes. workerd executes it on request. The production path is unaware of which build-time engine produced the bundle. ## The need zfb's renderer compiles a page's TSX through SWC, hands the resulting ESM to a JS host, calls the module's `default` export, and writes the returned HTML to disk. The host must: - support ESM (`import` / `export`) so pages can import shared components naturally, - support top-level `await` so a page can `await fetch(...)` or `await loadCollection(...)` at module scope, - surface thrown errors with **source-accurate** locations — the stack-trace line must match the line in the user's TSX file, not an offset into a wrapped script, - evaluate the same module repeatedly across a build of hundreds of pages without leaking memory. ## Runtime candidate evaluation The spike crate (`crates/zfb-runtime-spike/`) implemented the same `RenderHost` trait against three candidates, gated behind cargo features so the heavy V8 build was opt-in. The following table is historical context — this evaluation informed the runtime choice. ### Candidates - **`deno_core`** — V8 wrapped by Deno's reusable core. Ships an ES module loader, an event loop for top-level await, and source-mapped errors out of the box. **The current choice.** - **`ssr_rs`** — a thin V8 wrapper specialised for Preact / React SSR. Narrower scope, single-bundle entry-point model. - **`rquickjs`** — Rust bindings around QuickJS. Small footprint, fast to compile, single-threaded. Two more were rejected without a measured spike. `rusty_v8` is the same V8 we would get through `deno_core`, only at a lower level — choosing it means re-implementing the loader and isolate plumbing `deno_core` already gives us. `boa` is a pure-Rust ESM implementation; ECMA conformance and source-map fidelity trail V8 by enough that real-world Preact / React SSR hits unsupported corners. ### Trade-off matrix The spike's bench harness loaded five representative scenarios — static page, dynamic route, content collection, `"use client"`, and top-level await — and measured cold start, warm mean, warm p95, and steady-state RSS. Headline numbers from the measurement run: | Axis | `deno_core` | `ssr_rs` | `rquickjs` | | --------------- | ----------- | --------- | ------------------ | | Warm render | 16us | 1.37ms | 106us | | Cold start | 181us | 5.88ms | 572us | | Steady-state RSS| 19MB | 317MB | 4.5MB | | ESM | native | bundle | partial | | Top-level await | native | bundle | not supported sync | | Source maps | native | partial | offsets only | | Build cost | ~3 minutes | similar | ~30 seconds | The V8-class engines win on the correctness axes; QuickJS wins on footprint and build cost. `ssr_rs`'s default per-render isolate creation dominates everything else and accumulates memory under load. ## Constraints zfb imposes Two project-level invariants shape the choice. **Single isolate per render thread.** V8's isolate is pinned to one thread. The renderer treats the host as `!Send` and runs it on a dedicated thread, exchanging work over a channel. **The runtime is fixed by the binary, not by config.** `zfb.config.ts` cannot pick its own JS runtime. The runtime is whatever the `zfb` binary you installed was compiled with. That keeps the contract between zfb and user code single-valued — every project built with one binary uses the same runtime. The bootstrap rule lives in `crates/zfb/src/config.rs`. ## The boundary Everything above the host is written against the `RenderHost` trait in `crates/zfb-render/src/render_host.rs`: ```rust pub trait RenderHost { fn execute_module(&mut self, name: &str, source: &str) -> Result; fn call_default(&mut self, handle: &ModuleHandle, props: JsonValue) -> Result; fn get_export(&mut self, handle: &ModuleHandle, name: &str) -> Result; } ``` That is the entire surface the renderer uses. `zfb-render`, `zfb-build`, and the framework adapters never name the concrete runtime. The implementation can be swapped without touching call sites — add a new host, re-run the bench, and run the adapter integration tests to confirm byte-identical HTML. --- # /CLAUDE.md > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/claude-md/root **Path:** `CLAUDE.md` # Docs Documentation site built with [zudo-doc](https://github.com/zudolab/zudo-doc) — an Astro-based documentation framework with MDX, Tailwind CSS v4, and Preact islands. ## Tech Stack - **Astro** — static site generator with Content Collections - **MDX** — content format via `@astrojs/mdx` - **Tailwind CSS v4** — via `@tailwindcss/vite` (not `@astrojs/tailwind`) - **Preact** — for interactive islands only (with compat mode for React API) - **Shiki** — built-in code highlighting ## Setup - **zfb-wisdom Claude Code skill** — run `bash docs/scripts/setup-zfb-wisdom.sh` once to give AI agents lookup access to this documentation tree (see `src/content/docs/claude-skills/zfb-wisdom.mdx`) ## Commands - `pnpm dev` — Astro dev server (port 4321) - `pnpm build` — static HTML export to `dist/` - `pnpm check` — Astro type checking ## Key Directories ``` src/ ├── components/ # Astro + Preact components │ └── admonitions/ # Note, Tip, Info, Warning, Danger ├── config/ # Settings, color schemes ├── content/ │ └── docs/ # MDX content │ └── docs-ja/ # Japanese MDX content (mirrors docs/) ├── layouts/ # Astro layouts ├── pages/ # File-based routing └── styles/ └── global.css # Design tokens & Tailwind config ``` ## Content Conventions ### Frontmatter - Required: `title` (string) - Optional: `description`, `sidebar_position` (number), `category` - Sidebar order is driven by `sidebar_position` ### Admonitions Available in all MDX files without imports: ``, ``, ``, ``, `` Each accepts an optional `title` prop. ### Headings Do NOT use h1 (`#`) in doc content — the page title from frontmatter is rendered as h1. Start content headings from h2 (`##`). ## Components - Default to **Astro components** (`.astro`) — zero JS, server-rendered - Use **Preact islands** (`client:load`) only when client-side interactivity is needed ## i18n - English (default): `/docs/...` — content in `src/content/docs/` - Japanese: `/ja/docs/...` — content in `src/content/docs-ja/` - Japanese docs should mirror the English directory structure ## Active Settings Flags The following boolean flags are set in `src/config/settings.ts` and are currently enabled (`true`): - **mermaid** — Renders Mermaid diagrams in MDX content - **sitemap** — Generates `sitemap.xml` via `@astrojs/sitemap` - **docMetainfo** — Shows document metadata (word count, reading time, etc.) below the title - **cjkFriendly** — Applies `remark-cjk-friendly` for better CJK line-breaking - **llmsTxt** — Generates `llms.txt` for LLM consumption - **docHistory** — Shows document edit history on each page - **sidebarResizer** — Draggable sidebar width - **sidebarToggle** — Show/hide desktop sidebar button - **claudeResources** — Auto-generated docs for Claude Code resources (skills, commands) --- # Content Collections > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/content-collections A **content collection** is a directory of Markdown (or MDX) files declared in your project config. zfb scans the directory at build time, parses each file's frontmatter against a schema you supply, and exposes the entries through a `getCollection()` helper your pages can call. ## Declaring a collection Collections are configured in `zfb.config.ts` (or `zfb.config.json`) under the `collections` key. Each entry has a `name` and a `path`: ```ts collections: [ { name: "blog", path: "content/blog", }, ], }; ``` The `name` is the identifier you pass to `getCollection()`. The `path` is the directory (relative to the project root) holding the entries. zfb walks that directory, treats each file as a Markdown document with frontmatter, and exposes its entries through `getCollection("blog")`. You can additionally supply an optional `schema` field — a JSON Schema subset that validates each entry's frontmatter at build time. The supported keywords (`type`, `properties`, `items`, `required`) are documented on the [`defineConfig`](/api/define-config) page. The `[{ name, path }]` form remains supported for projects that don't need per-field validation. ## Loading entries from a page Pages use `getCollection()` to enumerate every entry, or `getEntry()` to look up a single one by slug: ```tsx const posts = getCollection("blog"); return ( {posts.map((post) => ( {post.data.title} ))} ); } ``` ```tsx const featured = getEntry("blog", "hello-zfb"); if (!featured) return null; return ; } ``` Both calls are **synchronous**. The entire content snapshot is built in Rust before any TSX module runs and embedded on `globalThis.__zfb`, so there's no I/O at call time and no `await` to thread through. The Rust↔JS bridge contract that backs this surface is stable and versioned with the `zfb` package. Each entry has three things you can rely on: - `data` — the parsed, validated frontmatter (typed against your schema). - `Content` — a renderable React/Preact component compiled from the body. Render it as `` and pass element-level overrides through the `components` prop. This is the same contract Astro's `@astrojs/mdx` exposes; see [MDX Components](/concepts/mdx-components) for details and `defaultComponents` recipes. - `slug` — derived from the file name (`my-first-post.md` → `my-first-post`). Nested directories become slash-separated slugs. The function signatures live in [`getCollection`](/api/get-collection) and the matching `getEntry`. ## How parsing works Under the hood, the `zfb-content` crate handles three jobs: it walks the configured directory, parses each file's YAML frontmatter, and compiles the Markdown/MDX body through an mdast → JSX-source emitter that is then handed to the existing SWC TSX → JS pipeline. The result is a JSX module per entry, addressed by a stable `mdx:///` specifier; the page renderer evaluates that module on demand and surfaces it to your page as `entry.Content`. The compilation and surface contract are stable. See [MDX Components](/concepts/mdx-components) for the rendering side. --- # Installation > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/getting-started/installation `@takazudo/zfb` ships as an npm package that pulls a prebuilt platform binary via npm optional-deps — no Rust toolchain required. **Don't have Node?** You can install zfb as a standalone binary without npm or Node. See [Install without Node](/install/node-free) for curl, Homebrew, and Windows install paths. ## Prerequisites You will need: - **Node.js ≥ 22** — the config loader and some build steps invoke Node. - **pnpm** — install via the [official installer](https://pnpm.io/installation). zfb scaffolds projects that use pnpm and will run `pnpm install` for you when it can find pnpm on your `PATH`. ## Install the CLI Install `@takazudo/zfb` as a dev dependency in your project: ```bash pnpm add -D @takazudo/zfb ``` ```bash npm install -D @takazudo/zfb ``` ## Verify the install ```bash npx zfb --help ``` You should see four subcommands: - `zfb new` — scaffold a new project - `zfb dev` — start the dev server with watcher and live-reload - `zfb build` — produce a static build - `zfb preview` — serve the build output If `npx zfb --help` prints all four, you're ready to move on to [Your first site](/getting-started/your-first-site). ## From source (contributors) If you are contributing to zfb itself and need to build the CLI from source, see [BUILDING.md](https://github.com/Takazudo/zudo-front-builder/blob/main/BUILDING.md) for full instructions including the Rust toolchain prerequisites. The short path: ```bash git clone https://github.com/Takazudo/zudo-front-builder.git cd zudo-front-builder cargo install --path crates/zfb ``` --- # Migrating from Eleventy > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/migrating-from-eleventy Eleventy and zfb agree on the big things: small dependency footprint, file-based routing, content driven by Markdown plus front matter. Where they differ is the templating story. Read the [honest caveat](#honest-caveat) section before committing to a port. ## Layouts and includes 11ty puts layouts in `_includes/` and references them via front matter `layout:` keys. zfb reads layouts from `layouts/` and you import them directly into your page TSX: ```tsx return {post.body}; } ``` There is no front-matter `layout:` lookup — composition is by import. ## Templates 11ty ships Liquid, Nunjucks, Handlebars, and a handful of others. zfb ships exactly one template language: TSX. Markdown content still flows through the parser, but anything that wraps that content is JSX. If your codebase leans heavily on Nunjucks macros or Liquid filters, plan for real porting work. There is no shim layer. ## Data cascade 11ty's `_data/` directory and the global / directory / template data cascade have no direct equivalent in zfb. Instead, you express data in two places: - **Static / shared data** — declare a data collection in `zfb.config.ts` (or the legacy `zfb.config.json`). See [`defineConfig`](/api/define-config) for the full shape: ```ts collections: [ { name: "site", path: "data/site" }, ], }); ``` - **Per-page data** — return it from a page's `paths()` export, which is the rough equivalent of 11ty's `eleventyComputed`: ```ts const posts = await getCollection("blog"); return posts.map((post) => ({ params: { slug: post.slug }, props: { post, year: post.data.pubDate.getFullYear() }, })); } ``` ## Pagination 11ty's `pagination` front-matter key becomes a `paginate()` helper inside `paths()`. See /api/paginate for the full surface, but the shape is: ```ts const posts = await getCollection("blog"); return paginate(posts, { pageSize: 10 }); } ``` You get one route per page, plus the standard `currentPage`, `totalPages`, and `data` props. ## Markdown front matter This part is largely unchanged. Use `---` as a separator at the top of your `.md` files; zfb-content parses the front matter into `entry.data` and the body into `entry.body`. The fields available depend on the collection schema you declared in `zfb.config`. ## Honest caveat If you love Liquid or Nunjucks and dislike JSX, zfb is not for you. The framework is opinionated about JSX-as-templating, and there is no roadmap item to add an alternative. Eleventy will likely remain the better fit for that workflow indefinitely. zfb is most worth your time if you already write React or Preact components elsewhere and want a small, fast SSG that speaks the same language. --- # Recipe: Enlargeable Images > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/recipes/enlargeable-images How to replace the removed `imageEnlarge` built-in feature by wiring a userland `EnlargeableImg` component through the [MDX override API](/concepts/mdx-components). Both a fully server-side variant (no JavaScript shipped per page) and an islands variant (client-side interactivity via ``) are shown. ## Background Up to v0.1.0-next.12, zfb included a built-in `imageEnlarge` Markdown feature that wrapped block-level images in a `` with a zoom button. That feature was removed in v0.1.0-next.17 because it depended on pipeline internals not available to userland code and had no clean configuration surface. The same behavior is now achievable entirely in userland using the `img` key in the `components` map. This recipe shows how. ## How the override receives image props When MDX compiles a Markdown image: ```md ![A sunset over the ocean](/images/sunset.jpg "A warm evening") ``` it renders `` with the following props — the same attributes the HTML element accepts: - `src` — the image source URL - `alt` — the alt text - `title` — the title attribute (from the quoted string after the URL, if present) Your `img` override receives all of these as props, which is the key insight the `no-enlarge` opt-out relies on. ## The `no-enlarge` opt-out The old pipeline used a build-time sentinel to skip wrapping certain images. The userland equivalent is a **component-read prop**: set `title="no-enlarge"` in the Markdown source and check it in the component. Author writes: ```md ![A diagram](/images/diagram.png "no-enlarge") ``` Component reads: ```tsx if (props.title === "no-enlarge") { // render a plain without the enlargeable wrapper } ``` ## Variant A — server-side only (no islands) This is the simpler variant. `EnlargeableImg` is a server component: it renders a `` with a disclosure-style zoom button and delegates all interactivity to a small global click script in the layout. No JavaScript is shipped per page; the click script is a one-time layout-level inline ``. ### Component ```tsx title="components/enlargeable-img.tsx" type Props = { src?: string; alt?: string; title?: string; [key: string]: unknown; }; // opt-out: title="no-enlarge" → plain , no wrapper if (title === "no-enlarge") { return ; } return ( {/* zoom-in SVG icon */} ); } ``` ### Global click script Add this once to your layout. It uses event delegation — one listener for the whole page, regardless of how many images are present. ```tsx title="layouts/default.tsx (excerpt)" // Inside your or at the end of : { const btn = e.target.closest(".zd-enlarge-btn"); if (!btn) return; const fig = btn.closest(".zd-enlargeable"); if (!fig) return; const enlarged = fig.dataset.enlarged === "true"; fig.dataset.enlarged = enlarged ? "false" : "true"; }); `, }} /> ``` ### CSS (minimal) ```css .zd-enlargeable { position: relative; display: inline-block; margin: 0; } .zd-enlargeable img { display: block; transition: transform 0.2s ease; } .zd-enlargeable[data-enlarged="true"] img { transform: scale(1.5); z-index: 10; position: relative; } .zd-enlarge-btn { position: absolute; bottom: 0.5rem; right: 0.5rem; background: rgba(0, 0, 0, 0.5); color: white; border: none; border-radius: 4px; padding: 0.25rem; cursor: pointer; line-height: 0; } ``` ### Mounting the override Per-route mounting: ```tsx title="pages/blog/[slug].tsx" return ( ); } ``` Global mounting via `mdx-components.tsx` (zfb v0.1.0-next.16+): ```tsx title="mdx-components.tsx" // Default export: merged with defaultComponents before every Content render. ...defaultComponents, img: EnlargeableImg, }; ``` When `mdx-components.tsx` exists at the project root, zfb automatically installs it on `globalThis.__zfb.mdxComponents` at build time. Every `entry.Content` render merges it under `defaultComponents` and above the per-call `components` prop, so you do not need to pass `img: EnlargeableImg` on every route. ## Variant B — interactive island Use this variant when you need richer client-side behavior — for example, a true lightbox that traps focus, supports keyboard navigation, and renders an overlay. The component wraps an `` whose child island receives `src` and `alt` as serializable props. A `components` map is applied at server render time. If you put `"use client"` directly on `EnlargeableImg` and tried to mount it via `components={{ img: EnlargeableImg }}`, zfb would call it during SSR — the hydrate boundary cannot be crossed through the components map alone because functions are not JSON-serializable into `data-props`. The solution is a **server wrapper** (`EnlargeableImg`) that renders an `` whose child (`ImageLightbox`) carries only JSON-safe props. ### The island (client component) ```tsx title="components/image-lightbox.tsx" "use client"; type Props = { src: string; alt: string; }; const [open, setOpen] = useState(false); return ( <> setOpen(true)} style="cursor:zoom-in" /> setOpen(true)} > {/* zoom-in icon — same SVG as Variant A */} {open && ( setOpen(false)} aria-modal="true" aria-label={alt} > )} ); } ``` ### The server wrapper ```tsx title="components/enlargeable-img.tsx (island variant)" type Props = { src?: string; alt?: string; title?: string; [key: string]: unknown; }; // opt-out: title="no-enlarge" → plain , no island overhead if (title === "no-enlarge") { return ; } // Island serializes the child's own non-children props as data-props. // `src` and `alt` are plain strings — they survive JSON serialization // and arrive at the hydrated ImageLightbox intact. return ( ); } ``` Mount it exactly as in Variant A — either per-route or via `mdx-components.tsx`. ## One implicit limitation The old built-in operated on the Markdown AST and could distinguish **block-level images** (standalone paragraph containing only an ``) from inline images (an image inside a sentence). An `img` override fires for **every** image — block or inline — because that distinction is lost by the time MDX calls the component. If you need to skip inline images, use `title="no-enlarge"` on those authors. The `no-enlarge` opt-out is the supported mechanism; block-level detection is not reproducible from userland. ## See also - [MDX Components](/concepts/mdx-components) — the `components` prop, `defaultComponents`, and the `mdx-components.tsx` global convention. - [Islands](/concepts/islands) — the `` wrapper, hydration strategies, and the `"use client"` directive. --- # MDX Components > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/mdx-components How `.mdx` content collection entries become renderable JSX components, what arrives by default through `getCollection()`, how to override individual HTML elements with your own components, how the global `mdx-components.tsx` convention works, and the complete props contract each override receives. ## The mental model Every `.mdx` file in a content collection is compiled to a JSX module at build time. The compiled module exports an `MDXContent({ components })` function — a regular component whose only job is to render the post body, with optional per-element overrides supplied through the `components` prop. This is the same contract Astro's `@astrojs/mdx` exposes via ``. zfb adopts it directly so the mental model transfers across the Astro ecosystem. The shape is: ```tsx // Conceptually, your `hello-zfb.mdx` becomes: // ...renders the post body, looking up , , , etc. in `components` } ``` When MDX encounters `some text` (which is what a markdown paragraph compiles to), it looks up `p` in the `components` map. If you supply one, your component renders. If you don't, the raw HTML element renders. The same lookup applies to your custom JSX — `...` in the post body is resolved against `components.Note` at evaluation time. ## What `getCollection()` already gives you You don't construct `MDXContent` yourself. The collection layer wraps every entry's compiled module in a `Content` component and attaches it to the entry. That means: ```tsx const posts = getCollection("blog"); const post = posts[0]; // `post.Content` is a renderable component: ; ``` You get `post.data` (the parsed frontmatter), `post.slug`, and `post.Content` (the renderable body). Same shape for `.md` and `.mdx` — CommonMark is a strict MDX subset, so the same pipeline handles both. Plain markdown entries simply have no JSX nodes to override, but the `components` prop still applies to the HTML elements they emit. The bridge that connects `post.Content` to the actual compiled JSX module lives at `globalThis.__zfb.content.get(specifier)`, installed by the Rust-side renderer before the page module evaluates. When the bridge isn't present (unit tests, dev sandboxes), `Content` falls back to a clearly marked `` block so the absence is obvious. ## Spreading `defaultComponents` zfb ships a `defaultComponents` map from its root export — a set of element-level overrides (the **htmlOverrides** convention) that wrap the bare HTML tags MDX emits with sensible component implementations. The recipe is to spread it first, then layer your own overrides on top: ```tsx ; ``` Spread order matters: keys to the right win. Putting `defaultComponents` first means your individual overrides take precedence on collisions, while everything you don't override still picks up the default behavior. The current `defaultComponents` set covers: | Key | Component | Wraps | | ------------ | ------------------- | ------------------------------------------- | | `h2` | `ContentH2` | `` | | `h3` | `ContentH3` | `` | | `h4` | `ContentH4` | `` | | `p` | `ContentParagraph` | `` | | `a` | `ContentLink` | `` | | `strong` | `ContentStrong` | `` | | `blockquote` | `ContentBlockquote` | `` | | `ul` | `ContentUl` | `` | | `ol` | `ContentOl` | `` | | `table` | `ContentTable` | `` | | `code` | `ContentCode` | `` (inline; block code is unaffected) | Each is also exported individually (`import { ContentLink } from "zfb"`) for cases where you want a single override without dragging in the whole map. `h1` is intentionally absent: page titles render `` from frontmatter, per the zudo-doc convention. Adding `h1` to your overrides would silently double-render the title. ## The global `mdx-components.tsx` convention For project-wide element overrides you want applied to every `` call without per-call spreading, place an `mdx-components.tsx` file at your project root (next to `zfb.config.ts`) with a **default export** that is a flat `{ tag: Component }` map: ```tsx // mdx-components.tsx (project root) h2: MyH2, }; ``` The build pipeline discovers this file, copies it into the shadow bundle, and installs its default export on `globalThis.__zfb.mdxComponents` before the page router runs. From that point every `` call picks it up automatically — no spreading required at the call site. ### Precedence order When components are merged, later entries win. The merge order is: 1. `defaultComponents` (lowest priority — the 11 built-in passthroughs) 2. `mdx-components.tsx` default export (project-wide overrides) 3. `components` prop on `` (highest priority — per-call overrides) So a per-call `components={{ h2: MyCallSiteH2 }}` beats the project-root file, which beats the built-in defaults. Under the hood, `mergeMdxComponents` (exported from `"zfb"`) performs this three-way spread: ```ts { ...defaultComponents, ...globalSlot, ...perCall } ``` ### Runnable example — wrapping `` with a `` A common pattern is to wrap headings in a styled container: ```tsx // mdx-components.tsx (project root) type Props = { id?: string; children?: unknown }; function MyH2({ id, children }: Props) { return ( {children} ); } ``` Every `##` heading in every content entry now renders `…` project-wide, with no per-page wiring needed. ## The canonical mappable element set The following HTML tags are routed through `_components.` by the MDX emitter, making them overridable via the `components` map. Tags not on this list are either not emitted by markdown at all, or emit as raw HTML literals (not overridable by the components map). **Core CommonMark:** `a` `blockquote` `br` `code` `em` `h1` `h2` `h3` `h4` `h5` `h6` `hr` `img` `li` `ol` `p` `pre` `strong` `ul` **GFM extensions** (available when GFM constructs are enabled in `zfb.config.ts`): `del` `input` `section` `sup` `table` `tbody` `td` `th` `thead` `tr` The `defaultComponents` map pre-wires 11 of these (h2–h4, p, a, strong, blockquote, ul, ol, table, code). The rest default to their plain HTML string, but you can override any of them by including the key in your `components` map. ## Props contract Each override component receives only the attributes that the markdown pipeline actually produces for that element. There is no universal `{...props}` spread of arbitrary HTML attributes — only the fields listed below are passed: | Element(s) | Props received | | ------------- | ----------------------------------------------------- | | `a` | `href`, `title` (optional), `children` | | `img` | `src`, `alt`, `title` (optional) — void, no children | | `h2`–`h6` | `id` (present when the heading-links plugin is active and has slugged the heading), `children` | | `h1` | `children` only — `h1` is not slugged | | `pre` | `children` (wraps a `` child) | | `code` | `className="language-*"` (fenced blocks only; inline code receives `children` only) | | `ol` | `start` (only when non-1), `children` | | all others | `children` only | If you add `{...rest}` to an override and spread it onto the HTML element, no unknown attributes will arrive — the emitter does not forward arbitrary attributes from the markdown source. ## Lowercase vs. PascalCase asymmetry There is a deliberate behavioural difference between lowercase HTML tags and PascalCase component names in the `components` map: - **Lowercase tags** (`h2`, `p`, `a`, …) default to their plain HTML string in the emitted `_components` map. If you omit a lowercase key, the raw element renders — no error. - **PascalCase names** (`Note`, `Callout`, …) are looked up from the caller's `components` prop with a hard throw: ```js // Inside compiled MDX output: const Note = _components.Note ?? components.Note; if (!Note) throw new Error("MDX requires `Note` to be passed via the `components` prop"); ``` A PascalCase component referenced in an `.mdx` file that is not present in the `components` map at render time throws at runtime — not at build time. This is intentional: the compiled module stays portable; the caller is responsible for passing all required components. ## Islands constraint A `components` map is a plain JavaScript object resolved at SSR time. It cannot cross the hydration boundary as JSON, because the values are function references. This means: - Components in the map are **server-side only**. They render on the server and their HTML is delivered to the browser as static markup. - If a component in your map needs client-side interactivity (state, event handlers, browser APIs), wrap the interactive part in an `` component instead of putting the interactive component directly in the map. ```tsx // Correct: interactive content goes through Island function WrappedCounter() { return ( ); } ``` The `components` map runs through SSR; `` is the escape hatch for the browser. ## Deferred open item — `wrapper` MDX supports a special `wrapper` key in the `components` map: a component that wraps the entire rendered output. zfb's emitter does not recognise `wrapper` — the body is wrapped in `<_Fragment>` directly, and no lookup against a `wrapper` key is performed. Support for `wrapper` is a tracked open item and is not implemented in the current release. ## Authoring `.mdx` with custom components The bundled `basic-blog` template demonstrates the end-to-end shape (full source at the [zfb-example-blog standalone repo](https://github.com/Takazudo/zfb-example-blog)). The MDX post uses a custom `` admonition: ```mdx --- title: Hello, zfb date: 2026-04-20 description: A short introduction to the basic-blog dogfood example. --- Welcome to the **basic-blog** example. This post is `.mdx`, not plain markdown. The `` admonition you are reading right now is a custom JSX component passed in via the `components` prop on `` — exactly the same delivery contract Astro's `@astrojs/mdx` exposes. ``` The per-post route resolves `` against the `components` map by passing it in alongside `defaultComponents`: ```tsx // pages/blog/[slug].tsx return ( {post.data.title} ); } ``` The `Note` component itself is an ordinary Preact (or React) component that receives `title` and `children`: ```tsx // components/note.tsx type Props = { title?: string; children: ComponentChildren; }; return ( {title ? {title} : null} {children} ); } ``` The full source lives in the [zfb-example-blog standalone repo](https://github.com/Takazudo/zfb-example-blog) — see `content/blog/hello-zfb.mdx`, `pages/blog/[slug].tsx`, and `components/note.tsx`. A live build is available at [zfb-example-blog.pages.dev](https://zfb-example-blog.pages.dev/). ## Reference - The `defaultComponents` set is zfb's port of the htmlOverrides convention pioneered by [zudo-doc](https://github.com/zudolab/zudo-doc), where the same map shape solves the same problem for that documentation framework. - The upstream pattern from Astro is documented at [Astro: Assigning custom components to HTML elements](https://docs.astro.build/en/guides/integrations-guide/mdx/#assigning-custom-components-to-html-elements). The contract is intentionally identical: a flat record of element-name → component, passed through the `components` prop. ## See also - [Content Collections](/concepts/content-collections) — how entries are discovered and exposed via `getCollection()`. - [Build Pipeline](/concepts/build-pipeline) — where MDX-to-JSX compilation sits in the end-to-end flow. - [Custom Directives](/concepts/custom-directives) — adding new MDX directive syntax (e.g. `:::callout`) that maps to your JSX components. - [Islands](/concepts/islands) — how to make interactive components work inside a content entry. --- # Custom Directives > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/custom-directives How to register MDX directives (container, leaf, text) so authors can write `:::callout`, `::youtube{id="…"}`, or `:badge[new]` and have them expand to your JSX components. Includes the v1 attribute restriction and how the engine reports unknown-directive diagnostics. zfb's MDX pipeline runs a **directive registry** as one of its mdast visitors. The registry is the engine primitive — frameworks (or your own project) populate it. You register a directive name, point it at a JSX component, and the pipeline rewrites matching paragraphs into `` JSX nodes. The shapes follow the [CommonMark Directives proposal](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444): container, leaf, and text. zfb's seven built-in admonitions (`:::note`, `:::tip`, `:::info`, `:::warning`, `:::danger`, `:::details`, `:::caution`) are themselves registered through the same registry via `Pipeline::with_defaults()` — the registry is the only mechanism, and the defaults aren't privileged. ## The three directive shapes ### Container — `:::name … :::` Block-level. Body content between the fences becomes the JSX element's children. Used for callouts, sections, anything that wraps a multi-paragraph body. ```md :::callout{tone="info"} Body content runs through the markdown pipeline normally. **Inline markdown** works. So do nested elements. ::: ``` Compiles to: ```tsx Body content runs through the markdown pipeline normally. Inline markdown works. So do nested elements. ``` ### Leaf — `::name[label]{attrs}` Block-level, single line. No fenced body. Use for self-contained embeds where attributes carry the data. ```md ::youtube[Intro to zfb]{id="abc123" start="42"} ``` Compiles to: ```tsx ``` (Whether `[label]` becomes a `title="…"` attribute or a child element is up to the directive's `title_from_label` flag at registration time.) ### Text — `:name[label]{attrs}` Inline within a paragraph. Use for badges, inline icons, anything you want to mix into prose. ```md The new feature is :badge[new]{tone="green"} now in beta. ``` Compiles to: ```tsx The new feature is new now in beta. ``` ## Registering a directive (Rust side) Frameworks register directives in Rust by populating a `DirectiveRegistry` and inserting it into the pipeline. Each directive is a `(name, kind, component_name)` triple plus a `title_from_label` flag. ```rust use zfb_content::plugins::directives::{ DirectiveDef, DirectiveKind, DirectiveRegistry, }; use zfb_content::pipeline::Pipeline; let mut registry = DirectiveRegistry::with_defaults(); // Container: :::callout … ::: registry.register(DirectiveDef::container("callout", "Callout")); // Leaf: ::youtube[label]{id="…"} registry.register(DirectiveDef::leaf("youtube", "Youtube")); // Text: :badge[new] registry.register(DirectiveDef::text("badge", "Badge")); let mut pipeline = Pipeline::with_defaults(); pipeline.add_mdast_visitor(registry.into_visitor()); ``` After registration, content authors use the source-side syntax (the markdown/MDX block) and the pipeline emits the right JSX element. You're responsible for making sure the JSX component identifier (`Callout`, `Youtube`, `Badge`) is in scope at the page module — that's ordinary MDX-component plumbing. `Pipeline::with_defaults()` is your starting point — it pre-registers the seven built-in admonitions and wires the standard plugin set (syntect highlighting, heading links, etc.). You can also start from `DirectiveRegistry::new()` if you want a clean slate, but the defaults are designed to be additive, not opinionated. ## Attribute escaping (v1) All directive attributes emit as **JSX string-literal attributes**. Authors can write `{tone="green"}` or `{data-foo="bar"}` and the emitter renders `tone="green"` / `data-foo="bar"` directly. `{count={5}}` or `{when={isLive}}` syntax is **not supported** in v1. Every attribute value goes through as a string literal — whatever the JSX component accepts as a string-typed prop is what authors can pass. If your component needs a non-string prop, accept the string form and parse it yourself, or expose the data through a different mechanism (a context, a layout prop, a frontmatter field). The downstream JSX emitter (`zfb-content::mdx_jsx_emit`) escapes `"`, `&`, `<`, `>`, and line terminators in attribute values, so authors can't accidentally inject markup through them. ## Unknown directives: diagnostics, not errors When the registry sees a directive name it doesn't recognise, it emits a `DirectiveDiagnostic` instead of failing the build. The original paragraph is left intact. The orchestrator drains diagnostics after each pipeline run and surfaces them as warnings. ```rust pub struct DirectiveDiagnostic { pub message: String, pub line: Option, pub column: Option, } ``` The pipeline returns a `Vec` sink alongside the compiled module. A typical orchestrator drains and prints them between files: ```rust let diagnostics = registry.take_diagnostics(); for d in diagnostics { eprintln!( "warning: {} (at {}:{})", d.message, d.line.unwrap_or(0), d.column.unwrap_or(0), ); } ``` The reason for non-fatal-by-default: a typo in a markdown post shouldn't break a 1,000-page build. Diagnostics surface the problem without blocking output. ## Typed attribute schemas Declare the accepted attributes for a directive using `AttrSchema` and `AttrType`. The registry validates raw MDX attrs against the schema during expansion and emits `DirectiveDiagnostic`s for violations. This lets content errors surface early rather than silently passing bad data to your JSX component. ```rust use zfb_content::plugins::directives::{ AttrSchema, AttrType, DirectiveDef, DirectiveRegistry, }; let mut registry = DirectiveRegistry::new(); registry.register( DirectiveDef::container("callout", "Callout") .with_attrs(vec![ // Required enum — must be one of the declared values. AttrSchema { name: "tone".to_string(), ty: AttrType::Enum(vec![ "info".to_string(), "warn".to_string(), "tip".to_string(), ]), default: None, required: true, }, // Optional string with a default. AttrSchema { name: "title".to_string(), ty: AttrType::String, default: Some("Note".to_string()), required: false, }, // Optional boolean, defaults false. AttrSchema { name: "compact".to_string(), ty: AttrType::Boolean, default: Some("false".to_string()), required: false, }, // Optional number. AttrSchema { name: "max-width".to_string(), ty: AttrType::Number, default: None, required: false, }, ]), ); ``` The four `AttrType` variants: | Variant | Input | Validated as | | --- | --- | --- | | `String` | Any value | Passed through unchanged | | `Enum(variants)` | Must equal one of the declared strings (case-sensitive) | `ValidatedAttrValue::Enum` | | `Boolean` | `"true"`, `"false"`, or empty string (bare attr = `true`) | `ValidatedAttrValue::Boolean` — emits `"true"` / `"false"` in JSX | | `Number` | Any string parseable as `f64` | `ValidatedAttrValue::Number` (original string stored) | ### Validation rules - **Missing required attr** → diagnostic emitted; expansion falls back to raw attrs unchanged. - **Enum mismatch** (value not in declared variants) → diagnostic. - **Type-coercion failure** (`x="abc"` for a `Number` attr) → diagnostic. - **Default values** — absent optional attrs get their `default` applied before the JSX is built. ### Unknown-attr policy: **warning only** Attributes present in the MDX source but not declared in the schema emit a **warning** diagnostic — they are NOT errors and they still pass through to the JSX element unchanged. This preserves existing leniency (authors can pass HTML attributes like `data-*` or `aria-*` without declaring them) and means a missing schema entry never breaks a build. ``` warning: directive `callout`: unknown attribute `data-testid` (warning only — attr passes through unchanged) ``` If you want strict mode, register all attrs and treat any diagnostic as a build error in your orchestrator. The policy is "warn, not error" by design; the engine never blocks output on an attr it doesn't recognise. ### Backwards compatibility Directives registered without `.with_attrs(…)` (i.e. via `container`, `leaf`, or `text` constructors without chaining) skip validation entirely. All existing call sites continue to compile and behave identically. ## What the engine doesn't do - **Validate attribute values against JSX component prop types.** The engine validates against the `AttrSchema` you declare (type + enum values). TypeScript typing on the JSX component side is still your responsibility. - **Provide a default directive set beyond the seven admonitions.** No built-in `:::callout`, `:::card`, `::youtube`, etc. — every framework picks its own. - **Auto-import the JSX components.** You're responsible for making the components in scope at the page module (a global components map, an MDX layout, an explicit import). See [MDX Components](/concepts/mdx-components) for the `defaultComponents` recipe. ## See also - [MDX Components](/concepts/mdx-components) — how compiled MDX modules look up element-level overrides. - [Engine vs Framework](/concepts/engine-vs-framework) — why the registry is engine-shaped and the directive set is framework-shaped. - `crates/zfb-content/src/plugins/directives.rs` — the registry implementation, including parser fixtures for each shape. --- # paginate > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/api/paginate ## Signature ```ts paginate( items: readonly T[], opts: PaginateOptions, ): PaginateRoute[] ``` Use `paginate` inside a `paths()` export to fan a list of items out across a paginated route. The function chunks `items` by `pageSize` and produces one `PaginateRoute` per chunk, each carrying the URL params and page props the component receives. `paginate` always emits at least one route — an empty input list yields a single empty page so the index route still renders rather than 404ing. ## PaginateOptions ```ts type PaginateOptions = { pageSize: number; param: K; }; ``` - `pageSize` — items per page. Must be a positive integer. - `param` — name of the dynamic URL segment to fill (e.g. `"page"` for a route file named `[page].tsx`). ## PaginateRoute shape ```ts type PaginateRoute = { params: Record; props: { page: PaginatedPage }; }; ``` Each route carries: - `params` — URL segment values. For a `param: "page"` call, this is `{ page: "1" }`, `{ page: "2" }`, etc. - `props.page` — a `PaginatedPage` record with: - `data: T[]` — items belonging to this page. - `page: number` — 1-based page number. - `lastPage: number` — total number of pages. - `pageSize: number` — items per page (echoed for convenience). - `total: number` — total item count across all pages. ## Example Paginate a blog index 10 posts at a time under `/blog/page/[page].tsx`. ```tsx const posts = getCollection("blog"); return paginate(posts, { pageSize: 10, param: "page", }); } const { data: items, page: currentPage, lastPage } = page; return ( Blog — page {currentPage} of {lastPage} {items.map((post) => ( {post.data.title} ))} ); } ``` The `param` value (`"page"`) must match the dynamic segment name in your route filename (`[page].tsx`). Page numbers start at 1 and are passed as strings in `params`. The page component receives the `PaginatedPage` record as `props.page`. Destructure it to access `data`, `page`, `lastPage`, `pageSize`, and `total`. --- # Framework adapters > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/architecture/framework-adapters zfb renders TSX. JSX is a syntax — the *runtime* that turns it into HTML is supplied by a framework. zfb supports two: **Preact** (default) and **React**. ## The need Two goals pull opposite ways. One: a single renderer pipeline that works the same for both frameworks, so we never fork the build. Two: many users want React (existing components, React-only libraries) and many want Preact (footprint, signals). zfb lets them pick: ```ts // zfb.config.ts framework: "preact", // or "react" }; ``` Read once at config-load time and threaded through the build. A one-line change for the user. For the renderer, the choice never appears — it talks to a uniform `Adapter` trait and has no `if framework == "react"` branches past adapter construction. ## The boundary The contract lives in `crates/zfb-render/src/adapters/mod.rs`. Every adapter implements the same narrow trait: ```rust pub trait Adapter { fn name(&self) -> &'static str; fn jsx_import_source(&self) -> &'static str; fn render_to_string_module(&self) -> &'static str; fn pre_render_setup(&self, host: &mut dyn RenderHost) -> Result<(), RenderError>; fn hydrate_shim_specifier(&self) -> &'static str; fn hydrate_shim_source(&self) -> &'static str; } ``` Three groups of responsibility, no more: 1. **Tell the SWC pipeline which JSX import source to inject** (`jsx_import_source`). This drives the `transform-react` pass with `runtime: "automatic"` so user code never writes `import { h } from "preact"` or `import React from "react"` and never spells a `@jsxImportSource` pragma. The framework choice lives in config; SWC threads it everywhere. 2. **Tell the runtime where the synchronous `renderToString` lives** (`render_to_string_module`) and **install a shim** (`pre_render_setup`) that aliases it as `globalThis.__zfbRenderToString`. The render orchestrator in `render.rs` then calls `__zfbRenderToString(vnode)` once per page, with no branching on framework identity. 3. **Provide the client-side hydration shim** (`hydrate_shim_specifier` + `hydrate_shim_source`). The islands bundler folds this module into the islands bundle as the framework-specific entry. It exports `hydrateIsland(Component, props, element)`, and the framework-agnostic hydration runtime in `zfb-islands` calls it for every `[data-zfb-island]` element in the DOM. That is the entire surface. Hook semantics, signal interop, event delegation strategy — none of these are inside the boundary. Each framework keeps its own behaviour; the adapter only controls *which* framework is reached through. ## Why a setup-phase shim `pre_render_setup` runs once, before the first page render, and installs `__zfbRenderToString` on `globalThis`. Per-page rendering is then one function call from Rust into JS — no module re-resolution, no shim re-installation, no per-call import dance. The cost is paid once per host lifetime; the benefit accrues across thousands of pages. Two tempting alternatives are ruled out. We are not generating per-page JS modules that import `preact-render-to-string` directly — that pushes module resolution onto the per-render hot path. We are not exposing a `hydrate_call()` API that returns a JS expression for Rust to template into source — that puts JS-expression concatenation in Rust, the wrong language for it. The shim keeps the Rust-to-JS boundary expressed purely as static module strings. ## The Preact adapter Lives at `crates/zfb-render/src/adapters/preact.rs`. JSX import source `"preact"`; render module `"preact-render-to-string"`; hydration shim wraps `hydrate(vnode, container)`. Preact is the default because its footprint fits zfb's static-output target cleanly — `preact-render-to-string` is purpose-built for synchronous SSR and the runtime image stays small. ## The React adapter Lives at `crates/zfb-render/src/adapters/react.rs`. Larger than Preact, opt-in for users who need the React ecosystem. JSX import source `"react"`; render module `"react-dom/server"`; hydration shim wraps React 18+'s `hydrateRoot(container, vnode)` — note the swapped argument order versus Preact, which the adapter owns so user code does not have to. The React adapter uses **synchronous** `renderToString`, not `renderToReadableStream` or `renderToPipeableStream`. zfb produces ahead-of-time static HTML; a sync string is simpler, deterministic, and avoids dragging Node streaming primitives into the build host. The cost is no streaming HTML and no React Server Components in v1. ## Future adapters Solid, Vue SSR, Svelte SSR — none ship today. The `Adapter` trait is small enough that adding one is purely additive: a new adapter file, a new `Framework` enum variant, a new `make_adapter` arm. The render orchestrator does not change. If a future framework cannot satisfy the trait — for example, it requires an async render entry that does not fit `renderToString → string` — that is a reason to widen the trait deliberately, not to special-case it. --- # Data > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/data zfb gives you three different routes for getting structured data into a page, and the right choice depends on the shape of the data and how much ceremony you want around it. ## 1. Content collections When the data is **prose** — blog posts, docs articles, recipes — use a content collection. Each entry is a Markdown file with frontmatter, the frontmatter is validated against a schema, and the body is rendered to HTML for you. See [Content Collections](/concepts/content-collections) for the full story. This is the right choice whenever the body of each entry is meant to be read. ## 2. Data collections When the data is **structured** — a list of products, a directory of team members, a set of pricing tiers — use a data collection: a directory of JSON / YAML / TOML files declared in `zfb.config.ts`. The same `[{ name, path }]` shape that content collections use (see [Content Collections](/concepts/content-collections)) accepts data directories without ceremony: ```ts collections: [ { name: "products", path: "data/products", }, ], }; ``` Each file becomes one entry. You can supply an optional `schema` field with per-field validators (see [`defineConfig`](/api/define-config) for the full shape). You load data entries the same way as content entries: ```tsx const products = getCollection("products"); ``` A data collection earns its keep when you want **per-entry validation, slug derivation, and the same loading API as your prose content** — without the Markdown-rendering step. ## 3. Plain TS modules under `data/` For everything else — small lookups, helper functions, derived constants — a regular TypeScript module is the lightest path: ```ts // data/site.ts return date.toISOString().slice(0, 10); } ``` Import it from any page or component: ```tsx ``` There is no schema, no slug, and no per-entry overhead. Use this when the "collection" framing would be more ceremony than the data deserves. ## Picking the right tool A useful rule of thumb: - The data is **content meant to be read** → content collection. - The data is **structured records that share a shape** → data collection. - The data is **a handful of constants or a helper** → TS module under `data/`. You can mix all three in the same project without conflict. --- # Your first site > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/getting-started/your-first-site This walkthrough takes you from an empty directory to a running dev server and a built site. No global CLI install is required — the scaffold command fetches everything on demand. ## Scaffold a new project ```bash pnpm create zfb@latest my-site # or npm create zfb@latest my-site ``` This runs the `create-zfb` initializer, which calls `zfb new` under the hood and copies the bundled `basic-blog` template into `my-site/`. If pnpm is on your `PATH`, zfb runs `pnpm install` automatically right after copying the template; otherwise it prints a short note telling you to run it yourself. ```bash cd my-site ``` If you already have the zfb CLI installed globally (see [Installation](/getting-started/installation)), you can invoke `zfb new` directly with the same result: ```bash zfb new my-site --template basic-blog ``` `--template basic-blog` is the default and currently the only available template, so you can omit it. ## Start the dev server ```bash pnpm zfb dev # or npx zfb dev ``` You'll see a "ready" banner with the host and port (defaults: `http://localhost:3000`). zfb watches the project directory; saving any file in `pages/`, `layouts/`, `components/`, `content/`, or `styles/` triggers a live-reload broadcast to connected browsers. The server also serves anything you drop into `public/`. You can override host and port from the CLI — they always win over `zfb.config.ts`: ```bash pnpm zfb dev --port 4000 --host 0.0.0.0 # or npx zfb dev --port 4000 --host 0.0.0.0 ``` ## Build and preview To produce a static build of the site: ```bash pnpm zfb build pnpm zfb preview # or npx zfb build npx zfb preview ``` `zfb build` resolves the output directory (defaulting to `dist/`), enumerates routes via the file-system router, and writes one HTML file per static route. `zfb preview` then serves that directory over HTTP on port `4321` by default. CLI flags (`--outdir`, `--port`) win over the config file in both commands. ## A working example A fully built site using `basic-blog` is available at [https://github.com/Takazudo/zfb-example-blog](https://github.com/Takazudo/zfb-example-blog). ## What to read next - [Project structure](/getting-started/project-structure) — what every directory the template created actually does. - [Routing](/concepts/routing) — how `pages/` becomes URLs, including dynamic and catchall routes. - [Islands](/concepts/islands) — how to opt individual components into client-side hydration. - [Build engine](/architecture/build-engine) — how the Rust crates fit together under the hood. --- # Customizing Markdown > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/customizing-markdown zfb's Markdown pipeline lives in the `zfb-content` Rust crate. It parses each file, runs a chain of visitors (Core always-on, plus any Opt-in features you enable), highlights code blocks server-side with syntect, and hands each entry back as both a raw `body` string and a ready-to-render `Content` component. For the full list of what the pipeline provides — Core features that are always on, Opt-in features you enable in `zfb.config.ts`, and how to add your own visitors in-tree — see the [Markdown Features](/markdown-features) category. ## Rendering Markdown in a page The standard pattern is to receive a content entry through `paths()` and render its `Content` component: ```tsx const posts = await getCollection("blog"); return posts.map((post) => ({ params: { slug: post.slug }, props: { post }, })); } return ( ); } ``` `entry.Content` is the JSX component zfb compiles from your Markdown. It renders MDX-aware output including syntect-highlighted code blocks and any custom directives you have registered. If you only need the raw markdown source (for example, to compute a summary or feed an RSS generator), reach for `entry.body` instead. ## Extension points The `plugins: []` field in `zfb.config` is the build-orchestration plugin system. Each registered plugin exposes three optional hooks — `preBuild`, `postBuild`, and `devMiddleware` — for tasks like emitting extra files alongside the build, running post-build verification, or adding routes to the dev server. Plugins do **not** participate in Markdown rendering: the parse and visitor chain run inside the Rust engine and never call back into npm-installed plugins. Markdown-pipeline customisation lives in-tree as `MdastVisitor` / `HastVisitor` implementations on the engine side. See [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline). When in doubt, render the body and style it. Most "I want to customize Markdown" requests turn out to be styling questions that CSS solves. ## See also - [Markdown Features](/markdown-features) — full catalog of Core and Opt-in features. - [Custom Directives](/concepts/custom-directives) — register new directive names against the `DirectiveRegistry`, no Rust required. - [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) — write a Rust visitor and wire it into the engine. --- # Extending the Markdown Pipeline > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/extending-the-markdown-pipeline The engine-side extension surface for `zfb-content`'s Markdown pipeline. When you want a new syntax-level feature — admonition variants, heading rewrites, custom code-block treatment, link resolvers — this page tells you where to put it and how to wire it in. The pipeline that turns Markdown / MDX into HTML lives in `crates/zfb-content`. It is plugin-shaped: parse to mdast, run mdast visitors, convert to hast, run hast visitors, serialize. There is no runtime / userland plugin loader — every visitor compiles into the binary — but the surface for adding a new visitor in-tree is small and stable, and that is where almost every "I want a new Markdown feature" change lives. If your feature is just a new directive name (`:::callout`, `::youtube`, `:badge`), you do not need this page — the directive registry handles it without touching Rust beyond a one-line `register` call. See [Custom Directives](/concepts/custom-directives) for that path. ## Decision: directive, visitor, or AST arm | You want to … | Path | Where it goes | |---|---|---| | Add `:::name` / `::name` / `:name` syntax that compiles to a JSX component | Register a directive | `Pipeline::with_defaults` + `DirectiveRegistry` ([Custom Directives](/concepts/custom-directives)) | | Rewrite existing AST nodes (slugify headings, wrap code blocks, swap `` for a JSX marker, normalise links) | Write a visitor | New file under `crates/zfb-content/src/plugins/` | | Surface a Markdown construct that markdown-rs parses but zfb currently drops (tables, footnotes, math, definitions, reference-style links) | Extend the AST converter | `mdast_to_hast` in `crates/zfb-content/src/pipeline.rs` | | Add brand-new Markdown syntax that markdown-rs cannot parse | Out of scope | Upstream change against the `markdown` crate, then a converter arm in zfb | Most contributions land in the second row — a fresh visitor. ## The two-phase pipeline The pipeline runs two distinct passes over two different ASTs: 1. **Parse** the input into mdast (`markdown::mdast::Node`) using markdown-rs with MDX-aware options. 2. **mdast visitors** — `MdastVisitor` implementations rewrite the markdown AST in place. Run first because some transforms only make sense before HTML structure exists. The directive registry is a mdast visitor: it folds runs of paragraphs delimited by `:::name` / `:::` into a single MDX JSX element, which is much easier on mdast than after `` tags have appeared. 3. **Convert** mdast → hast via `mdast_to_hast`. hast is zfb's minimal HTML AST (`HastNode`). 4. **hast visitors** — `HastVisitor` implementations rewrite the HTML AST in place. Most rewrites that target HTML element structure (heading anchors, `` wrappers, `` → JSX markers, syntax highlighting) live here. 5. **Serialize** hast to an HTML string in `zfb_content::serializer`. Pick the phase that matches what you are operating on. Rule of thumb: if you need to look at the original Markdown structure (a directive, a paragraph run, a particular link reference style), mdast. If you need to look at HTML element structure (a ``, a heading level, an `` with a width attribute), hast. ## Visitor trait shape Both visitor traits are intentionally small — one method, called once on a node, mutate in place: ```rust pub trait MdastVisitor { fn visit(&mut self, node: &mut MdastNode); } pub trait HastVisitor { fn visit(&mut self, node: &mut HastNode); } ``` The pipeline calls `visit` exactly once, with the root node. Recursion is the visitor's responsibility — there is no auto-walk. A typical hast visitor looks like: ```rust use crate::pipeline::{HastNode, HastVisitor}; pub struct MyPlugin; impl HastVisitor for MyPlugin { fn visit(&mut self, node: &mut HastNode) { match node { HastNode::Root { children } | HastNode::Element { children, .. } => { for child in children { // mutate `child` here, then recurse self.visit(child); } } _ => {} } } } ``` Visitors can carry state (per-document slug counters, configuration options, references to a shared resource). `HeadingLinksPlugin` keeps a `HashMap` for github-slugger-equivalent dedup; `SyntectPlugin` holds an `Arc` so the syntax theme is shared across all code blocks in the build. ## When to add a Core vs. Opt-in feature Both live as Rust visitors, but where you wire them depends on how many consumers need them. **Core (wire into `Pipeline::with_defaults`)** when the behaviour is universal: every content-collection consumer would want it the same way, with no legitimate reason to opt out. Examples: `HeadingLinksPlugin`, `CodeTitlePlugin`, `SyntectPlugin`. **Opt-in (wire into `Pipeline::with_defaults_and_features`)** when the feature is valuable but not universally needed, or when it requires project-specific config (a source map, a feature flag, custom options). Examples: all 13 features in `zfb-md-extras`. The promotion threshold follows the [three-consumer rule](/concepts/design-philosophy): don't extract until the same pattern has been written by hand in three different zfb consumer projects. One project's convenience is a recipe. ## Where files go Core plugins live under `crates/zfb-content/src/plugins/`. Opt-in features live under `crates/zfb-md-extras/src/`. The convention is one file per feature: ``` crates/zfb-content/src/plugins/ ├── cjk_friendly.rs ├── code_title.rs ├── directives.rs ├── external_links.rs ├── heading_links.rs ├── resolve_links.rs ├── strip_md_ext.rs ├── syntect_plugin.rs ├── toc.rs # heading-marker TOC (wired via features) └── util/ crates/zfb-md-extras/src/ ├── admonitions_preset.rs ├── code_enrichment.rs ├── code_tabs.rs ├── github_alerts.rs ├── github_autolinks.rs ├── heading_marker_toc.rs ├── image_dimensions.rs ├── link_validation.rs ├── mermaid.rs ├── reading_time.rs ├── ruby.rs ├── toc_export.rs └── transclude.rs ``` For Core plugins, add your file and re-export from `crates/zfb-content/src/plugins.rs`: ```rust // in plugins.rs pub mod my_plugin; pub use my_plugin::MyPlugin; ``` For Opt-in features, add your file and expose the feature from `crates/zfb-md-extras/src/lib.rs`, gated on the corresponding `MarkdownConfig::features` flag. Tests typically live in a `#[cfg(test)] mod tests {}` block alongside the plugin, with cross-plugin integration cases in `crates/zfb-content/tests/`. The existing `tests/integration_pipeline.rs` is the reference shape. ## Wiring into the default pipeline `Pipeline::with_defaults()` is the project-wide default plugin chain. Adding your visitor there means every caller that uses the defaults picks it up automatically. Append it in the right phase: ```rust // in crates/zfb-content/src/pipeline.rs, inside Pipeline::with_defaults() let mut p = Self::with_mdx(); // mdast phase p.add_mdast_visitor(Box::new(AdmonitionsPlugin::new())); // hast phase p.add_hast_visitor(Box::new(HeadingLinksPlugin::new())); p.add_hast_visitor(Box::new(CodeTitlePlugin::new())); p.add_hast_visitor(Box::new(MyPlugin)); // <-- new p.add_hast_visitor(Box::new(MermaidPlugin::new())); p.add_hast_visitor(Box::new(SyntectPlugin::new(highlighter))); p ``` If your plugin is opt-in, do **not** put it in `with_defaults()`. Wire it into `Pipeline::with_defaults_and_features()`, which accepts a `MarkdownFeatures` config struct and appends only the visitors whose flags are set. That is how all 13 features in `zfb-md-extras` are wired. `ResolveLinksPlugin` and `StripMdExtensionPlugin` are handled separately because they need a project-specific source map, not just a feature flag. ## Ordering matters Visitor order is **load-bearing**. The defaults document the full rationale in `Pipeline::with_defaults_and_features`'s doc comment, but the rules that bite most often: - **`HeadingLinksPlugin` runs first in the hast phase.** Anything that mutates headings later sees the slugified `id` attributes. `TocPlugin` and `TocExportPlugin` depend on these `id` values. - **`CodeTitlePlugin` runs before `SyntectPlugin`.** SyntectPlugin replaces the entire `` element with a `HastNode::Raw` HTML fragment; once that happens, the `data-meta` attribute that carries `title="…"` is no longer reachable as structured AST. - **`MermaidPlugin` runs before `SyntectPlugin`.** MermaidPlugin flags mermaid code blocks with `data-mermaid="true"`; SyntectPlugin uses that flag to skip them rather than syntax-highlighting the diagram source. - **`CodeEnrichmentPlugin` runs after `SyntectPlugin`.** It post-processes the per-line `` structure that syntect emits; it cannot run before syntect produces those spans. - **`ImageDimensionsPlugin`** runs in the hast phase before `SyntectPlugin`. It only touches `` elements and is order-independent relative to heading / code-block visitors. - **`GithubAlertsPlugin` runs in the mdast phase**, before the mdast → hast conversion. It rewrites blockquote nodes; the `AdmonitionsPlugin` (which also runs in the mdast phase) reads the results independently. - **`TranscludePlugin` runs first in the mdast phase.** Included content must be merged into the AST before any other visitor sees it. When inserting a new plugin, ask: do I need to see element shapes that a later plugin will erase? Run before that plugin. Do I need the results of an earlier plugin's rewrite (a generated `id`, a synthesised JSX element)? Run after it. ## Adding genuinely new syntax Some Markdown constructs that markdown-rs parses are currently **dropped** by the mdast → hast converter. Look at `mdast_to_hast` in `crates/zfb-content/src/pipeline.rs`: ```rust // Unhandled: degrade to empty Raw so we never crash on // unsupported input. Tables, footnotes, definitions, math, // reference links/images, ESM, frontmatter, etc. fall here. _ => HastNode::Raw(String::new()), ``` If you want zfb to surface tables, footnotes, definitions, math, reference-style links, or anything else that lives in the catch-all, two changes are needed: 1. **Add a match arm** to `mdast_to_hast` that turns the mdast variant into the right `HastNode::Element` (or `Raw` for passthrough JSX/HTML). Mirror the existing arms — handle children with `convert_children`, build attributes as `Vec<(String, String)>`. 2. **Possibly toggle `markdown::ParseOptions`** if the construct needs an extension flag. The current `Pipeline::with_mdx()` uses `markdown::ParseOptions::mdx()`; you may need a custom `ParseOptions` with additional `constructs.*` fields enabled. Check the `markdown` crate's docs for the exact flag. Tests for converter changes belong in `pipeline.rs`'s own `#[cfg(test)] mod tests {}` block (the file already covers headings, code blocks, links, images, lists, blockquotes, MDX JSX) plus a matching round-trip case in `crates/zfb-content/tests/`. ## What about runtime / userland plugins? There is no plugin loader today. Every visitor compiles into the binary. The closest the user-facing config (`zfb.config`) gets to plugins is a `plugins: []` field reserved for future use; it is not yet wired into the build pipeline. For now the practical extension model is: add the visitor in-tree. The visitor traits are stable across the workspace, so a feature written as a fresh `plugins/` file rarely needs follow-up changes when the rest of the codebase moves. ## See also - [Markdown Features](/markdown-features) — full catalog of Core and Opt-in features, with per-feature ordering notes. - [Custom Directives](/concepts/custom-directives) — author-facing story for `:::name` / `::name` / `:name` syntax, no Rust required. - [Customizing Markdown](/guides/customizing-markdown) — what the Markdown rendering pipeline looks like from a content-collection consumer's perspective. - `crates/zfb-content/src/pipeline.rs` — `Pipeline`, `MdastVisitor` / `HastVisitor` traits, and the `Pipeline::with_defaults()` ordering rationale in its doc comment. - `crates/zfb-content/src/plugins/code_title.rs` — small, stateless `HastVisitor` example. - `crates/zfb-content/src/plugins/heading_links.rs` — stateful `HastVisitor` (per-document slug counter) example. - `crates/zfb-content/src/plugins/admonitions.rs` — façade over `DirectiveRegistry`, an `MdastVisitor` example. --- # Island > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/api/island ## The `` wrapper Import `Island` from `"@takazudo/zfb"` and wrap any component that should hydrate in the browser. Everything else stays server-rendered HTML. ```tsx return ( My Page ); } ``` At SSR time the `` wrapper emits a `` marker with the server-rendered child inside. The client runtime (`@takazudo/zfb-runtime`) queries these markers and mounts the component when the `when` condition fires. ## IslandProps ```ts interface IslandProps { when?: When; ssrFallback?: VNode; children?: VNode; } ``` - `when` — hydration strategy. Defaults to `"load"`. See [Hydration strategies](#hydration-strategies) below. - `ssrFallback` — enables SSR-skip mode (equivalent to Astro's `client:only`). When provided, the heavy `children` are **not** evaluated server-side; `ssrFallback` is rendered in their place and the client swaps in the real component on hydration. - `children` — the component to hydrate. ## Hydration strategies The `when` prop accepts three values, all shipped today: | Value | Behaviour | | --- | --- | | `"load"` | Hydrate immediately when the page's JavaScript runs. This is the default when `when` is omitted. | | `"visible"` | Hydrate when the island's root element enters the viewport (IntersectionObserver, threshold 0). Cheapest deferral for off-screen content. | | `"idle"` | Hydrate during the browser's next idle callback. Falls back to `setTimeout(0)` on platforms without `requestIdleCallback`. | ## SSR-skip mode Pass `ssrFallback` to skip server rendering the heavy child entirely: ```tsx return ( Loading chart…}> ); } ``` The server emits `…fallback…`. On hydration the client runtime renders `HeavyChart` into the placeholder. ## Exported constants and helpers The following are exported from `"@takazudo/zfb"` alongside `Island`: - `HYDRATE_MARKER_ATTR` — the `data-zfb-island` attribute name. - `SKIP_SSR_MARKER_ATTR` — the `data-zfb-island-skip-ssr` attribute name. - `ANONYMOUS_COMPONENT_NAME` — fallback name used when a wrapped child's identity cannot be determined. - `resolveWhen(when: unknown): When` — validates and normalises a `when` value. Unknown inputs fall back to `"load"` (with a warning in development). ## "use client" directive zfb also supports a `"use client"` file-level directive as an alternative authoring style. A component file whose first statement is the literal string `"use client"` is treated as an island entry by the build scanner (`crates/zfb-islands`). The esbuild-backed bundler compiles it to ESM separately, with its own dependency graph. ```tsx "use client"; const [count, setCount] = useState(0); return ( setCount(count + 1)}> Count: {count} ); } ``` Import the island from a server component or page module. The build pipeline wires the hydration bootstrap automatically. For the broader story on islands architecture and when to reach for one, see [Islands](/concepts/islands). --- # Why Rust > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/architecture/why-rust zfb is a static-site framework whose users write TypeScript and JSX. The framework itself is Rust. This page explains why. ## Performance The headline is per-page rebuild time measured in milliseconds. Parsing, type checking, content processing, and atomic file writes are all native Rust — not "fast enough", just genuinely fast. A 2,000-page site rebuilds the affected pages in well under a second when a shared header changes, because the work is a tight Rust loop over a known dependency graph. The dev loop is the same shape. A save produces an FS event; the watcher debounces; the orchestrator queries the graph; affected pages re-render; the dev server broadcasts a reload. End-to-end latency lives in the tens of milliseconds for a typical edit. The browser refresh is the slow part. This is not magic. It is what you get when the framework does not JIT-warm an interpreter every save and does not keep a multi-gigabyte module graph in memory. ## Memory safety and structured errors Rust's compiler eliminates use-after-free, data races, and null derefs at compile time. The build does not segfault halfway through writing `dist/`. The watcher does not panic when save bursts overlap. Boring wins, the kind you stop noticing once you have them. The visible win is error quality. zfb uses `anyhow` end-to-end, so every error reaches the CLI as a chain: the immediate failure, the operation that failed, the file path, the higher-level intent. A TSX compile error shows the file, line, column, and the operation that triggered it — not a generic "build failed". ## Concurrency that fits The build orchestrator schedules across CPUs; the dev server runs an `axum` listener and an SSE broadcast at the same time. Rayon handles parallel-page rendering; tokio handles the async I/O. They coexist without ceremony — Rust's type system enforces the boundaries, so threads never share state they should not share. The same code in Node would need worker threads or child processes for equivalent isolation. Workers do not share modules; child processes do not share memory. Either way, you pay coordination overhead on every page that the Rust version does for free. ## Distribution zfb is a single binary distributed via npm. `@takazudo/zfb` pulls a prebuilt platform binary via npm optional-deps — that executable is the framework. No `node_modules` for zfb itself, no transitive tree to audit. Your project still brings its own `pnpm` for the dependencies you import (TypeScript, npm packages, lockfiles), but the framework is not in that tree. (Contributors who need to build from source can follow the steps in [From source](/getting-started/installation#from-source-contributors).) A single binary pins cleanly. CI installs the version it needs; production deploys ship the same binary that ran in dev. The version your team is on is one number, not a constellation of `package.json` ranges. ## Honest counterpoint: build cost The first `cargo build` of zfb's workspace is slow. The V8 source bundle (pulled in by `zfb-render` via `deno_core`) compiles for 15 to 30 minutes on a typical contributor machine. Incremental builds are fast; the first one hurts. This cost is accepted because Tauri distribution requires a single binary with no Node dependency on end-user machines — embedding `deno_core` is the only way to meet that constraint. See [JS runtime](/architecture/js-runtime) for the full rationale. zfb gates the V8 build behind a default-on `embed_v8` cargo feature on `zfb-render`. Crates that do not need JavaScript rendering never pull in V8. A contributor running `cargo test -p zfb-content` compiles in seconds. End users do not pay this cost — they install the prebuilt binary. The gate is not a free lunch on the linked `zfb` binary itself: V8 dominates the artifact (the bundled `v8` rlib is ~169 MiB on its own, and the rest of the `deno_*` tree adds another ~13 MiB), so a V8-off `zfb` is roughly 82 MiB smaller stripped than the V8-on shipping binary. Today that lighter binary is only useful for V8-less consumers of the `zfb-render` library — there is no V8-less `zfb` build step yet, because SSG rendering needs V8 to run user pages. The feature is wired so a future shipping path (Tauri sidecar, standalone SSR server, `cargo install`-as-deploy) can opt into the smaller artifact without re-architecting the workspace. ## Compared to JS-based alternatives Vite, Next, Astro, SvelteKit — all great tools. zfb's bet is different, not better in every direction: **what if the framework was Rust and only the user code was JS / TS?** That tilts the trade-offs. Build speed gets cheaper because there is no bundler walking a JS module graph at framework cost. Distribution gets simpler — no framework `node_modules`. Memory and concurrency get language-level guarantees. Error quality gets a uniform shape through `anyhow`. The cost is a smaller plugin ecosystem in the framework's native language and a longer first build for contributors. zfb pays both on purpose, because the design point is users who want their build out of their way. If you want a JS-native framework with a vast plugin ecosystem and a hot-reload story polished over years, those tools exist. If you want the framework to disappear into a fast binary and the rebuild to feel like nothing happened, that is what zfb is for. --- # Styling > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/styling Styling in zfb has two layers — global CSS and Tailwind v4 — and one well-supported pattern for everything else: utility classes on the markup itself. ## Global CSS The default template ships with `styles/global.css`. This is plain CSS, processed by zfb's CSS pipeline, and made available to every page. Use it for design tokens, resets, base typography, and anything else that should apply site-wide: ```css :root { --color-text: #1a1a1a; --color-bg: #ffffff; --font-body: system-ui, sans-serif; } body { color: var(--color-text); background: var(--color-bg); font-family: var(--font-body); } ``` Imports inside CSS work as you would expect — split your styles across files and pull them together from `global.css`. ## Tailwind v4 Tailwind v4 is opt-in via `zfb.config.{ts,json}`: ```json { "tailwind": { "enabled": true } } ``` When enabled, the `zfb-css` crate runs the bundled `tailwindcss-v4` binary as part of the build. There is **no per-project Tailwind install** — you do not add `tailwindcss` to `package.json` and you do not maintain a `tailwind.config.js`. The compiler is built into zfb itself. Once Tailwind is on, utility classes work in any `.tsx` file: ```tsx return ( Hello ); } ``` Tailwind v4's CSS-first configuration is supported through `@theme` directives in `global.css` — you customise tokens by editing CSS, not a JS config file. ## Component-scoped styling There are two well-supported patterns for component-level styling: Tailwind utility classes, and CSS Modules. ### Tailwind utility classes The simplest pattern is **global CSS for site-wide concerns plus Tailwind utility classes for component-level styling**. This keeps the build fast and the runtime trivial, and it maps cleanly onto Tailwind v4's design-token model. ### CSS Modules For genuinely component-scoped CSS — class names that must not collide across components — zfb supports CSS Modules. Any file named `*.module.css` is a CSS Module: its class names are rewritten to scoped, file-stable identifiers at build time, so two components can both define a `.button` class without clashing. Author the styles in a `.module.css` file: ```css title="components/card.module.css" .card { border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; } .title { font-weight: 700; } ``` Import the module with a **default import** and read class names off the imported object: ```tsx title="components/Card.tsx" return ( Hello ); } ``` At build time zfb resolves `styles.card` to the scoped class name (e.g. `KdPA9G_card`) — the rendered HTML carries that scoped class, and the scoped CSS is folded into the same hashed `dist/assets/styles-.css` stylesheet as the rest of your CSS. There is no separate `.css` file per module and no runtime cost: the lookup is resolved during the build. How it works: - `import styles from "./x.module.css"` must be a **default import**. `styles` is a plain object mapping your original class names to the scoped ones. - Access a class with member access — `styles.card` or `styles["card"]`. Both work; computed access with a dynamic key does not, because the rewrite happens at build time. - Plain `.css` imports (a file *not* ending in `.module.css`) are still treated as global CSS — only the `.module.css` suffix opts a file into scoping. - The `.module.css` file is discovered when it is imported from a `.tsx`/`.ts`/`.jsx`/`.js` file under `pages/`, `components/`, `layouts/`, or `content/`. Limitations: - CSS Modules imported by **bare specifier** from `node_modules` (e.g. `import s from "@org/pkg/x.module.css"`) are not scoped — only project-relative `./` / `../` imports are. - The `:export` block and `composes` directive are not supported; use plain class selectors. ## What lands in `dist/` The build pipeline runs Tailwind and PostCSS, writes a hashed stylesheet to `dist/assets/`, and injects a `` into each rendered HTML page. The stylesheet reference is stable so CDN caches can hold it across deployments until the content changes. --- # Project structure > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/getting-started/project-structure When you run `zfb new my-site`, the `basic-blog` template lays down a small but complete project. Knowing what each directory is for makes the rest of the docs much easier to skim. ```text my-site/ ├── pages/ │ ├── index.tsx │ └── blog/ │ └── [slug].tsx ├── layouts/ │ └── default.tsx ├── components/ │ ├── note.tsx │ └── theme-toggle.tsx ├── content/ │ └── blog/ │ └── hello-zfb.mdx ├── styles/ │ └── global.css ├── public/ ├── zfb.config.json ├── package.json ├── tsconfig.json └── .gitignore ``` ## pages/ File-system routing lives here. Every `.tsx` file under `pages/` becomes a route: - `pages/index.tsx` → `/` - `pages/about.tsx` → `/about` - `pages/blog/[slug].tsx` → `/blog/:slug` (dynamic route — one HTML file per resolved slug) - `pages/docs/[...slug].tsx` → catchall, matches any depth The default template ships an index page and one dynamic blog route. A dynamic route file exports a `paths()` function; zfb calls it at build time to expand `[slug]` into one HTML file per resolved slug. See [Dynamic routes](/concepts/dynamic-routes) for the full API. ## layouts/ Reusable page wrappers. `layouts/default.tsx` is the shell every page imports — header, footer, common metadata, and so on. Layouts are plain TSX components; you compose them however you like. ## components/ Plain components and **islands**. The template ships `components/note.tsx` (a server-rendered callout) and `components/theme-toggle.tsx` (a `"use client"` island for toggling dark mode). The `"use client"` directive at the top of a file is what turns a component into a client-side island — components without it render only on the server and ship zero JavaScript. The full mental model is on the [islands page](/concepts/islands). ## content/ Content collections. `content/blog/` holds the seed entries of a `blog` collection: Markdown and MDX files in a named directory, queryable from pages via `getCollection("blog")`. Collection schemas are declared in `zfb.config.ts` under `collections`. ## styles/ Global CSS. `styles/global.css` is the entry point — import it from your root layout. The default template wires up Tailwind here when `tailwind.enabled` is set in the config. ## public/ Static assets served as-is from the site root. Put `favicon.ico`, `robots.txt`, SVGs, raster images, fonts, manifest files, or any binary you want to reference by absolute URL here. The directory does NOT appear in the URL — `public/logo.svg` is reachable at `/logo.svg`, both in `zfb dev` and after `zfb build`. Reference these files by URL from your TSX, MDX, or CSS: ```tsx ``` Do NOT use bundler-style imports (`import logo from "./logo.svg"`) for static assets — zfb does not run an asset pipeline over `public/`. The directory is a verbatim mirror; files come out at the URL that matches their relative path. When `base` is set in `zfb.config.ts` (e.g. `base: "/pj/site/"`), files in `public/` are served under that prefix too — `public/logo.svg` → `/pj/site/logo.svg`. The same prepend happens at build time, so the prefix is consistent between dev and prod. See [Static Assets](/concepts/static-assets) for the full reference, including when to use `public/` vs a TSX import for islands. ## zfb.config.json The config file uses a camelCase schema with these keys: `outDir` (default `"dist"`), `publicDir` (default `"public"`), `host` (default `"localhost"`), `port` (default `3000`), `framework` (`"preact"` or `"react"`, default `"preact"`), `collections`, `tailwind`, and `plugins`. The template scaffolds `zfb.config.json`. If you rename it to `zfb.config.ts`, zfb loads it via the bundled esbuild binary and you get full TypeScript types and IDE completion — the schema is identical in both formats. ## package.json, tsconfig.json, .gitignore Standard project plumbing. `package.json` declares the framework runtime dependency (`preact` by default) and any libraries your islands import. `tsconfig.json` is configured so TSX in `pages/`, `layouts/`, and `components/` type-checks cleanly. The shipped `.gitignore` excludes `dist/` and `node_modules/` so build output and dependencies stay out of version control. --- # Syntax Highlighting > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/syntax-highlighting zfb ships **server-side syntax highlighting** via [syntect](https://github.com/trishume/syntect) — a Rust library that runs at build time inside `crates/zfb-content`. When the pipeline encounters a fenced code block, `SyntectPlugin` looks up the language tag, highlights the source with the configured theme, and replaces the `` element with a `…` HTML fragment baked into the output. No JavaScript is shipped to the browser for highlighting. ## Built-in behaviour | Property | Value | |---|---| | Engine | `syntect` (Sublime Text–compatible grammars) | | Execution | Build-time (hast visitor phase in `crates/zfb-content`) | | Output | Inline HTML with `class` attributes — zero runtime JS | | Unknown languages | Themed fallback: wrapped in ``, code preserved | | `mermaid` blocks | Skipped — routed to `MermaidPlugin` instead | ## Customising the theme (built-in themes) The simplest way to change the colour scheme is to pick one of syntect's bundled themes in `zfb.config.ts`: ```ts // zfb.config.ts codeHighlight: { theme: "Solarized (light)", }, }; ``` Built-in theme names: `"base16-ocean.dark"` (default), `"base16-ocean.light"`, `"InspiredGitHub"`, `"Solarized (dark)"`, `"Solarized (light)"`. These are **not** Shiki theme names. Using a name like `"dracula"` without loading it via `themesDir` will produce an `unknown theme` error at build time. ## Using custom .tmTheme files Syntect is compatible with Sublime Text's `.tmTheme` format. You can load any `.tmTheme` file (Dracula, One Dark, Catppuccin, …) by dropping it into a directory and pointing `codeHighlight.themesDir` at it: ```ts // zfb.config.ts codeHighlight: { themesDir: "./themes", // relative to the project root theme: "Dracula", // the `name` declared inside the .tmTheme file }, }; ``` **Directory layout:** ``` my-project/ ├── themes/ │ └── dracula.tmTheme ← drop your .tmTheme files here ├── pages/ ├── content/ └── zfb.config.ts ``` The `.tmTheme` filename does not matter — the name you pass to `theme` must match the `` value of the `name` key inside the plist. For Dracula, the declared name is `"Dracula"`. **Downloading Dracula:** the official Dracula `.tmTheme` is available at [https://draculatheme.com/sublime](https://draculatheme.com/sublime) or directly from the [Dracula GitHub repository](https://github.com/dracula/sublime/blob/master/Dracula.tmTheme). **Error reporting:** if `themesDir` points at a missing directory or any `.tmTheme` file is malformed, zfb surfaces a clear error at build start (before rendering any pages) that includes the file path and the parse error. ## Supplementary pattern 1 — additional client-side highlighting If you want interactive theme switching or per-user preferences, layer a client-side highlighter on top of the server-rendered output as a [client island](/concepts/islands): ```tsx "use client"; const ref = useRef(null); useEffect(() => { Prism.highlightAllUnder(ref.current); }, []); return {children}; } ``` Wrap your article body in `` and the island will re-highlight the pre-rendered blocks in place. This is useful for themes that depend on media queries or user preference, but it adds JavaScript and causes a brief re-paint after hydration. ## Supplementary pattern 2 — post-build script for custom grammars `syntect` uses Sublime Text–compatible grammars. If you need a grammar that syntect does not bundle (an internal DSL, a niche language), you can run a post-build Node script to replace specific blocks after `zfb build`: ```ts // post-build/highlight-custom.ts — runs after `zfb build` for (const file of await glob("dist/**/*.html")) { const html = await readFile(file, "utf8"); const next = await highlightCustomBlocks(html, codeToHtml); if (next !== html) await writeFile(file, next); } ``` This only makes sense for grammars absent from syntect's bundled set. For all standard languages (Rust, TypeScript, Python, Go, etc.), the built-in pipeline handles them without an extra step. ## See also - [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) — how `SyntectPlugin` fits into the hast-phase pipeline and how to swap or extend it. - [Custom Directives](/concepts/custom-directives) — `mermaid` blocks use this path instead of syntect. - `crates/zfb-content/src/plugins/syntect_plugin.rs` — plugin source. --- # Static Assets > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/static-assets How to ship static files — images, SVGs, fonts, favicons, `robots.txt`, JSON manifests, anything binary — through the `public/` directory. Covers the URL convention, the dev/prod parity guarantee, the precedence rule when filenames collide with pages, the interaction with the `base` mount prefix, and when to reach for a TSX `import` instead. zfb handles non-code assets through a single directory: `public/`. Drop a file in, reference it by absolute URL, and the same URL works in `zfb dev`, `zfb preview`, and the static `dist/` your build emits. There's no plugin to install, no `import` to write, no bundler step you can break. ## The convention Anything inside `public/` is served verbatim at the site root. The `public` segment does **not** appear in the URL. ```text public/favicon.ico → /favicon.ico public/logo.svg → /logo.svg public/robots.txt → /robots.txt public/img/hero.png → /img/hero.png public/fonts/Inter.woff2 → /fonts/Inter.woff2 ``` Subdirectories are preserved, but the top-level `public/` name is stripped. A request to `/img/hero.png` resolves to `/public/img/hero.png` in dev and to `dist/img/hero.png` after `zfb build`. ## Referencing assets Use absolute URLs. The asset path mirrors what shows up in the rendered HTML: ```tsx // pages/index.tsx return ( ); } ``` CSS works the same way — the URL is what the browser ultimately requests: ```css /* styles/global.css */ .hero { background-image: url("/img/hero.png"); } @font-face { font-family: "Inter"; src: url("/fonts/Inter.woff2") format("woff2"); } ``` ## Do not import static assets as modules zfb does **not** run a bundler over `public/`. Patterns like the ones below — common in Vite, webpack, and similar toolchains — do not work here: ```tsx // ❌ Do not do this for static files. ``` There is no asset pipeline that turns those imports into URLs. Use the absolute-URL form (`src="/logo.svg"`) instead. Imports are still the right answer for **code** — `.ts`, `.tsx`, `.css` modules used by islands — but not for binary files like images, fonts, or SVGs you want the browser to fetch as-is. If you genuinely need to inline an SVG as JSX (so CSS can style strokes, fills, etc.), copy the SVG markup into a TSX component. That's a code path; `public/` is the byte-for-byte path. ## Dev / prod parity The dev server and the production build agree on URL shape. This is a guarantee, not a coincidence: - **`zfb dev`** — the page handler falls back to reading from `/` after a page-cache miss and a `dist/` miss. The `public/` directory has no URL prefix and no top-level `nest_service` mount; files appear at the site root directly. - **`zfb build`** — `copy_public_dir` (in `crates/zfb/src/commands/build.rs`) copies every file under `public/` into `dist/`, recursively. The static `dist/` tree your edge CDN serves is the same shape your browser saw in dev. That means `` written once in your page works in both modes without conditional logic, environment checks, or a `withBase`-style helper. ## Precedence: pages win over public files It is possible — though usually unintentional — to have a `pages/foo.tsx` route and a `public/foo` file with the same URL. zfb resolves this deterministically: 1. **Plugin dev-middleware** that claims `/foo` runs first. 2. **Page cache** — the rendered output of `pages/foo.tsx` wins next. 3. **`dist/` directory** — files written by the build pipeline are served next. 4. **`public/` directory** — only consulted if all three of the above miss. 5. **404** otherwise. So a same-named TSX page always shadows a public file. The reverse is not possible — `public/foo` cannot override a route. If you need a static file at a URL that a page also claims, rename one of them. ## Interaction with `base` When `zfb.config.ts` sets a `base` prefix (e.g. `base: "/pj/site/"` for a deploy under a sub-path), files in `public/` move under that prefix too: ```text config: base: "/pj/site/" public/logo.svg → /pj/site/logo.svg (dev and prod) ``` Both the dev server's `serve_page` fallback and the build-time `copy_public_dir` honour the prefix. As long as you write asset URLs in HTML the same way the rest of your project does — typically by going through the link rewriter that the markdown / TSX pipeline already runs — the prefix is applied for free. ## Configuration The directory is configurable. Add `publicDir` to `zfb.config.ts` to point somewhere other than the default: ```ts // zfb.config.ts publicDir: "static", }); ``` Default: `"public"`. The path is resolved relative to the project root. A missing directory is a silent no-op — not every project needs one. ## What does NOT go in `public/` `public/` is the right home for: - Site-wide icons and favicons (`favicon.ico`, `apple-touch-icon.png`) - Open Graph / social-share images - `robots.txt`, `humans.txt`, security.txt - Web app manifests (`manifest.webmanifest`) - Fonts you self-host - Decorative imagery referenced by absolute URL from many pages It is the wrong home for: - **Source images you transform** (resize, optimise, convert to AVIF/WebP). zfb has no built-in image pipeline; if you need transforms, run them out-of-band (e.g. via a `prebuild` script) and check the optimised outputs into `public/`, or reach for a separate tool entirely. - **Code dependencies of islands**. TSX / JSX / TS / CSS imported by a `"use client"` island should live alongside the island and be bundled. Putting code in `public/` skips the bundler entirely — the browser will fetch raw source the runtime cannot execute. - **Files that need a different `Content-Type` than the extension implies**. zfb derives the Content-Type from the file extension. If you need an override, render the file through a TSX page instead (see [Non-HTML Pages](/concepts/non-html-pages)). ## See also - [Project structure: `public/`](/getting-started/project-structure) — the directory layout at a glance. - [Non-HTML Pages](/concepts/non-html-pages) — render `.xml`, `.json`, or `.txt` through a TSX page when you need control over headers or want the page to depend on collection data. - [Islands](/concepts/islands) — the path for client-side JS, distinct from the static-asset path described here. --- # meta export > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/api/meta-export ## The pattern Every page module may export a `meta` constant (or an async function returning the same record) describing the page's `` data — title, description, Open Graph tags, canonical URL, and so on. The renderer reads this export via `RenderHost::get_export` and merges it into the page layout's `` block. ## Example ```tsx title: "Blog — My Site", description: "Latest writing on web development.", canonical: "https://example.com/blog", og: { image: "/og/blog.png", type: "website", }, }; return {/* ... */}; } ``` If you need data from a collection or an async source to compute meta — for example, an OG image derived from a CMS field — export `meta` as an async function. The renderer awaits it before rendering the page shell. ```tsx const post = getCollection("blog")[0]; return { title: post.data.title, og: { image: `/og/${post.slug}.png` }, }; } ``` ## Supported keys The renderer treats `meta` as a permissive bag, but the following keys are the supported subset that page layouts look for: - `title?: string` — overrides the layout default for this page. - `description?: string` — used for the `` tag and as the OG fallback. - `canonical?: string` — absolute URL for ``. - `og?: { title?, description?, image?, type?, url? }` — Open Graph tags. Missing fields fall back to top-level `title` / `description` where it makes sense. - `twitter?: { card?, site?, creator?, image? }` — Twitter Card tags. - `layout?: string` — module specifier (relative path or bare specifier) for the page layout component. When absent, the project default layout is used. Unknown keys pass through untouched, which lets you stash structured data for plugins or custom layout components. --- # Islands > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/islands zfb pages render to static HTML by default. **Islands** are the escape hatch — small components that ship JavaScript to the browser and hydrate on the client, while the rest of the page stays as plain HTML. The mental model is straightforward: most of your page is a static document. A few interactive bits — a counter, a search box, a theme toggle — are islands embedded inside that document, each loaded and hydrated independently. ## What an island is Add the `"use client"` directive at the top of a `.tsx` file: ```tsx "use client"; const [count, setCount] = useState(0); return ( setCount(count + 1)}> Count: {count} ); } ``` That single directive is the whole opt-in. Files without it are pure server components — they render once at build time and never reach the browser. The `theme-toggle.tsx` component from the [zfb-example-blog standalone repo](https://github.com/Takazudo/zfb-example-blog) is the canonical real-world example: it reads `localStorage` and `matchMedia`, manages its own state, and mirrors the active theme to `document.documentElement.dataset.theme`. The key pattern is that it renders a deterministic SSR-safe default on first paint, then syncs to the user preference inside `useEffect`: ```tsx "use client"; type Theme = "light" | "dark"; // Deterministic SSR-safe default. Real preference is applied in useEffect. const [theme, setTheme] = useState("light"); useEffect(() => { const saved = window.localStorage.getItem("theme"); if (saved === "light" || saved === "dark") setTheme(saved); }, []); const next: Theme = theme === "dark" ? "light" : "dark"; return ( setTheme(next)} > {theme === "dark" ? "Light mode" : "Dark mode"} ); } ``` ## What esbuild produces for N islands For a project with three islands (`Counter`, `ThemeToggle`, `SearchBox`), the islands build step emits **four files**: ``` dist/islands/Counter.js dist/islands/ThemeToggle.js dist/islands/SearchBox.js dist/islands/islands-runtime.js ``` Three per-island bundles — one per component — plus a single shared runtime bundle that drives hydration. Each per-island file is a self-contained ESM module: it imports the component and the framework-specific hydration glue (e.g. Preact's `hydrate`) and exports nothing. Sharing between islands happens at the esbuild level within each bundle; the runtime itself is a separate bundle. **Filenames are stable** — no content hash in the name. The `ProductionAssetPipeline` is the single source of truth for hashing and handles URL rewrites in the emitted HTML; the bundler writes predictable paths so nothing downstream has to guess. ## How islands are loaded After render, each island's server-rendered HTML is wrapped in a `` that carries metadata: ```html Dark mode ``` On pages that have at least one island, **one `` tag** is injected into ``: ```html ``` The runtime (`islands-runtime.js`) walks all `[data-zfb-island]` elements on the page. For each element it finds, it `dynamic-import()`s the matching per-island bundle — `/islands/ThemeToggle.js` for `data-zfb-island="ThemeToggle"` — reads the serialised `data-props`, and calls `hydrate()` on the existing server-rendered DOM. ## Consequences of the dynamic-import design Because the runtime resolves islands lazily via `import()`: - **Only the islands a page actually uses are fetched.** A page that uses `ThemeToggle` never downloads `Counter.js` or `SearchBox.js`. - **Pages with no islands get no runtime.** If a page's render tree contains no `"use client"` components, the build pipeline skips the `` injection entirely. Zero bytes of JavaScript land on a fully static page. - **Adding a new island to one page does not affect other pages.** Because each page's HTML is independent and islands are fetched by name, adding `SearchBox` to the home page leaves every other page's HTML and network traffic unchanged. The stable filenames reinforce this: a CI deploy adds `SearchBox.js` to the `dist/islands/` directory; all existing `*.js` files stay byte-identical and remain cache-valid in the browser. ## Framework choices zfb supports two frameworks for islands: | Config value | Runtime | |---|---| | `"preact"` (default) | Preact + `preact/jsx-runtime` | | `"react"` | React 18 + `react-dom/client` | Set it once in `zfb.config.ts`: ```ts framework: "preact", // or "react" }; ``` This is a **project-wide setting** — one framework per project. The bundler (`FrameworkKind` enum in `crates/zfb-islands`) threads the choice through JSX transform options (`--jsx-import-source`) and the hydration glue that wraps each per-island entry. You cannot mix Preact islands and React islands in the same project. zfb does not support Vue, Svelte, or Solid. The `FrameworkKind` enum is intentionally a two-variant enum, not a plugin point. If you need a different framework, the escape hatches below cover the common cases. For a deeper look at how the two adapters work, see [Framework adapters](/architecture/framework-adapters). ## Escape hatches for non-island client JS Islands cover stateful UI components. For other client-side JavaScript needs, use the standard HTML mechanisms directly. **Inline scripts** — write a `` tag directly in your page TSX or layout: ```tsx return ( {children} ); } ``` This is the right tool for synchronous pre-hydration work that must run before the stylesheet parses (FOUC prevention, theme init, analytics setup). **External scripts** — reference any `.js` file from `public/` or a CDN: ```tsx ``` **Custom build steps** — if you need to bundle additional TypeScript modules, add a separate esbuild or Rollup step to your build pipeline and reference the output from a ``. zfb does not auto-bundle arbitrary non-`"use client"` modules — only the islands pipeline runs automatically. What you **cannot** do: import a regular (non-`"use client"`) `.ts` or `.tsx` module from a page and expect its browser-side code to reach the client. Modules without the directive are server-only — SWC compiles and evaluates them at build time, and none of their bytes are included in the output. ## When not to use islands Islands ship JavaScript. That has a cost. Before adding one, ask whether you can solve the problem without it. **DOM class-swap toggles** — accordions, disclosure menus, show/hide panels — are often solved with a few lines of plain CSS or a small inline ``. The native `` / `` element handles accordion behaviour with no JavaScript at all: ```html Frequently asked question The answer goes here. ``` CSS-only approaches (`:target`, `:checked` + ``, `@starting-style`) handle many interactive patterns that previously required JavaScript. **Islands are the right tool when:** - The component has state that must survive beyond a single interaction (e.g., a cart, a user session, a multi-step form). - The component relies on browser APIs not available at build time (`canvas`, `WebGL`, `getUserMedia`, real-time data). - You would otherwise write the component twice — once for the server render, once for the client — and keep them in sync manually. If the honest answer is "I just want a class toggled on click", reach for CSS or a tiny inline script first. Islands for "stateful UI you'd build twice" is the right heuristic. For more on the pipeline shape, see [Build pipeline](/concepts/build-pipeline) and [Build engine](/architecture/build-engine). --- # Non-HTML Pages > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/non-html-pages How TSX pages can emit non-HTML output (XML sitemaps, RSS feeds, `llms.txt`, JSON manifests). Covers the filename convention, the frontmatter override, the precedence rule between them, per-page `Content-Type` metadata, and what the engine does about stale outputs when the extension changes. zfb's page model isn't HTML-only. The same TSX file format that renders pages can also produce `.xml`, `.rss`, `.txt`, `.json`, or anything else you can stringify. The engine picks the output extension, sets the `Content-Type` for the dev server, and writes the file under `dist/` exactly like an HTML page — only the extension changes. This is one of the six engine primitives — see [Engine vs Framework](/concepts/engine-vs-framework) for the full list. Sitemap and feed generation, `llms.txt`, JSON API endpoints rendered at build time — they all sit on this primitive. ## Two ways to declare the extension ### Filename convention The simplest path: name your page file with the target extension embedded as the second-to-last `.`-separated segment of the filename stem. ```text pages/sitemap.xml.tsx → /sitemap.xml pages/feed.rss.tsx → /feed.rss pages/llms.txt.tsx → /llms.txt pages/about.tsx → /about/index.html ``` The last segment before `.tsx` is the output extension; earlier dots are part of the page name. `pages/api.v2.json.tsx` becomes `/api.v2.json`. `pages/about.tsx` has no convention hint and defaults to `.html`. ### Frontmatter override Sometimes the filename is fixed and you want a different output. Add a sibling `extension` literal next to the page's frontmatter: ```tsx // pages/raw.tsx return "plain text body"; } ``` Output: `/raw.txt`. Both `frontmatter` and `extension` are statically extracted by SWC without evaluating the module — see [Frontmatter](/concepts/frontmatter) for the literal-only contract. ## Precedence When both mechanisms apply, the rule is: > **frontmatter `extension` > filename convention > `html` default** ```text pages/page.html.tsx → .html (filename hint) pages/page.html.tsx + extension="txt" → .txt (frontmatter override wins) pages/about.tsx → .html (default) pages/about.tsx + extension="json" → .json (frontmatter override) ``` The rule is enforced jointly by `zfb-content::tsx_frontmatter` (extracts the `extension` literal) and `zfb-router::route::Route` (parses the filename convention). A small helper — `zfb_render::meta::derive_output_extension(frontmatter, route)` — applies the precedence at the call site. ## `Content-Type` metadata Every output extension carries a default `Content-Type`, threaded through to the dev server so it can set the response header correctly: | Extension | Default `Content-Type` | | --------- | ---------------------- | | `html`, `htm` | `text/html; charset=utf-8` | | `xml` | `application/xml` | | `rss` | `application/rss+xml` | | `atom` | `application/atom+xml` | | `json` | `application/json` | | `txt` | `text/plain; charset=utf-8` | | `css` | `text/css; charset=utf-8` | | `js`, `mjs`, `cjs` | `application/javascript; charset=utf-8` | | `svg` | `image/svg+xml` | | anything else | `text/html; charset=utf-8` (permissive fallback — set `contentType` explicitly) | Override per page with `export const contentType = "..."`: ```tsx // pages/feed.xml.tsx return /* the RSS body */; } ``` The override beats the table; the table beats the unknown-extension fallback. Static-file hosts (Cloudflare Pages, Netlify, S3) derive the response `Content-Type` from the file extension instead — the in-engine `contentType` is purely a dev-server convenience. ## Stale-output cleanup across builds The output extension can change between builds. Today's `pages/raw.tsx` builds `/raw.html`; tomorrow you add `export const extension = "txt"` and it builds `/raw.txt`. The engine deletes the previous-build output for that route when the extension changes, so `dist/` doesn't accumulate stale files (`/raw.html` left behind beside the new `/raw.txt`). The cleanup runs as part of the build orchestrator's atomic write phase — see [Build Pipeline](/concepts/build-pipeline) — and is keyed on the route's stable identity (the source path), not its output filename, so renames don't leak old files either. ## Example: an XML sitemap from a content collection ```tsx // pages/sitemap.xml.tsx const SITE = "https://example.com"; const posts = getCollection("blog"); const urls = posts.map((p) => `${SITE}/blog/${p.slug}`); const body = `` + `` + urls .map( (loc) => `${loc}`, ) .join("") + ``; return body; } ``` Filename convention picks `xml`. The engine writes `dist/sitemap.xml` and tags it `application/xml` for the dev server. No HTML, no layout, no chrome — the page returns a string and the engine treats it as the body. ## Example: `llms.txt` ```tsx // pages/llms.txt.tsx const posts = getCollection("blog"); const lines = [ "# My Site", "", "## Posts", "", ...posts.map((p) => `- [${p.data.title}](/blog/${p.slug})`), ]; return lines.join("\n"); } ``` Filename convention picks `txt`. Output: `dist/llms.txt`, served with `text/plain; charset=utf-8`. ## What the engine doesn't do - **String-encode JSX automatically for non-HTML outputs.** Your page returns the body as a string (or a JSX value the renderer serializes). The engine doesn't attempt to "render JSX as XML" — produce the bytes you want and return them. - **Generate sitemaps / feeds / `llms.txt` for you.** That's a framework concern. The engine ships the substrate (file output + collections + `paths()`); your framework or your project assembles the convention. ## See also - [Frontmatter](/concepts/frontmatter) — the literal-only contract for `extension` and `contentType` exports. - [Routing](/concepts/routing) — the static-route side, including the filename convention table. - [Build Pipeline](/concepts/build-pipeline) — where stale-output cleanup runs in the build sequence. - [Engine vs Framework](/concepts/engine-vs-framework) — why non-HTML emission is an engine primitive (not a framework add-on). --- # Build Pipeline > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/build-pipeline zfb is built as a set of small Rust crates that each own one job. This page is a tour of the whole pipeline at the level of "which crate does what, and in what order" — enough to give you a mental map without diving into any single piece. ## The crates, in order 1. **CLI** — `crates/zfb`. Parses command-line args, loads `zfb.config.{ts,json}`, and dispatches to the `dev`, `build`, or `preview` command. 2. **Router** — `crates/zfb-router`. Scans `pages/` and produces the route table. See [Routing](/concepts/routing). 3. **Watcher** — `crates/zfb-watcher`. Dev mode only. Observes `pages/`, `content/`, `components/`, `layouts/`, `styles/`, `data/`, `public/`, and `zfb.config.ts`, and emits a stream of change events. 4. **Dependency graph** — `crates/zfb-graph`. Tracks page-to-source dependencies. When the watcher reports a change, the graph answers a single question: which pages need to be rebuilt? 5. **Build orchestrator** — `crates/zfb-build`. Coordinates the per-page work and writes outputs atomically via `atomic_write_string`, so a partial write can never leave `dist/` in a half-broken state. 6. **Renderer** — `crates/zfb-render`. Compiles TSX to JS with SWC, evaluates the resulting ES module via the JS runtime host, and calls the page's `default()` export to produce an HTML string. Content collection entries (`.md` / `.mdx`) are compiled through the same pipeline as JSX modules — the renderer resolves `mdx:///` specifiers and surfaces them to user pages as `entry.Content` (see [MDX Components](/concepts/mdx-components)). 7. **CSS pipeline** — `crates/zfb-css`. Runs Tailwind v4 and PostCSS as needed. See [Styling](/concepts/styling). 8. **Islands pipeline** — `crates/zfb-islands`. Bundles each `"use client"` component as a separate ESM module via esbuild. See [Islands](/concepts/islands). 9. **Server** — `crates/zfb-server`. Dev mode only. An axum-based HTTP server that serves the page cache as static files, plus an SSE channel at `/__zfb/reload` for browser reload events (see [Dev mode lifecycle](/concepts/dev-mode-lifecycle) for the full event-type breakdown). ## Production: `zfb build` `zfb build` runs the pipeline once and writes a complete site to `outDir`: CLI → Router → Graph → Build orchestrator → Renderer → CSS pipeline → Islands pipeline. The watcher and dev server are not involved. Every page is rendered, every CSS bundle is produced, every island is bundled, and the result lands in `dist/` (or wherever `outDir` points). Atomic writes mean an interrupted build leaves the previous `dist/` intact rather than a partially overwritten mess. For `prerender = false` routes, the adapter emits a worker bundle instead of static HTML — see [SSR on a Worker (adapter mode)](/concepts/ssr-on-a-worker) for how that output is structured. ## Development: `zfb dev` `zfb dev` runs the same pipeline as a long-lived loop, plus the watcher and server: CLI → Router → Watcher → Build orchestrator → Renderer → CSS pipeline → Islands pipeline → Server. When the watcher reports a change, the dependency graph picks the affected pages, the orchestrator rebuilds only those, and the server broadcasts a reload event over the SSE channel. The browser reconnects automatically and reloads. ## Preview: `zfb preview` `zfb preview` is the simplest command of the three: it serves the existing `dist/` directory as static files on `:4321`. There is no rendering, no watching, no rebuilding — it exists so you can sanity-check a production build locally before deploying it. ## Why the split The crate boundaries are not accidental. Routing is a different problem from rendering, which is different from watching, which is different from serving. Splitting them means each crate stays small enough to test in isolation, and means the dev loop can swap in optimisations (incremental rebuilds, page caching, partial CSS extraction) without touching the production path. The deeper reasoning lives in [/architecture/build-engine](/architecture/build-engine). --- # Desktop Deployment > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/desktop-deployment This guide covers what is realistic today when you want to embed a zfb site inside a desktop app. There are four distinct composition modes between zfb and Tauri — each is the right answer for a different project. Choose deliberately: - **Mode A** wins when content is authored at build time and bundled with each release. - **Mode B** wins when you want zfb's full runtime dynamism with minimal Tauri-side code. - **Mode C** wins when the dynamic part of your app is Rust-doable without zfb in the process. - **Mode D** is the future mode once the in-process embed API ships. The core invariant that governs all four modes: > **Build time = Node.js required. Runtime = no Node.js, ever.** zfb's static output is deliberately runtime-agnostic. The build tooling is not. Plan your architecture around that boundary rather than against it. ## Mode A — Ship `dist/` only **What it is.** `zfb build` produces a fully static site in `dist/` — HTML, CSS, and JavaScript files with no server-side dependency at runtime. A desktop app can serve that directory directly using the framework's built-in asset server. zfb is invisible at runtime; the packaged app is just files on disk. **When to pick it.** Documentation apps, help systems, and any desktop app where the content is authored at build time and bundled with the release. If users do not need to edit content inside the running app, Mode A is the right choice. **What's involved.** For Tauri, the relevant `tauri.conf.json` keys are: ```json { "build": { "distDir": "../dist", "devPath": "http://localhost:4321" } } ``` Point `distDir` at your `dist/` folder (adjust the relative path to match your project layout). Tauri's built-in static file server handles the rest. There is no Node.js process and no HTTP server required in the packaged app. 1. Run `zfb build` on the developer machine (or in CI). 2. Include the resulting `dist/` in your Tauri app bundle. 3. Ship it. Done. See the [Tauri `tauri.conf.json` reference](https://tauri.app/v1/api/config/) for the full set of asset-serving options, including custom protocols and security policies. **Tradeoff.** Updating content requires a new app release. There is no mechanism for users to see fresh content without a new build. --- ## Mode B — zfb binary as Tauri sidecar **What it is.** Tauri spawns the `zfb` binary as a child process; the WebView points at `localhost:`. zfb handles request-time MDX rendering, resource discovery, and so on from inside that child process. The sidecar binary is Rust — no Node.js required in the packaged app. **When to pick it.** You want zfb's full dynamism — `prerender = false` routes, MDX rendering at request time, content reloading without a rebuild — but you do not need the zfb server to live inside the Tauri process itself. **What's involved.** Ship the `zfb` binary alongside your Tauri app as a [Tauri sidecar](https://v2.tauri.app/develop/sidecar/). Write a thin IPC bridge so the Tauri frontend can start the sidecar, discover the port, and open the WebView to `http://127.0.0.1:`. The boundary between sidecar and Tauri is clean; the integration glue is not provided out of the box. **Tradeoff.** You carry extra moving parts: child process lifecycle management, port discovery, and IPC plumbing between Tauri and the sidecar. In exchange you get zfb's full render pipeline at runtime with no Node.js dependency. --- ## Mode C — Custom Rust crates that use zfb at build-time only **What it is.** zfb compiles the Preact shell to `dist/` once, at build time. After that, all runtime dynamism — resource discovery, markdown rendering, serving — is hand-rolled Rust code, typically an Axum server that reads files from disk and substitutes content into the pre-built shell. zfb is not present at runtime at all; it is purely a build-time tool. **When to pick it.** The dynamic part of your app is markdown rendering or something else that straightforward Rust libraries already handle well, and you would rather write focused Rust server code than carry the full zfb render pipeline into your binary. You want the smallest possible runtime footprint. **What's involved.** Build the Preact shell with `zfb build`. Then write a purpose-built Rust server (or add Axum routes to an existing Tauri app) that reads markdown files at request time, renders them with a Rust markdown crate, and splices the result into the static shell via sentinel substitution or a similar pattern. [CCResDoc](https://github.com/Takazudo/ccresdoc) is a concrete, working example of this pattern: zfb compiles the Preact shell once and a hand-rolled Rust backend delivers content at runtime. **Tradeoff.** You write more Rust glue code, and you give up TSX-level page components at runtime. In exchange you get the smallest possible binary, full control over the runtime behaviour, and zero V8 dependency in the packaged app. --- ## Mode D — Embed zfb as a Rust crate inside Tauri (FUTURE) **What it is.** Tauri's `setup` hook would spawn a zfb server on a Tokio thread inside the Tauri process — no child process, no port management, in-process Rust-to-Rust handoff for `prerender = false` routes and Tauri IPC calls. The WebView would talk directly to the embedded server without a network port. **When to pick it (once it ships).** You want zfb's full runtime dynamism and tight Tauri integration — IPC, filesystem access, hot content reload — without the sidecar lifecycle plumbing of Mode B. **What's involved.** The embed API does not exist yet. When it ships it will require depending on `zfb-core` or a similar crate and calling an initialization function in the Tauri `setup` hook. The exact surface is still being designed. **This mode does NOT work today.** Follow the research issue for progress: [https://github.com/Takazudo/zudo-front-builder/issues/346](https://github.com/Takazudo/zudo-front-builder/issues/346) **Tradeoff.** Once it ships: no sidecar plumbing, tighter IPC, cleaner packaging. Until it ships: not an option. --- ## The harder case: rebuild content inside the app If you need a user to edit Markdown files locally and see a live preview inside the running app — essentially running `zfb dev` from within a packaged window — the right approach depends on what you need: - **Mode B** (sidecar) is the best path today if you need full MDX rendering. The sidecar keeps the file-watch / incremental-rebuild loop running in the background; the WebView reflects changes without an app restart. - **Mode D** (in-process embed) will be the preferred path once it ships — no child process, no port, tighter integration with Tauri IPC and the filesystem. - **Mode A or C** do not help here: both assume content is static at the point the packaged app runs. If you need Node.js in the runtime — for example, to run custom `zfb` plugins that have Node.js dependencies — consider Electron instead. **Electron** embeds Node.js, so `zfb dev` runs naturally inside the main process; the cost is a significant binary size increase and a more complex packaging story. This is not a zfb mode per se; it is a framework choice driven by the Node.js-at-runtime requirement. --- ## What about Electron, Wails, or other desktop frameworks? The story is the same regardless of which desktop framework you use. - **Electron** — `dist/` works as a static asset directory (use `loadFile()` or a `file://` protocol handler). Running `zfb dev` inside the main process is possible because Electron embeds Node.js, but it means shipping the full zfb build toolchain with your app. - **Wails** — embeds a WebView and serves assets from an embedded Go server. Point it at the `dist/` directory. The build-time Node.js requirement is the same as above. - **Neutralino, Tauri, or any other framework** with a built-in static file server — ship `dist/`, no runtime dependency on Node. The `dist/` output is just files. Any framework that can serve files from disk works. ## Tauri-specific tips A few practical notes for Tauri integrations: **`dist/` as the asset source.** Set `distDir` to point at the `dist/` directory zfb produces. During development, set `devPath` to `http://localhost:4321` (the default `zfb dev` port) so Tauri loads from the live dev server rather than a stale snapshot. **Content Security Policy.** Tauri's default CSP may block inline scripts. zfb's island hydration uses inline `` tags. Relax or extend the CSP policy in `tauri.conf.json` if you use islands in your app. **File paths.** `zfb build` generates absolute-root-relative paths by default (`/assets/main.css`). Tauri's custom protocol rewrites these correctly when you use the `tauri://` protocol. If you use `file://` directly, you may need to set `base` in `zfb.config.ts` to a relative path. ## See also - [Build pipeline](/concepts/build-pipeline) — how `zfb build` produces `dist/` and what each crate contributes to the output. - [Architecture: build engine](/architecture/build-engine) — the crate split and why `dist/` writes are atomic. - [Installation](/getting-started/installation) — the Node.js requirement for build and dev tooling is documented here. --- # Incremental rebuild > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/incremental-rebuild Edit one file, rebuild only the pages that depend on it. This page explains how that guarantee works, what the graph tracks, where the wins are real, and where the current costs live. ## The promise When you save `content/blog/post-3.md` during `zfb dev`, zfb does not rebuild every page on your site. It asks the dependency graph which pages imported that file, and re-renders only those — typically one or two pages in a content-heavy blog. A concrete example: a 500-page site with a blog index (`pages/blog/index.tsx`) and per-post pages (`pages/blog/[slug].tsx`) consuming a `blog` collection. Saving `content/blog/post-3.md` dirties `pages/blog/index.tsx` (lists all posts) and the single post page that renders that slug — two pages, not five hundred. The graph makes this precise. It is not a heuristic or a cache invalidation timeout — each page is rebuilt if and only if a recorded dependency changed. ## What zfb-graph tracks `crates/zfb-graph` maintains two layers of dependency information. **Source-level deps (per-page):** Each page is assigned a `PageId` (its source `.tsx` path). The graph records every file that page depends on, tagged with a `DepKind`: | Kind | Examples | |---|---| | `Module` | TSX/TS layouts, components, lib files | | `Content` | Markdown/MDX entries via content collections | | `Style` | CSS sources, CSS modules | | `Data` | JSON/TOML/YAML data modules | | `Asset` | Static files under `public/` | The reverse index maps every dependency path back to the set of pages that consume it. This is the index that `dirty_pages()` queries. **Asset-level deps (per-page):** Alongside the source graph, each page also carries an `AssetDeps` record: - `islands` — the stable component identifiers of every `"use client"` island the page hydrates. - `css_modules` — the CSS-Modules source paths the page imports. This second layer answers a different question: not "which pages re-render?" but "which asset bundles re-emit?" A change to a `"use client"` component tells the graph which page-scoped islands bundles to invalidate without re-rendering every page that ships JS. The graph updates both layers after every successful render, so the next query always has a current picture. ## DirtySet When a file changes, the build orchestrator calls `DependencyGraph::dirty_pages(path)` (or the batch variant `dirty_pages_batch` for bursts of changes). The return type is `DirtySet`: ```rust pub enum DirtySet { /// Rebuild every page. Triggered by global files like zfb.config.ts. All, /// Rebuild exactly this set of pages. May be empty. Specific(BTreeSet), } ``` **`DirtySet::Specific`** is the common case. The graph performs a reverse-index lookup in O(consumers) time and returns only the pages that imported the changed path. **`DirtySet::All`** is the nuclear option. Files registered via `DependencyGraph::mark_global()` always return `All` regardless of recorded edges. `zfb.config.ts` is globally registered by default. Other candidates include top-level `_app.tsx` or `_document.tsx` — anything whose change semantically affects every page. Unknown paths (a file the graph has never seen as a dependency) return an empty `DirtySet::Specific`. Nothing to rebuild, nothing happens. ## What the dev pipeline does with the dirty set The orchestrator (`crates/zfb-build`) receives the `DirtySet` and feeds it into the dev pipeline (`DevAssetPipeline`): 1. **Re-render dirty pages only.** The pipeline calls the renderer for the pages in `DirtySet::Specific`. Pages not in the set are untouched. 2. **Skip writes when HTML is byte-identical.** After rendering, each page's new HTML is compared to the last-known bytes. If the bytes match, the file is not written and the page is not included in the reload signal. A pure refactor that produces no semantic HTML change produces no browser reload. 3. **Re-bundle islands only when consuming pages changed.** The islands sub-pipeline runs only when the plan's `rerun_islands` flag is set — which the orchestrator sets only when a changed file is inside an islands root (e.g. `components/`). A content-only change does not trigger an islands re-bundle. 4. **Keep filenames stable.** Dev-mode output files use stable names (no content hashes). The browser cache contract does not change between watcher ticks. Content hashing is the production pipeline's job. The combined effect: a content edit that touches two pages produces two HTML writes, one live-reload event, and no island bundling. A header component edit produces writes for every page that imports the header, but still only those pages. For the full breakdown of SSE event types (`Page`, `Css`, `Islands`) and how the browser reacts to each, see [Dev mode lifecycle](/concepts/dev-mode-lifecycle). For a deeper look at the orchestrator design, see [Build engine](/architecture/build-engine). ## Comparison to Astro's dev model Astro uses Vite under the hood. Vite's hot module replacement (HMR) invalidates modules, walks the module import graph to find affected ESM boundaries, and pushes updates to the browser over a WebSocket. The centralized Content Layer caches parsed content in a SQLite store and reloads on collection changes. zfb's model differs in granularity level: - **Astro's unit of invalidation is an ES module.** A change invalidates the module and the bundler decides which chunks to re-emit based on the module graph. - **zfb's unit of invalidation is a page.** The dependency graph is keyed on rendered output, not on modules, so a shared component change dirties exactly the pages that produce HTML — not the full import closure. For a content-heavy site the distinction matters: editing one MDX file in Astro can trigger a Content Layer reload that re-parses every collection entry before the page re-renders. zfb routes the change through the graph, finds the two pages that consume that entry, and renders only those without touching the snapshot for other entries. For the broader architecture picture, see [Architecture overview](/concepts/architecture-overview). ## Honest current bottlenecks The dependency graph is not the bottleneck. Even with a perfect dirty set (one page to rebuild), two costs are paid on every dev-mode rebuild: **1. Per-rebuild engine boot.** zfb renders pages by loading a worker bundle into the embedded V8 host. Each rebuild reloads the renderer — the V8 isolate spins up, evaluates the worker bundle, and then tears down when the dirty pages are rendered. This overhead is constant regardless of how many pages are dirty. The graph saves you from rendering 500 pages, but it cannot save you from the isolate boot cost that happens before any page renders. This is not a graph problem. It is a separate optimization axis — keeping the isolate warm between ticks, or switching to a persistent renderer host — that is on the roadmap but not implemented today. **2. Worker bundle rebuild on content changes.** The `ContentSnapshot` (all content collection entries, serialized to JSON) is embedded directly into the worker bundle that the V8 host loads. A content change today triggers a full bundle rebuild, even if only one entry changed. Bundle rebuild time is O(snapshot size), not O(changed entries). For the project sizes zfb targets — hundreds of MDX files, typical documentation sites — this fits comfortably. Very large content sets will push rebuild time up linearly. The planned mitigations (snapshot patching so only the changed entry is re-serialized, per-collection sharding so each bundle covers one collection) are tracked as future roadmap but are not implemented today. To measure the snapshot footprint of a build, set `ZFB_DEBUG_SNAPSHOT=1`: ```sh ZFB_DEBUG_SNAPSHOT=1 pnpm exec zfb build ``` This prints a line to stderr like: ``` content snapshot: 187 entries / 412 KB ``` See the [README's snapshot section](https://github.com/Takazudo/zudo-front-builder#limits) for the full measurement guide and the planned sharding work. ## Scaling sweet spot The table below gives a rough feel for where the current architecture is comfortable and where it hurts. Numbers are order-of-magnitude estimates — actual times depend on hardware, content entry sizes, and component depth. | Site size | Cold-start boot | Graph lookup | Pages re-rendered | Overall feel | |---|---|---|---|---| | ~100 pages, ~100 content entries | Fast | Instant | 1–3 | Instant save-to-reload | | ~1 000 pages, ~1 000 entries | Fast | Instant | 1–5 | Snappy; snapshot size still small | | ~10 000 pages, ~5 000 entries | Fast | Instant | 1–10 | Noticeable; snapshot rebuild is the cost | | ~100 000+ pages | Fast | Instant | 1–many | Linear pain; consider split builds or per-collection sharding | The graph lookup and page-render columns do not change much with site size — that is the win. The snapshot rebuild column grows with content set size and is the current ceiling. Engine boot is constant overhead that matters most when the dirty set is tiny (one page to render, but still paying the same boot cost). For sites above the ~10 k line, the advice today is: monitor snapshot size with `ZFB_DEBUG_SNAPSHOT`, keep content entries short, and plan for per-collection sharding when the roadmap delivers it. --- # Embed as library > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/embed-as-library The `zfb-server` crate ships a small builder API so a Rust host can run zfb's HTTP server in-process — no child sidecar, no extra binary. The same crate that powers `zfb dev` powers your embedded server; the only difference is who owns the lifecycle. This guide covers the public builder shape, the request-extension injection point, and the host-handler seam (`with_ssr_handler`). ## When to reach for this Pick the embed-as-library path when the host needs to: - run zfb's full route table inside a desktop or CLI process, - attach per-process context (an `AppHandle`, an FS capability set, an auth token) that handlers should read on every request, - short-circuit specific URL patterns with a Rust-owned response (a markdown lookup, a database read, a sidecar API bridge) without round-tripping through any JS runtime. See [Desktop Deployment](/guides/desktop-deployment) for the composition matrix that decides between this mode and shipping a child sidecar. ## Builder shape The public surface is small. The whole `Server` + `ServerBuilder` types together expose ≤ 10 methods. ```rust use zfb_server::{Server, ServerHandle, ServerMode, RouteParams}; use axum::http::{Request, StatusCode}; use axum::body::Body; let server = Server::builder() .config_path("./zfb.config.json") .mode(ServerMode::Embed) .bind("127.0.0.1:0".parse()?) .with_request_extension(host_ctx.clone()) .with_ssr_handler( "/api/echo/:msg", |req: Request, params: RouteParams| async move { let msg = params.get("msg").unwrap_or("(none)"); let ctx = req.extensions().get::().cloned(); (StatusCode::OK, format!("ctx={ctx:?} msg={msg}")) }, ) .build()?; let handle: ServerHandle = server.serve_in_thread()?; println!("listening on {}", handle.addr()); // later: handle.shutdown()?; ``` Key choices the builder commits to: - **`ServerMode::Embed`** turns off the live-reload script injection and the `/__zfb/reload` SSE endpoint. The route table is otherwise identical to `zfb dev`. - **`bind("127.0.0.1:0")`** asks the OS for an ephemeral port; read the actual port back from `ServerHandle::addr()`. - **`serve_in_thread()`** spawns a dedicated OS thread with its own `current_thread` tokio runtime, so the host does not need a tokio runtime of its own (Tauri's synchronous `setup` callback works without changes). - **`ServerHandle::shutdown()`** is idempotent — calling it twice is a no-op. The async terminal `Server::serve(self, shutdown).await` is also available for hosts that already drive their own tokio runtime. ## Request-extension injection `ServerBuilder::with_request_extension::(value)` registers a per-process value to be cloned into every incoming request's `http::Extensions` map. The handler reads it via `req.extensions().get::()`: ```rust #[derive(Clone)] struct HostCtx { /* … */ } let server = Server::builder() .config_path("./zfb.config.json") .mode(ServerMode::Embed) .with_request_extension(host_ctx) .with_ssr_handler("/whoami", |req: Request, _params| async move { let ctx = req.extensions().get::().cloned(); format!("{ctx:?}") }) .build()?; ``` The bound `T: Clone + Send + Sync + 'static` is the minimum any per-request context type needs. The handler never has to import `axum::Extension` — that extractor type is deliberately kept off the public surface of `zfb-server`. Calling `with_request_extension` multiple times with different `T`s accumulates values; calling twice with the same `T` overwrites the first (this matches `http::Extensions::insert` semantics). ## Handler signature `with_ssr_handler` takes a URL pattern and an async function with the shape: ```rust async fn(http::Request, zfb_server::RouteParams) -> impl IntoResponse ``` That is the whole contract. The handler: - Receives the inbound request as a plain `http::Request` — query string, headers, body, and extensions all accessible on the request itself. - Receives the captured route parameters as a `RouteParams` value with `params.get("name")` lookups. - Returns anything that implements `axum::response::IntoResponse` — a `String`, a tuple `(StatusCode, String)`, a full `http::Response<…>`, or a custom type. The signature is HTTP-shaped on purpose: it does not mention what the handler is for. The same primitive can back a markdown lookup, a database read, an IPC bridge, or any other request-time response — the server only sees bytes in and bytes out. ### Route-pattern grammar Patterns are leading-slash paths. Each segment is one of: | Form | Matches | Captured under | | --- | --- | --- | | `foo` | the literal segment `foo` | — | | `:name` | exactly one non-empty segment | `params.get("name")` | | `*name` | one or more remaining segments joined with `/` | `params.get("name")` | A `*name` wildcard must be the final segment of the pattern. Empty captures (a bare `/` against a `:name` slot) are rejected. Examples: - `/health` matches only `/health`. - `/users/:id` matches `/users/42` with `id = "42"`. - `/files/*rest` matches `/files/a/b.txt` with `rest = "a/b.txt"`. - `/projects/:proj/refs/*rest` mixes both. ## Precedence: host handlers win over runtime SSR This is the load-bearing rule of the embed seam. When the dev router receives a request, it tries layers in this order: 1. **plugin dev-middleware** (longest-prefix match) — when a registered plugin claims the path, 2. **host-registered Rust handler** (this builder method) — when one of the patterns above matches, 3. **request-time SSR** — when an `SsrRouteSet` claims the path, 4. in-memory page cache (SSG output), 5. on-disk fallback to `dist/`, 6. on-disk fallback to `public/`, 7. dev 404. The host handler is **always preferred over a runtime-SSR page that claims the same URL**. If your project has both a Rust handler at `/dynamic` and a `prerender = false` page that would also serve `/dynamic`, the Rust handler wins and the SSR dispatcher is never invoked. Why this direction: - The host is the trusted owner of the process. If it registers a handler for a path, that is a deliberate override. - A handler that needs to fall through can choose not to register; precedence is decided at registration time, not by the handler returning a sentinel. - The reverse order would force every host that wants to override a runtime page to gate the page export behind a build-time flag — coupling two concerns that the embed seam intentionally separates. ## Lifecycle and shutdown `Server::serve_in_thread()` returns a `ServerHandle`. The handle is `Clone` so the host can hand copies to multiple shutdown call-sites (Tauri's `on_window_event`, a Ctrl-C handler, an HTTP `/admin/stop` route). All clones share the same one-shot sender and join handle behind `Arc>`: ```rust let handle = server.serve_in_thread()?; let h2 = handle.clone(); ctrl_c_callback(move || { let _ = h2.shutdown(); // idempotent }); // On the main thread, wait for the server to exit: handle.join()??; ``` `shutdown()` sends the graceful-shutdown signal once; subsequent calls are no-ops. `join()` is single-shot — only one caller can wait for the thread. ## Method budget The whole embed surface is intentionally small. The `Server` and `ServerBuilder` types combined expose nine methods: `Server::builder`, `Server::serve`, `Server::serve_in_thread`, plus six builder methods (`config_path`, `mode`, `bind`, `with_request_extension`, `with_ssr_handler`, `build`). `ServerHandle::addr`, `ServerHandle::shutdown`, and `ServerHandle::join` are deliberately on a separate type and are excluded from the budget. If a future feature needs to exceed ten total, the right move is a follow-up issue that re-shapes the seam — not a tenth method tacked on. ## Example crate A minimal end-to-end example lives at `crates/zfb-server/examples/embed/`. Build it from the workspace root: ```sh cargo build --manifest-path crates/zfb-server/examples/embed/Cargo.toml ``` Or run it (the example boots a server, issues one HTTP request to demonstrate the handler answers with the injected context, and shuts down): ```sh cargo run --manifest-path crates/zfb-server/examples/embed/Cargo.toml ``` The example crate uses an empty `[workspace]` table to opt itself out of the parent workspace, so the root manifest doesn't have to list it as a member. --- # SSR and Cloudflare Bindings > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/guides/ssr-and-cloudflare-bindings How to opt a route out of build-time static rendering, deploy it as a Cloudflare Pages Worker with `@takazudo/zfb-adapter-cloudflare`, and read Cloudflare Worker bindings — secrets, environment variables, and a **D1 database** — from inside the route's SSR handler. There are two distinct concepts both called "Worker" in a zfb + Cloudflare project. Blurring them is the most common source of confusion. **zfb's emitted `dist/_worker.js`** — produced by the Cloudflare adapter for every route that exports `prerender = false`. It runs inside the same TSX pipeline as static pages: shared layouts, components, and MDX virtual modules all work exactly as they do for SSG routes. **External standalone Workers** — your own wrangler-built Worker bundles deployed separately (e.g. an auth Worker, a photo-upload Worker, a payment-webhook Worker). zfb has no awareness of them. The seam between zfb and an external Worker is always HTTP/JSON: either a `pages/api/*.tsx` proxy route that `fetch()`'s the external Worker, or a `prerender = false` page that calls `fetch()` directly. Why this matters: AI agents and human readers often try to import shared layout TSX directly into an external Worker. That doesn't work — an external Worker has a different bundler, a different runtime, and no access to zfb's virtual-module layer. If you find yourself trying to `import` a layout into a wrangler project, you are crossing the wrong boundary. For the conceptual mental model of how the emitted worker actually runs, see [SSR on a Worker (adapter mode)](/concepts/ssr-on-a-worker). ## SSG vs SSR in zfb By default every page in zfb is rendered **once at build time** into static HTML (SSG). That is the right default for content sites — it is fast, cacheable, and needs no server. A route that must run **per request** — reading a database, checking a session cookie, handling a `POST` — opts out of SSG with a single export: ```tsx // pages/api/products.tsx ``` `prerender = false` tells `zfb build` to skip this page during static rendering and instead include it in the **SSR bundle** that is handed to your configured adapter. If a route exports `prerender = false` but no adapter is configured, the build fails fast with an error naming the offending route — zfb will not silently drop a route it cannot deploy. ## dev-prod parity for `prerender = false` `zfb dev` runs `prerender = false` routes through the **same render code** Cloudflare runs in production. The dev server hosts an embedded V8 isolate (the same one that drives build-time SSG), and the dev router dispatches a `prerender = false` URL into that isolate at request time — not at build time, not from a static snapshot. The parity guarantee is **semantic, not byte-for-byte**: status code, response body, and `Content-Type` match between dev and the deployed Cloudflare adapter. Values that legitimately vary across runs (timestamps stamped into a response, randomly generated request IDs) are allowed to differ. What this means in practice: - A page that returns different HTML based on `?id=…` query parameters renders the right HTML on every dev page reload — no stale snapshot from the last build. - An SSR handler that throws shows you the V8 stack trace inline in the browser at dev time, instead of failing only after `zfb build` + deploy. - Plugin dev-middleware still claims its registered URL first (plugin routes can override SSR for things like dev-only mock responses); the SSR layer sits between plugin middleware and the static page cache. One intentional limitation of the dev-side SSR path today: **No live reload of the SSR bundle.** Editing a `prerender = false` page's source during a `zfb dev` session does not re-evaluate the bundle inside the running V8 host. Restart `zfb dev` to pick up the new code. (Live reload of the static-HTML cache works unchanged — only the SSR bundle path is restart-only.) This limitation does not apply to the **production** Cloudflare adapter — which is what the dev server is mirroring. The point of dev parity is that the rendered output matches, not that every dev feature is the final shipping shape. zfb detects `prerender` via **static AST inspection at build time**, not runtime evaluation. The export must be a **literal `export const`** declaration: ```tsx ``` These forms are **not** detected and silently fall back to SSG: ```tsx // ❌ indirect assignment — not a literal export const const flags = { prerender: false }; // ❌ function call — not a literal export const ``` The same restriction applies to the `frontmatter` export — see [Frontmatter](/concepts/frontmatter) for the literal-only contract. ## Configuring the Cloudflare adapter Install the adapter and name it in `zfb.config.json`: ```sh pnpm add -D @takazudo/zfb-adapter-cloudflare ``` ```jsonc { "framework": "preact", "adapter": "@takazudo/zfb-adapter-cloudflare" } ``` `zfb build` then produces, under `dist/`: - the static HTML for every SSG page, and - `_worker.js` + `_zfb_inner.mjs` — a Cloudflare Pages **advanced-mode** Worker entry that serves your `prerender = false` routes. Deploy `dist/` to Cloudflare Pages as usual. The Worker handles dynamic routes; the static asset server handles everything else. The adapter threads the per-request `(env, ctx, request)` context through an `AsyncLocalStorage` (from `node:async_hooks`) that `getCloudflareContext()` reads from. Workerd does not expose `node:async_hooks` by default — you must opt in via `wrangler.toml`: ```toml # wrangler.toml compatibility_flags = ["nodejs_compat"] ``` Without this flag the Worker fails to boot with an error naming `node:async_hooks` as the missing module. See [SSR on a Worker (adapter mode)](/concepts/ssr-on-a-worker#the-getcloudflarecontext-trick) for the deeper mechanism. ## Reading the Worker `env` from an SSR handler A Cloudflare Worker's `fetch` handler receives `(request, env, ctx)`. The adapter threads `env` and `ctx` to your page through a per-request [`AsyncLocalStorage`][als] scope, so an SSR route reads them with `getCloudflareContext()`: ```tsx // pages/api/whoami.tsx interface Env { ANTHROPIC_API_KEY: string; } const { env, ctx } = getCloudflareContext(); ctx.waitUntil(reportToAnalytics()); // fire-and-forget background work return new Response(env.ANTHROPIC_API_KEY ? "ok" : "missing key"); } ``` The `Env` generic narrows the bindings shape so TypeScript catches a typo like `env.ANTRHOPIC_KEY`. `getCloudflareContext()` throws if called outside a Worker request scope — for example during build-time SSG. That is by design: a route that needs bindings **must** export `prerender = false`. If you want a route to work in both modes, catch the error and branch on it. ## Reading a D1 database (`env.DB`) [D1][d1] is Cloudflare's serverless SQLite. A D1 binding is exposed on `env` exactly like any other binding — the adapter does not treat it specially. Declare the binding's TypeScript shape and query it: ```tsx // pages/api/products.tsx interface Env { // `D1Database` comes from `@cloudflare/workers-types`. Install it as // a devDependency if you want the full typed surface; otherwise a // minimal structural shape like the one below works too. DB: D1Database; } const { env } = getCloudflareContext(); // Always use `.bind(...)` for user input — D1 prepared statements // are parameterised, which prevents SQL injection. const { results } = await env.DB .prepare("SELECT id, name, price_cents FROM products ORDER BY id") .all(); return new Response(JSON.stringify({ products: results }), { status: 200, headers: { "content-type": "application/json" }, }); } ``` Single-row reads use `.first()`: ```tsx const product = await env.DB .prepare("SELECT * FROM products WHERE id = ?") .bind(productId) .first(); ``` Writes (`INSERT`/`UPDATE`/`DELETE`) use `.run()`: ```tsx await env.DB .prepare("INSERT INTO orders (user_id, total_cents) VALUES (?, ?)") .bind(userId, totalCents) .run(); ``` ## Wiring up the D1 binding D1 is bound to your Pages project through `wrangler.toml`. The binding **name** (`DB` below) is the property you read on `env`: ```toml # wrangler.toml [[d1_databases]] binding = "DB" # → env.DB inside the Worker database_name = "webshop" database_id = "" # printed by `wrangler d1 create` ``` The lifecycle, end to end: 1. **Create the database** — `wrangler d1 create webshop`. This prints the `database_id`; paste it into `wrangler.toml`. 2. **Write migrations** — put `.sql` files under `migrations/` (the wrangler default). Each migration is plain SQL — `CREATE TABLE`, etc. 3. **Apply migrations** — `wrangler d1 migrations apply webshop` (add `--local` for the local dev database, `--remote` for the deployed one). 4. **Deploy** — `zfb build` then deploy `dist/` to Cloudflare Pages. For a **preview vs production** split, declare the binding under a named environment so each gets its own database: ```toml [[d1_databases]] binding = "DB" database_name = "webshop" database_id = "" [[env.preview.d1_databases]] binding = "DB" database_name = "webshop-preview" database_id = "" ``` ## Local development `wrangler pages dev dist/` runs the built `_worker.js` locally with a **local** D1 database (a SQLite file under `.wrangler/`). Apply your migrations to it with `wrangler d1 migrations apply webshop --local` before the first run. [als]: https://nodejs.org/api/async_context.html [d1]: https://developers.cloudflare.com/d1/ --- # Dev mode lifecycle > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/dev-mode-lifecycle The save-to-pixel loop for `zfb dev`: how the watcher detects your change, how the orchestrator decides what to rebuild, and how three SSE event types let the browser react without an unnecessary full page reload. For the overall build step order read [Build pipeline](/concepts/build-pipeline); for how the dependency graph limits rebuilds to affected pages read [Incremental rebuild](/concepts/incremental-rebuild). This page describes the **SSG / static-HTML dev loop only**. For `prerender = false` SSR routes, source edits during a `zfb dev` session do **not** hot-reload — the V8 SSR bundle is bound to the boot-time JS bundle and is restart-only. The per-tick rebuild described below does **not** reload the SSR renderer; it re-renders the SSG output that the renderer was bootstrapped with. See [No live reload of the SSR bundle](/guides/ssr-and-cloudflare-bindings#dev-prod-parity-for-prerender--false) in the SSR guide. ## You save a `.tsx` file. Then what? The moment you save, the operating system fires a filesystem event on the file's path. `crates/zfb-watcher` is watching every relevant directory — `pages/`, `components/`, `content/`, `layouts/`, `styles/`, `data/`, `public/`, and the two config files `zfb.config.json` and `zfb.config.ts` — via the [`notify`](https://crates.io/crates/notify) crate. (See `DEFAULT_WATCH_ROOTS` in `crates/zfb/src/commands/dev.rs` for the canonical list.) Editor saves are rarely a single clean event. vim renames a swap file over the original; VS Code emits several metadata-then-data events in quick succession; a `git checkout` fires hundreds at once. The watcher's debouncer coalesces everything within a 50ms quiet window into a single `Change { path, kind }` value. The `kind` field is one of `ChangeKind::Created`, `ChangeKind::Modified`, or `ChangeKind::Removed`; any event kind the OS cannot classify collapses to `Modified` so that a real change is never silently dropped. Once the burst settles and the debounce window closes, the build orchestrator (`crates/zfb-build`) receives the change. It calls into `crates/zfb-graph` with the changed path and gets back a `DirtySet` — either `All` (global files like `zfb.config.ts`) or `Specific(set_of_page_ids)` for the pages that actually import the changed file. Pages outside the dirty set are not touched. (The full dependency tracking story lives in [Incremental rebuild](/concepts/incremental-rebuild).) The orchestrator assembles a `RebuildPlan` from the dirty set. If the changed path is inside an islands root (e.g. `components/`), the plan's `rerun_islands` flag is set. If a CSS source changed, `rerun_css` is set. Then the `DevAssetPipeline::apply()` method runs, in this order: 1. **Pages re-rendered.** The orchestrator calls the renderer for each page in the dirty set, driving SSG output through the embedded V8 host that was booted when `zfb dev` started. Each rendered `RenderedPage.html` is compared byte-for-byte against the last-known output. If the bytes are identical (a pure refactor that produced no semantic HTML change), no file is written and no reload signal is sent for that page. The V8 host itself is **not** reloaded per tick — `BuildContext::reload_renderer` is `None` in dev today (see `crates/zfb/src/commands/dev.rs`), so the renderer stays bound to the boot-time bundle for the whole session. 2. **CSS pipeline.** When `rerun_css` is true, Tailwind v4 + PostCSS run. If the CSS output is byte-identical to the previous tick, no `Css` event is emitted. 3. **Islands re-bundle.** When `rerun_islands` is true, the esbuild Go binary subprocess is invoked. It bundles every `"use client"` component and writes a single combined module to a **stable filename** — `dist/assets/islands.js` (the `STABLE_ISLANDS_FILENAME` constant in `crates/zfb-types/src/asset_urls.rs`, re-exported and consumed by the islands bundler). No content hash in the filename; see [Why filenames stay stable in dev](#why-filenames-stay-stable-in-dev). 4. **Build outcome broadcast.** The pipeline returns a `BuildOutcome` struct. `outcome_to_events()` in `crates/zfb-server/src/livereload.rs` inspects the outcome and maps it to `ReloadEvent` values that are broadcast over the SSE channel at `/__zfb/reload`. Every browser tab that has your site open is subscribed to that channel and reacts immediately. ## The three SSE event types | Outcome trigger | Event | Browser behaviour | |---|---|---| | `pages_written.len() > 0` | `Page` | Full `location.reload()` | | `css_changed` | `Css` | Hot-swap every `` — appends `?v=` to bust the browser cache without reloading the document | | `islands_bundle.is_some()` | `Islands { component, bundle_url }` | Dynamic `import()` of the new bundle URL (with a cache-busting `?v=`). The newly-imported module runs its hydration, which by default re-mounts **every** `[data-zfb-island]` element on the current page — no document reload | When multiple events fire in the same tick, the server emits every applicable event, and **every connected tab is subscribed to the same SSE channel and receives all of them.** The browser processes events in arrival order. On receiving a `Page` event, each tab calls `location.reload()`, which discards the current document — this makes any `Css` or `Islands` events emitted in the same tick moot for that tab. The "only this tab gets a full reload" pattern does not exist here; **the in-place `Css` and `Islands` swaps are moot for every subscribed tab in that tick**, not just the active one. One detail on `Islands`: in dev mode today, `BuildContext::run_islands` reports `components: Vec::new()` to `outcome_to_events()` because the build-side payload does not currently surface per-island names. The server therefore emits a **single** `Islands` event with `component: ""` and the stable bundle URL (`/assets/islands.js`). The client script at `/__zfb/livereload.js` reads only `bundleUrl`, appends a fresh timestamp (`?v=`), and dynamic-imports the result. The imported bundle's top-level hydration code then runs and walks every `[data-zfb-island]` on the page — so a single islands tick re-hydrates the whole page, not a single component, by default. **Targeted re-hydration is opt-in.** When the client detects a user-provided `window.__zfbIslandsReload(component, swapUrl)` function, it delegates the want to preserve scroll position or component state across an islands hot-swap install this hook and decide for themselves which components to remount. Without the hook, the default page-wide re-hydration is what runs. ## Why filenames stay stable in dev Production builds use content-hashed asset URLs (`/assets/islands-abc12345.js`) so that deployed CDN responses can be cached indefinitely and a new deploy's changed assets get fresh URLs. The hash is determined by file content; it changes every time the bundle changes. Dev mode deliberately skips the content hash. The output lands at `dist/assets/islands.js` — the same URL every tick. This is the URL-contract guarantee that makes SSE-driven hot-swap work: when the browser receives an `Islands` event, it knows the new bundle is always reachable at the same base URL. Changing the URL on every rebuild would force a full page reload because the browser would have no cached reference to swap from. Content hashing is the production pipeline's responsibility. In dev the stable name is correct by design, not an oversight. ## What this means in practice Three typical edit scenarios, and which event fires: **Edit a `.tsx` island component body only.** The watcher fires on the component file. The dependency graph marks pages that consume it dirty; the orchestrator re-renders affected pages first, then re-bundles islands. If the rendered HTML changed, a `Page` event fires and the browser reloads. If the HTML is byte-identical (e.g. you only changed client-side state logic that never reaches the server render), only an `Islands` event fires — the new bundle is dynamic-imported and its hydration runs, re-mounting every island on the page. To preserve scroll position or per-component state across that swap, install a `window.__zfbIslandsReload` hook (see [The three SSE event types](#the-three-sse-event-types)). **Edit a page file that consumes an island.** Both page and island are dirty. The orchestrator re-renders the page; the HTML almost certainly changed; a `Page` event fires. The browser gets a fresh full-page load with up-to-date server-rendered HTML. Any concurrent `Islands` events for that tab are mooted by the reload. **Edit a CSS file only.** No pages are dirty, no islands re-bundle. The CSS pipeline runs; if the output changed, a `Css` event fires. The browser swaps the stylesheet in place — no document reload, your scroll position and any client-side state are preserved. ## Related - [Build pipeline](/concepts/build-pipeline) — the full pipeline from CLI to `dist/`, and how dev mode layers on top of it - [Incremental rebuild](/concepts/incremental-rebuild) — the dependency graph and `DirtySet` that limit rebuilds to affected pages - [Islands](/concepts/islands) — how `"use client"` opts a component into the islands bundle - [SSR and Cloudflare Bindings](/guides/ssr-and-cloudflare-bindings) — including the restart-only limitation for `prerender = false` routes in dev --- # SSR on a Worker (adapter mode) > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/ssr-on-a-worker The mental model of what actually runs in production for a `prerender = false` zfb page: the two-layer worker output, how requests are dispatched to the right handler, and how `getCloudflareContext()` delivers Cloudflare bindings to any page component without prop-drilling. This page is the mental model. For hands-on setup — installing the adapter, wiring up `wrangler.toml`, querying D1, and configuring `compatibility_flags` — read [SSR and Cloudflare Bindings](/guides/ssr-and-cloudflare-bindings). This page is also specifically about zfb's emitted `dist/_worker.js`, not external Workers you deploy separately with wrangler. ## Your page is just a function Strip away the framework vocabulary and a `prerender = false` zfb page is a plain `async function` that returns a `Response`. In the snippet below, `renderToString`, `getUser`, `updateProfile`, and `AccountLayout` are placeholder identifiers representing app-side code — they are not exports of zfb or `@takazudo/zfb-adapter-cloudflare`. The only framework-provided symbol the example uses is `getCloudflareContext`. ```tsx // pages/account.tsx interface Env { DB: D1Database; SESSION_SECRET: string; } const { env, request } = getCloudflareContext(); if (request.method === "POST") { const form = await request.formData(); await updateProfile(env, form); return new Response(null, { status: 303, headers: { location: "/account" } }); } const user = await getUser(env, request); return new Response( renderToString(), { headers: { "content-type": "text/html" } }, ); } ``` The function receives no arguments. Cloudflare's `(request, env, ctx)` tuple is available through `getCloudflareContext()` anywhere in the call tree — layouts, helpers, lib modules. A `POST` from a `` on the same page reaches the same handler; branching on `request.method` is the mutation path. Nothing special happens here that does not happen in a hand-written Worker. The rest of this page explains how `getCloudflareContext()` actually works, and what the build emits to make it possible. ## What the build actually emits Running `zfb build` with `@takazudo/zfb-adapter-cloudflare` configured produces two files under `dist/`: **`dist/_worker.js`** — a small auto-generated stub written by the adapter. This is the Cloudflare Pages advanced-mode entry: the file Cloudflare loads when a request arrives. Its job is to set up an `AsyncLocalStorage` scope for `(env, ctx, request)` and then dispatch every request — either to the static asset server or to the inner bundle. It does not contain your application code. **`dist/_zfb_inner.mjs`** — the real application bundle: every `pages/*.tsx` file, layouts, components, and lib helpers compiled into a single Hono-shaped ESM module. This is what actually renders your pages. The two-file layout avoids a second esbuild pass inside the adapter. Workerd's module loader resolves relative ESM imports inside an advanced-mode `_worker.js` directory, so `_worker.js` can simply `import inner from "./_zfb_inner.mjs"` and both files stay independent. For the broader two-bundle mental model (worker bundle vs island bundles), read [Architecture overview](/concepts/architecture-overview). ## The dispatch flow Every request to your Cloudflare Pages site hits `_worker.js` first. The stub applies a static-first, dynamic-second rule: | Request method | Dispatch order | |---|---| | GET, HEAD | Probe `env.ASSETS` first. The inner worker is invoked only if the asset server returns 404. Any other status — 200, 308, etc. — is returned to the client directly. | | POST, PUT, PATCH, DELETE | Skip `env.ASSETS` entirely. Go straight to the inner worker. | The fall-through rule is "404 only", not "non-200 only". This matters because Cloudflare Pages' asset server emits 308 redirects to canonicalise trailing slashes for prerendered routes (e.g. `/docs/account` → 308 → `/docs/account/` → `dist/docs/account/index.html`), and the wrapper has to let those 308s reach the client so the browser can follow them to the index.html. Inspecting `worker-wrapper.mjs`: the wrapper calls `await env.ASSETS.fetch(request)` and returns the response untouched whenever `assetResponse.status !== 404`. **Why static-first for GET/HEAD:** the asset server is responsible for the trailing-slash canonicalisation described above, and it also serves the build-time head injection (``, `.js">`) that makes island hydration work. If the inner Hono router handled prerendered routes first, it would re-render them at request time without those injected assets, and islands would not hydrate. **Why POST/PUT/PATCH/DELETE go straight through:** The asset server is read-only by definition. Probing it for a form submission would always return 404 or 405 — an unnecessary round-trip with no benefit. Form submissions, JSON API calls, and any mutation go directly to the inner worker. The result: static pages are not paying the SSR cost. A `prerender = true` page is served entirely by `env.ASSETS`, and the inner worker's `fetch` handler is not invoked. (The inner bundle is still loaded and evaluated on worker boot because `_worker.js` imports it at module scope — see [What is NOT in the worker output](#what-is-not-in-the-worker-output) for the precise wording.) ## The `getCloudflareContext()` trick Cloudflare Workers can dispatch multiple requests concurrently inside the same V8 isolate. A naïve `globalThis.__env = env` write would race across those concurrent requests. The adapter solves this with `AsyncLocalStorage` from `node:async_hooks`. The mechanism in two steps: **Step 1 — the wrapper stores the context.** Before calling `inner.fetch(request)`, the generated `_worker.js` stub runs: ```js als.run({ env, ctx, request }, () => inner.fetch(request)); ``` This opens a per-request async scope. Every `await` inside that scope — across layouts, helpers, database calls — still sees the same stored value. **Step 2 — user code reads the context.** `getCloudflareContext()` calls `als.getStore()` on the same `AsyncLocalStorage` instance. Because the adapter module registers the storage instance on `globalThis` under a stable key, the wrapper file (`_worker.js`) and the user bundle (`_zfb_inner.mjs`) share the same instance even though they are separate ESM modules. `getStore()` returns the `{ env, ctx, request }` object the wrapper stored for this request, not for any other concurrent request. **Why this means `compatibility_flags = ["nodejs_compat"]` is mandatory.** `AsyncLocalStorage` lives in `node:async_hooks`. Workerd does not expose it by default — you must opt in with: ```toml # wrangler.toml compatibility_flags = ["nodejs_compat"] ``` Without this flag, the Worker fails to boot. The error message names `node:async_hooks` as the missing module. See the [SSR and Cloudflare Bindings guide](/guides/ssr-and-cloudflare-bindings) for the full `wrangler.toml` configuration. **Why AsyncLocalStorage instead of prop-drilling.** Page handlers compose shared layouts and lib helpers freely. Threading `env` as an explicit parameter would force every component and helper to accept it — a leaky coupling between Cloudflare-specific infrastructure and generic UI code. ALS lets the framework boundary stay clean: the adapter owns the storage, user code reads it only where it's needed. The concepts map like this: | Concept | Next.js App Router | Remix | zfb adapter-mode | |---|---|---|---| | File-based routing | `app/account/page.tsx` | `routes/account.tsx` | `pages/account.tsx` | | Server data fetch | `async function Page()` | `loader` | `async function AccountPage()` | | Mutation | Server Actions | `action` | Plain `` | | Layouts | `layout.tsx` | Nested route layouts | `layouts/*.tsx` (imported) | | Static vs dynamic | `dynamic = 'force-static'` / `'force-dynamic'` | (always dynamic) | `export const prerender = true` / `false` | Mechanically a Worker; ergonomically App-Router-style SSR; philosophically Remix-without-the-Remix-runtime. No RSC streaming, no Server Actions abstraction — just one bundle and a Web `fetch` handler. ## What is NOT in the worker output Two categories of output are intentionally absent from `dist/_worker.js` and `dist/_zfb_inner.mjs`: **Islands** — components marked with `"use client"` are compiled by a separate esbuild step into a **single combined** browser-shipped bundle. In dev the bundle lands at `dist/assets/islands.js` (stable filename, no hash); in production `ProductionAssetPipeline` writes it as `dist/assets/islands-.js` and rewrites every reference in the rendered HTML to the hashed URL. The bundle is neither part of `dist/_worker.js` nor of `dist/_zfb_inner.mjs` — it is browser-shipped JavaScript, not server-executed Worker code. The Worker renders the static HTML shell; the browser downloads the islands bundle and its top-level hydration code walks every `[data-zfb-island]` element on the page. See [Islands](/concepts/islands) for the full mechanism. **Prerendered pages** — any page that exports `prerender = true` (or omits the export, which defaults to true) is rendered to static HTML at build time. At request time, Cloudflare Pages serves the `.html` file directly from `env.ASSETS`, so the inner Worker's `fetch` handler is **not invoked** for those routes. The inner bundle is still loaded and evaluated on worker boot, however — `_worker.js` does a static `import inner from "./_zfb_inner.mjs"`, so workerd pulls the inner module graph into memory regardless of which route the first request lands on. The optimisation is "static hits skip `inner.fetch()`", not "static-only deploys skip loading the inner bundle". ## Related - [Architecture overview](/concepts/architecture-overview) — the two-bundle model and how the worker bundle shape is the stable contract between build time and production. - [Islands](/concepts/islands) — how `"use client"` opts a component into the browser-shipped bundle that lives outside the Worker. - [SSR and Cloudflare Bindings](/guides/ssr-and-cloudflare-bindings) — for hands-on setup: `wrangler.toml`, D1 queries, secrets, and local development with `wrangler pages dev`. --- # Plugins > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/concepts/plugins zfb plugins are plain ES modules whose default export is a `ZfbPlugin` object. The zfb build (and dev) host loads each plugin module once at boot and dispatches four optional lifecycle hooks to it. A plugin may declare any subset of the four — anything it omits is a silent no-op. This page documents the contract for plugin authors. The companion API reference is on [`defineConfig.plugins`](/api/define-config). ## When you actually need a plugin Reach for a plugin when you need one of the four capabilities that `setup` provides — the same four the sections below document. If none of these apply, a plain script is the right tool instead (see [When you don't — write a recipe](#when-you-dont--write-a-recipe) below). For the broader engine-vs-script mental model, see [Design Philosophy](/concepts/design-philosophy). - **Virtual module backing a synthetic data source.** If your pages need to `import` from a specifier that has no real file on disk — a metadata DB, a content index, a generated config blob — `addVirtualModule` is the only way to inject that source into the module graph. Example: `import metadata from "virtual:metadata-db"` across every page. - **Alias rewrite that must apply across all bundlers.** `addAlias` registers an exact-match import rewrite that is honored by all three consumers: the embedded V8 host, the main page/layout bundler, and the islands esbuild bundler. A `tsconfig.json` `paths` entry only reaches the type-checker. If the alias has to work at runtime in all three bundlers, it belongs in a plugin. - **Dev-only injected route that lives outside `pages/`.** `injectRoute` registers a TSX/TS file under a URL pattern the dev server recognises — a synthetic page that has no counterpart in the on-disk `pages/` tree, gated on `command === "dev"` so it never escapes into a production build. - **Dev middleware HTTP handler.** `devMiddleware` is for responses that aren't pages at all: a JSON API endpoint, a hot-reload bridge, an upload handler. No JSX pipeline, no page renderer — just a function returning `{ status, headers, body }`. ## The four hooks ```ts name: "my-plugin", setup?(ctx) {}, // #255 — runs once at host boot, before preBuild preBuild?(ctx) {}, // file-generation work before the bundler / renderer postBuild?(ctx) {}, // finalisation after dist/ has been written devMiddleware?(ctx) {},// per-request HTTP handlers in `zfb dev` }); ``` Hooks run sequentially in the order plugins appear in `zfb.config.ts`'s `plugins` array. A throw in any hook aborts the build (or the dev boot) and surfaces the plugin name + the hook that threw in the error banner. ## `setup` — register virtual modules, aliases, and injected routes Runs **once** per `zfb build` and once per `zfb dev` host boot, before `preBuild`. The hook is where a plugin contributes to the module-resolution pipeline (virtual modules + import aliases) and registers any dev-only synthetic routes. After `setup` completes, the registries are frozen for the remainder of the run. ```ts setup({ command, projectRoot, config, options, logger, addAlias, addVirtualModule, injectRoute }) { // `command` is "build" or "dev". Gate dev-only registrations on it. addAlias("@/components/foo", "./src/components/foo.tsx"); addVirtualModule("virtual:my-data", () => `export default ${JSON.stringify(myJson)}`, ); if (command === "dev") { injectRoute("/api/dev/x", "./scripts/dev-x.ts"); } } ``` ### `addAlias(from, to)` — exact-match import rewrites Registers a single import specifier that, when matched **exactly**, resolves to `to`. The path is joined against the project root. ```ts addAlias("@/components/foo", "./src/components/foo.tsx"); ``` After this, `import Foo from "@/components/foo"` resolves to `./src/components/foo.tsx`. Subpath imports do NOT match: `import "@/components/foo/bar"` is not rewritten and surfaces as an unresolved-import error at bundle time. All three consumers (the embedded V8 host that drives SSR and `paths()` evaluation, the main page/layout bundler, and the islands esbuild bundler that produces client-side `"use client"` bundles) honor the same exact-match contract. **Conflict detection.** Two plugins registering the same `from` with different `to` raises `AliasConflict` and aborts the build, naming both offending plugins. Idempotent re-registration (same plugin, same `to`) is allowed. ### `addVirtualModule(specifier, loader)` — synthetic module sources Registers a bare specifier whose source text is produced on-demand by `loader`. The recommended prefix is `virtual:` but it is not enforced — anything that does not collide with a real module specifier works. ```ts addVirtualModule("virtual:metadata-db", () => `export default ${JSON.stringify(buildMetadataIndex())}`, ); ``` `loader` returns the **complete ESM source text** as a string. The bundler / embedded V8 host feeds the returned string in as the module's source verbatim. The loader is invoked **exactly once per build** at the moment the specifier is first imported; subsequent imports of the same specifier reuse the cached source. There is **one** loader contract. There is no alternate "loader returns JSON and zfb wraps it" mode — if you want to expose JSON, do `() => "export default " + JSON.stringify(data)` yourself. **Conflict detection.** Two plugins registering the same `specifier` raises `VirtualModuleConflict` and aborts the build. ### `injectRoute(pattern, entrypoint)` — dev-only synthetic page routes Registers a TSX/TS file under a URL pattern the dev server is aware of, **conceptually** routed through the same page-rendering pipeline as `pages/<...>.tsx`. The pattern follows the `pages/` filename grammar (`/blog/[slug]`, `/api/dev/x`, `/docs/[...rest]`); `entrypoint` is resolved relative to the project root. ```ts injectRoute("/api/dev/x", "./scripts/dev-x.ts"); ``` The registry, conflict detection, dev-only guard, and pattern-matching plumbing are all shipping in v1. **Full evaluation of the matched entrypoint through the page renderer is a follow-up issue.** Today the dev server logs a structured match on the `zfb_plugin` tracing target when the URL matches an injected pattern, so you can confirm the registration landed end-to-end — but the request then falls through to the existing dist / public fallbacks (and 404s if no other file claims the URL). The renderer-side wiring will land without changing the public API documented here. **Dev-only.** Calling `injectRoute` when `command === "build"` raises `InjectRouteInBuildMode` and aborts the build. Static builds should not surface SSR-shaped routes; if you need dev-only HTML on a real URL, gate the call with `if (command === "dev")`. **Conflict detection.** Two plugins registering the same `pattern` raises `InjectRouteConflict` and aborts the dev boot. ### `injectRoute` vs `devMiddleware` — pick the right hook Both add new URLs that only exist during `zfb dev`, but they aim at different problems: | Hook | Returns | Use when | | --- | --- | --- | | `injectRoute(pattern, entrypoint)` | a TSX/TS module that the page renderer evaluates and rasterises to HTML | you want a real, JSX-shaped page on a dev-only URL (matching zmod's Astro `injectRoute`). | | `devMiddleware(ctx)` → `ctx.register(path, handler)` | a JS function that returns `{ status, headers, body }` per request | you want an HTTP handler — JSON API, a hot-reload bridge, an upload endpoint. No page pipeline, no JSX. | `devMiddleware` is the right answer when the response is not a page; `injectRoute` is the right answer when you want a synthetic `pages/foo.tsx` that lives outside the on-disk `pages/` tree. ### Closed surface — no markdown extension hooks `SetupContext` exposes exactly three registration methods: `addAlias`, `addVirtualModule`, `injectRoute`. The deliberate omissions: - no `addRemarkPlugin` / `addRehypePlugin` / `addMarkdownVisitor`, - no `addModuleLoader` / `addModuleTransform`, - no `onConfigResolved` / `onModuleLoad`. Markdown extensibility lives **inside** zfb's tree as in-tree Rust visitors (TOC, external links, CJK handling, etc.); future markdown features are added to the engine, not exposed as JS plugin points. This keeps the v1 contract narrow and the build pipeline auditable. ## `preBuild(ctx)` and `postBuild(ctx)` - `preBuild` runs after `setup` and before the bundler / renderer / CSS / islands work. Use it to emit files the downstream stages will see. - `postBuild` runs after `dist/` has been fully written (including any adapter wrapping). Use it for finalisation steps that need a complete tree on disk. Both hooks receive `{ projectRoot, outDir, config, options, logger }`. `postBuild` additionally receives `ctx.routes` — the complete route manifest for the build. See [`ZfbBuildHookContext`](https://github.com/Takazudo/zudo-front-builder/blob/main/packages/zfb/src/plugins.ts) for the full shape. ### `ctx.routes` — the route manifest (postBuild only) `postBuild` plugins receive a `ctx.routes` object describing every URL the build emitted. The field is **absent (`undefined`)** during `preBuild` — the manifest is not available until rendering finishes. ```ts interface ZfbRouteManifest { routes: ZfbRouteEntry[]; } interface ZfbRouteEntry { url: string; // emitted URL path, e.g. "/blog/hello/" output: string; // path under outDir, e.g. "blog/hello/index.html" extension: string; // file extension: "html", "xml", "rss", "txt", "json", … source: string; // source page module, e.g. "pages/blog/[slug].tsx" prerender: boolean; // true = SSG (written to disk under outDir); // false = SSR (no on-disk artifact, served by the adapter) params?: Record; // absent for static routes; // dynamic params are strings, catchall params are string[] } ``` Routes are sorted by `url` for byte-stable output across runs. Non-HTML routes (`sitemap.xml.tsx`, `feed.rss.tsx`, `llms.txt.tsx`) appear with their actual extension and output path. The manifest includes **both** SSG routes (`prerender: true`) and SSR routes (`prerender: false`). SSR routes are valid runtime URLs served by the adapter — but they have no on-disk artifact under `outDir`. Indexes that enumerate "URLs the build wrote to disk" (sitemap.xml, search-index.json, …) should filter `r.prerender !== false` to avoid surfacing those. ### On-disk access — `dist/__zfb/routes.json` The same manifest is also written to `/__zfb/routes.json` at the end of every `zfb build` (#347). The on-disk file mirrors the in-memory `ctx.routes` shape one-for-one — same fields, same url-sorted order — so any script wired into `pnpm build` (a sibling sitemap generator, an OGP indexer, a search-shard builder) can read the manifest without writing a zfb plugin. ```json { "routes": [ { "url": "/", "output": "index.html", "extension": "html", "source": "pages/index.tsx", "prerender": true }, { "url": "/blog/hello/", "output": "blog/hello/index.html", "extension": "html", "source": "pages/blog/[slug].tsx", "prerender": true, "params": { "slug": "hello" } } ] } ``` The plugin `ctx.routes` and the on-disk `routes.json` are two access shapes over the same data, not two contracts. Opt out by setting `emitRoutesManifest: false` in `zfb.config.ts` for projects that strip everything but shipped assets out of `dist/` before deploy. ### Worked example: generating a `sitemap.xml` in `postBuild` A plugin that writes a `sitemap.xml` from every HTML route the build produced. The filter combines `extension === "html"` (skip `.xml` / `.rss` / `.txt` routes) with `prerender !== false` (skip SSR routes that have no on-disk artifact). ```ts // plugins/sitemap.ts name: "sitemap", postBuild({ outDir, routes }) { if (!routes) return; // guard: absent on preBuild const siteUrl = "https://example.com"; const htmlRoutes = routes.routes.filter( (r) => r.extension === "html" && r.prerender !== false, ); const xml = [ '', '', ...htmlRoutes.map( (r) => ` ${siteUrl}${r.url}`, ), "", ].join("\n"); writeFileSync(join(outDir, "sitemap.xml"), xml, "utf-8"); }, }); ``` ```ts // zfb.config.ts plugins: [{ name: "./plugins/sitemap.ts" }], }); ``` The plugin runs after `dist/` is fully written; any file it creates alongside the emitted HTML pages is served as a static asset in production. The filter `r.extension === "html"` above will over-include server-rendered (SSR) routes — routes where `prerender` is `false` — once SSR ships. Today `ctx.routes[].prerender` does not exist on `ZfbRouteEntry`. When it does, the correct filter will be `r.extension === "html" && r.prerender !== false`. Do not use that expression yet; the field is not there. Tracked in [zudo-front-builder#347](https://github.com/Takazudo/zudo-front-builder/issues/347). ## `devMiddleware(ctx)` Unchanged from v1. Register one or more HTTP handlers via `ctx.register(path, handler)`; handlers return `{ status, headers, body }` (or `undefined` to fall through to zfb's built-in dev routes). See [`ZfbDevMiddlewareContext`](https://github.com/Takazudo/zudo-front-builder/blob/main/packages/zfb/src/plugins.ts) for the shape. ## Worked example: `virtual:metadata-db` A plugin that builds a metadata index and exposes it as a virtual module. Pages can then `import metadata from "virtual:metadata-db"` without zfb knowing anything about the index format. ```ts // plugins/metadata-db.ts name: "metadata-db", setup({ projectRoot, addVirtualModule }) { addVirtualModule("virtual:metadata-db", () => { const dir = join(projectRoot, "src/content/docs"); const entries = readdirSync(dir, { recursive: true }) .filter((p) => typeof p === "string" && p.endsWith(".mdx")) .map((relPath) => { const body = readFileSync(join(dir, relPath as string), "utf-8"); // ... parse frontmatter, compute slug, etc. return { slug: relPath, title: "...", description: "..." }; }); return `export default ${JSON.stringify(entries)}`; }); }, }); ``` ```ts // zfb.config.ts plugins: [{ name: "./plugins/metadata-db.ts" }], }); ``` ```tsx // pages/index.tsx return ( {metadata.map((m) => ( {m.title} ))} ); } ``` The loader runs once at the start of `zfb build`; the bundler caches the result and every `import` of `virtual:metadata-db` sees the same source. On the next `zfb build` the loader runs again — there is no on-disk cache between builds. ## Conflict-detection summary When two plugins clash on a registration, zfb aborts the build/dev with one of three errors that names both offending plugins: - `AliasConflict` — same `from`, different `to`. - `VirtualModuleConflict` — same specifier, different plugins. - `InjectRouteConflict` — same URL pattern, different plugins. `InjectRouteInBuildMode` is raised when any plugin calls `injectRoute` during `zfb build` (regardless of who registered first). ## When you don't — write a recipe Not everything that runs at build time needs a plugin. If your task doesn't require `setup`-level capabilities (no virtual module, no alias, no injected route, no dev middleware), a plain Node.js script in `scripts/` wired into `pnpm build` is simpler, easier to test in isolation, and less coupled to zfb's internals. The [Design Philosophy](/concepts/design-philosophy) and [Engine vs Framework](/concepts/engine-vs-framework) pages explain the broader principle. Common candidates that do not need a plugin: - **Sitemap generation.** `postBuild` gives you `ctx.routes` — but you can read the same `dist/` tree from a standalone script. No module graph access needed; wire it into `pnpm build`. - **OGP image emission.** Rendering open-graph images from page metadata is a pure data-in / image-out transform. A standalone script that reads built HTML or a JSON manifest and calls a canvas/puppeteer/satori pipeline needs no plugin hook; wire it into `pnpm build`. - **Search-index builds.** Tools like Pagefind or Lunr crawl the finished `dist/` tree. They need no access to zfb internals — just a path to the output directory; wire it into `pnpm build`. - **Build-end manifests.** If you need a custom JSON manifest (asset list, version map, route catalog) derived from the emitted files, a script that reads `dist/` after the build is self-contained; wire it into `pnpm build`. ## See also - [`defineConfig`](/api/define-config) — wiring `plugins: [{ name: "...", options: {...} }]`. - [Build Pipeline](/concepts/build-pipeline) — where each hook lands in the overall sequence. --- # CJK-friendly emphasis > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/cjk-friendly `CjkFriendlyPlugin` is always active. It post-processes the parsed markdown AST and re-tokenises emphasis flanking rules for CJK content. ## Why this exists CommonMark's emphasis flanking rules treat CJK ideographs and kana as non-whitespace non-punctuation. This means a `**` adjacent to CJK text — such as `**テスト。**テスト` — is not considered right-flanking by the base parser, and renders as literal stars instead of ``. `CjkFriendlyPlugin` post-processes the parsed markdown AST and re-tokenises these cases. The result matches the intuitive expectation for Japanese, Chinese, and Korean content sites. ## Behaviour **Default:** always on. No configuration key required. **Opt-out** (rare): set `cjkFriendly: false` in `zfb.config.ts` only when you need strict CommonMark output and your content has no CJK emphasis: ```ts title="zfb.config.ts" markdown: { cjkFriendly: false, }, }); ``` ## Scope This plugin handles `**bold**` and `_italic_` adjacent to CJK characters. GFM strikethrough (`~~foo~~`) does not need the toggle — markdown-rs's GFM tokeniser handles `~~` delimiter runs independently, so strikethrough works correctly at CJK boundaries in both modes. ## See also - [Customizing Markdown](/guides/customizing-markdown) — full pipeline overview. --- # Heading links > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/heading-links `HeadingLinksPlugin` is always active. It slugifies every ``–`` and injects a self-referencing anchor link so readers can copy a deep-link to any section. ## Behaviour For each heading the plugin: 1. Computes a GitHub-slugger-compatible slug from the heading text. 2. Deduplicates repeated slugs within the same document by appending a counter (`overview`, `overview-1`, `overview-2`, …). 3. Sets the `id` attribute on the `` element. 4. Wraps the heading text in an `` anchor. ## Example ```md ## Introduction ## Introduction ``` Produces: ```html Introduction Introduction ``` ## Config Always on, no config key. The slug algorithm is fixed (GitHub-slugger rules) and cannot be customised from `zfb.config.ts`. ## Ordering note `HeadingLinksPlugin` runs **first** in the hast phase. Plugins that depend on stable heading `id` values — such as `TocPlugin` (opt-in [heading-marker-toc](/markdown-features/heading-marker-toc)) and `TocExportPlugin` (opt-in [toc-export](/markdown-features/toc-export)) — must run after it. ## See also - [Heading-marker TOC](/markdown-features/heading-marker-toc) — opt-in TOC insertion that reads the `id` attributes this plugin produces. - [TOC export](/markdown-features/toc-export) — opt-in structured TOC data export. --- # Code block title > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/code-title `CodeTitlePlugin` is always active. It reads the `title="…"` token from a fenced code block's info string and renders a `` label immediately above the block. ## Usage Add `title="…"` after the language tag in the fence: ````md ```ts title="zfb.config.ts" markdown: { cjkFriendly: true }, }); ``` ```` Rendered output: ```html zfb.config.ts … ``` ## Config Always on, no config key. ## Ordering note `CodeTitlePlugin` runs **before** `SyntectPlugin` in the hast phase. `SyntectPlugin` replaces the entire `` element with a raw HTML fragment; once that happens, the `data-meta` attribute carrying `title="…"` is no longer reachable as structured AST. Running `CodeTitlePlugin` first ensures it reads the meta before syntect erases it. ## See also - [Syntax highlighting](/markdown-features/syntax-highlighting) — the syntect plugin that runs after this one. --- # External links > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/external-links `ExternalLinksPlugin` adds `target` and `rel` attributes to links whose destination is outside your site. It is always active when configured. ## Enable ```ts title="zfb.config.ts" markdown: { externalLinks: { target: "_blank", rel: ["noopener", "noreferrer"], }, }, }); ``` Omitting the `externalLinks` key leaves the rendered output byte-for-byte identical to the pre-feature behaviour — no extra attributes are emitted. ## What counts as external A link is external when its destination is an absolute `http:` or `https:` URL and its origin differs from your site's origin (determined by the top-level `site` config option, once configured). When `site` is absent, any absolute HTTP/HTTPS URL is treated as external. `mailto:`, `tel:`, and other non-HTTP(S) schemes are always left unchanged. Relative paths (`/internal/`, `./page.mdx`, `#anchor`) are always internal. ## Merging with existing rel If an `` element already carries a `rel` attribute, the configured tokens are merged in (deduplicated, case-insensitive). Existing tokens appear first, so author intent is preserved. Same-origin classification depends on the `site` config option. Until `site` is configured, every absolute HTTP/HTTPS URL is treated as external. ## See also - [Resolve links](/markdown-features/resolve-links) — companion Core plugin that normalises internal link targets. --- # Resolve links > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/resolve-links `ResolveLinksPlugin` normalises internal Markdown links (`[text](./page.mdx)`, `[text](../other/page)`) using the content source map built at startup. It is wired in at the project level when a source map is available. ## What it does During the mdast phase, before any HTML is produced, the plugin walks every `Link` node. For each relative link: 1. Resolves the path relative to the current source file. 2. Looks up the resolved path in the content source map. 3. If found, rewrites the `url` to the final output URL for that entry. 4. If not found, emits a warning diagnostic and leaves the link unchanged. This ensures that link targets written as relative file paths (the natural way to cross-link content while editing locally) produce correct output URLs in the built site, regardless of how the file-to-URL mapping is configured. ## Config `ResolveLinksPlugin` is wired in per-project and does not appear as a key in `zfb.config.ts`. When a content source map is present (the default for content collections), it is active automatically. ## See also - [Link validation](/markdown-features/link-validation) — opt-in plugin that treats broken internal links as hard build errors. - [External links](/markdown-features/external-links) — companion Core plugin for outbound links. --- # Strip .md extension > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/strip-md-ext `StripMdExtensionPlugin` removes the `.md` or `.mdx` suffix from internal link `href` values so that links authored as `[page](./other.md)` produce clean output URLs like `/docs/other`. ## What it does In the hast phase, the plugin walks every `` element. For each link whose `href` ends with `.md` or `.mdx`: 1. Strips the extension. 2. Leaves the rest of the path, query string, and fragment intact. The plugin only touches relative and root-relative paths — absolute URLs (`https://…`) and non-link elements are left unchanged. ## Config Always on when a source map is available, no config key. The plugin runs after `ResolveLinksPlugin` so it sees the already-resolved paths. ## Example ```md See [setup](./installation.mdx) for details. ``` Produces: ```html See setup for details. ``` ## See also - [Resolve links](/markdown-features/resolve-links) — runs before this plugin to normalise the target path. --- # Syntax highlighting > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/syntax-highlighting `SyntectPlugin` runs server-side syntax highlighting at build time. It is always active. Every fenced code block in your Markdown is highlighted via [syntect](https://github.com/trishume/syntect) — a Rust library using Sublime Text–compatible grammars — and the colour-annotated HTML is baked into the output. No JavaScript is shipped to the browser for highlighting. ## Theme configuration The default theme is `"base16-ocean.dark"`. Change it in `zfb.config.ts`: ```ts title="zfb.config.ts" codeHighlight: { theme: "Solarized (light)", }, }; ``` Custom `.tmTheme` files and supplementary client-side patterns are covered in the dedicated guide below. ## Full documentation For theme names, custom `.tmTheme` loading, client-side supplementary highlighting, and post-build custom grammar patterns, see [Syntax Highlighting](/guides/syntax-highlighting). ## Ordering note `SyntectPlugin` runs **last** in the hast phase, after `CodeTitlePlugin`, `MermaidPlugin`, and all other code-block visitors. It replaces each `` element with a raw HTML fragment; any plugin that needs to read the structured `` must run before it. --- # Directives registry > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/directives-registry `DirectiveRegistry` is the Core primitive that maps CommonMark Directives syntax — container (`:::name`), leaf (`::name[label]`), and text (`:name[label]`) — to JSX component calls in the compiled output. It is always active. You interact with it in two ways: - **Consuming the registry** — register your own directive names from a project-side config without writing Rust. See [Custom Directives](/concepts/custom-directives). - **Using the built-in preset** — enable the seven bundled admonition directive types (`note`, `tip`, `warning`, `danger`, `info`, `details`, `caution`) via the opt-in `admonitionsPreset` feature described below. ## Directive shapes The registry handles three directive shapes: - **Container** — `:::name[label]` … `:::` wraps multi-paragraph body content into a JSX component. - **Leaf** — `::name[label]{attrs}` produces a self-closing component with no children. - **Text** — `:name[label]{attrs}` is an inline component. ## Admonitions preset The seven built-in admonition types are opt-in — they are not registered by default. Enable them in `zfb.config.ts`: ```ts title="zfb.config.ts" markdown: { features: { admonitionsPreset: true, }, }, }); ``` See [Admonitions preset](/markdown-features/admonitions-preset) for the full directive syntax and blank-line requirements. ## Typed attribute schemas (from #584) The registry accepts typed attribute schemas for each registered directive. Unknown attributes or type mismatches emit a build-time warning rather than silently passing raw strings. The schema is declared alongside the `register` call: ```rust registry.register_typed( "badge", DirectiveShape::Text, BadgeSchema::schema(), ); ``` ## Custom directives To register additional directives or override built-ins, see [Custom Directives](/concepts/custom-directives) — the author-facing path that does not require writing Rust. ## See also - [Custom Directives](/concepts/custom-directives) — register new `:::name` / `::name` / `:name` directive names. - [Admonitions preset](/markdown-features/admonitions-preset) — opt-in preset of seven built-in admonition types. - [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) — engine-side extension surface. --- # Changelog > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog # Changelog This section tracks released versions of `@takazudo/zfb` and its lockstep workspace packages (`@takazudo/zfb-runtime`, `@takazudo/zfb-adapter-cloudflare`, `create-zfb`, and the five platform binaries). Each version has its own page below, sorted newest-first. ## Release process (internal) Maintainers use the `/l-make-release` Claude Code skill to bump versions and write entries in this section. See the skill's SKILL.md for the full flow. The Mac x86_64 build is optional via the `/l-make-mac-release-binary` skill — if the user pre-uploads the archive to the draft GH Release before publishing it, `release.yml` skips the slow `macos-13` CI leg. ## Versions Pages below this one list every released version. --- # Claude > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/claude Claude Code configuration reference. ## Resources --- # v0.1.0-next.5 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.5 # v0.1.0-next.5 Released: 2026-05-25 ## Features - feat(release): X9 trigger switch + A2 detect-mac-local job + docs update (17424b4) - feat(skills): add /l-make-release orchestrator skill (f14b26b) - feat(skills): add /l-make-mac-release-binary skill (2cf5228) - feat(zfb): stamp --version from ZFB_RELEASE_VERSION env at build time (c8f9bc3) - feat(scaffold): sync WORKSPACE_DEP_PLACEHOLDER via sync-platform-versions script (e3c931b) ## Bug Fixes - fix(release): address codex review findings (f438a5d) - fix(release): create GH Release as draft in build-macos-x64-local.sh fallback (04cf463) - fix(bundler): re-gate --preserve-symlinks on opt-in field (0bc1d7f) - fix(content): store snapshot on globalThis so dual zfb/content instances share state (efabf06) - fix(release): publish platform packages via npm to preserve binary mode (d71c669) - fix(release): set CI=true for pnpm install in macos-x64 local build (cec7f03) - fix(release): resolve cargo target dir via metadata in macos-x64 local build (096b04e) ## Other Changes - chore: deprecate l-version-* skills + changelog sortOrder + admonitions (f48db6f) - chore: gitignore per-platform native zfb binary (2e951c7) - tests(smoke): correct Sub #449 evidence — use dynamic paths() route (088e8e1) - test(smoke): add Wave 2 confirmation report for sub-452 (4379d62) --- # v0.1.0-next.6 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.6 # v0.1.0-next.6 Released: 2026-05-25 ## Features - feat(skills): make release skills model-invocable + build Mac binary locally by default (1f3fbfd) ## Bug Fixes - fix(zfb-content): emit slug ids + TOC entries for headings nested in MDX JSX bodies (90411d9) - fix(docs): align claude-resources generator frontmatter with mdx-formatter (b21f585) ## Other Changes - test(zfb-content): cover multiple + deeply-nested headings in one JSX body (35f771b) - docs(ja): translate recipes/ + changelog v0.1.0-next.5 + re-sync JA changelog index (97e7092) - docs(ja): use bare internal links in guides (5440b0d) - docs(ja): use bare internal links in architecture (87e1755) - docs(ja): translate guides pages into Japanese (1fd6e89) - docs(ja): translate concepts batch A into Japanese (3849b02) - docs(ja): translate concepts batch B into Japanese (b36bcc2) - docs(ja): translate architecture section into Japanese (90be2f9) - docs(ja): translate api reference section into Japanese (ff9385d) - docs(ja): translate getting-started pages into Japanese (c70ae9b) - docs(ja): mirror the Node-free install guide into Japanese (fd9ef20) - chore(git): ignore cloudflare adapter dist/ and scheduler state (b06a285) - chore(docs): stop tracking generated zfb-wisdom.mdx (d51a434) - docs(release): gate Homebrew updates to stable releases (a97eeab) - docs(skills): require waiting for the Release run before updating Homebrew (b90c727) --- # v0.1.0-next.7 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.7 # v0.1.0-next.7 Released: 2026-05-26 ## Features - feat(dev): ready banner shows Local + Network URLs when host is unspecified (#487) (fa1f61f) - feat(dev): wire CssRunner and inject styles `` on every served HTML response (#494) (161eaf9) - feat(release): add post-publish clean-room smoke job (#490) (ed3b639) ## Bug Fixes - fix(build): always build content snapshot when collections configured (fac7034) - fix(dev): embed content snapshot so getStaticProps sees collections (da42675) - fix(templates): use full @takazudo/zfb specifier in scaffold templates (a134ea4) - fix(release): self-disabling prerelease dual-tag in release.yml (#481) (7027e1a) - fix(template): declare @takazudo/zfb-runtime and preact-render-to-string in basic-blog template (e128908) - fix: add .zfb-build to template .gitignores (fixes #483) (d9c5e96) ## Other Changes - test(regression): add content_snapshot_no_deferred integration test (25d4be7) - test(fixtures): add collection-static-getStaticProps regression fixture (4206243) - docs: document prerelease dual-tag policy and one-time manual remediation (1870a62) - docs: drop stale 'flip private:true' section from release checklist (abe101d) - chore: remove one-off phase/pre-publish gate reports (bc360c5) - chore: stop tracking gitignored __inbox/ files (a2f69b9) --- # v0.1.0-next.8 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.8 # v0.1.0-next.8 Released: 2026-05-26 ## Features - feat(preview): add --host + config host + Local/Network banner (closes #504) (558fa18) - feat(dev): expand paths()-based SSG routes so zfb dev serves them (closes #502) (ac61c9d) ## Bug Fixes - fix(scaffold): self-sync scaffolded version pin from the binary's own version (closes #503) (1ad9eb5) - fix(scaffold): stop warning when a route page has no frontmatter (closes #505) (2154fe1) - fix(release): inject ZFB_RELEASE_VERSION in build-macos-x64-local.sh + assert version (closes #513) (05241af) - fix(build): prefilter SSR catch-all routes (closes #520) (d24e9fd) ## Other Changes - ci(release): pin pnpm version in smoke-clean-room job (f554d96) - docs(dev): note output-path page-id coupling landmine for future watch re-scan (8384df9) - test(dev): replace weak fan-out test with seam-driven version (closes #521) (5ee1347) - test(smoke): assert dev serves dynamic /posts/hello/ slug (closes #522) (029e267) - refactor(build,dev): address deep-review findings on dynamic-routes-hardening (e622d09) --- # v0.1.0-next.9 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.9 # v0.1.0-next.9 Released: 2026-05-27 ## Features - feat(create-zfb): add startup self-check for stale pnpm install (closes #531) (a1cfc3f) ## Bug Fixes - fix(zfb-build): normalize entry_key to Hono syntax so worker_only_routes filter matches catch-alls (closes #532) (56b1fcd) - fix(zfb-server): prepend <!doctype html> on dev SSR responses (closes #530) (f47927d) - fix(renderer): gate doctype on text/html + restore static-html verbatim (3cd43fa) - fix(renderer): prepend <!doctype html> to emitted HTML (closes #524) (220b64b) --- # v0.1.0-next.10 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.10 # v0.1.0-next.10 Released: 2026-05-27 ## Bug Fixes - fix(dev): refuse to start when outDir collides with dev HTML scratch root (#534 — codex P2) (9174a6b) - fix(dev): split ServeOpts.html_root so dev server reads dev's HTML dir (#534) (4d08be2) - fix(dev): redirect DevAssetPipeline write target too (#534 — codex review) (9f3563f) - fix(dev): write HTML to .zfb-build/dev-pages/ instead of dist/ (#534) (44f1d48) ## Other Changes - docs: hardcode homepage hero copy with explicit line breaks (2821609) - docs: update top-page logo to banner and add OGP image + meta tags (14e0178) - docs(settings): reframe siteDescription as engine-under-framework (7826303) --- # v0.1.0-next.11 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.11 # v0.1.0-next.11 Released: 2026-05-27 ## Bug Fixes - fix(bundler): rewrite .module.css symlinks in shadow and anchor esbuild to shadow path (#553) (9ec98e4) - fix(bundler): broaden --preserve-symlinks gate for #553 (Wave 2 deep-review) (e61df28) ## Other Changes - test(zfb): add dual-variant regression test for #553 CSS Modules build crash (#555) (60582c2) - test(zfb): forward ZFB_ESBUILD_BIN to the subprocess in #555 regression test (86b2ee2) - test(zfb): polish #555 regression test per Wave 1 deep-review findings (0a89029) - docs(bundler): update on-branch spec comment to reflect three-layer fix (#553) (e2560b5) --- # v0.1.0-next.12 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.12 # v0.1.0-next.12 Released: 2026-05-28 ## [Md Extras] Opt-in Markdown features catalog This release ships the complete **[Md Extras]** epic: 14 opt-in Markdown features, the `zfb-md-extras` and `zfb-md-ast` crates, a revised Core pipeline, and the [Markdown Features](/markdown-features) docs category. ### Migration required — 4 features moved from Core to Opt-in The following four features were previously always-on. They are now opt-in. If your site relied on them, add the corresponding keys to `zfb.config.ts`: ```ts title="zfb.config.ts" markdown: { features: { imageEnlarge: true, // was always-on in Core mermaid: true, // was always-on in Core admonitionsPreset: true, // was always-on in Core (6 built-in admonition names) headingMarkerToc: true, // was always-on in Core }, }, }); ``` All four accept `true` (use defaults) or a config object for option overrides. Omitting a key means the feature is off and no extra bytes are emitted. ### New infrastructure - **`zfb-md-ast` crate** — shared `MdastNode`, `HastNode`, `MdastVisitor`, and `HastVisitor` types. Both Core (`zfb-content`) and Opt-in (`zfb-md-extras`) features implement these traits from a single source. - **`zfb-md-extras` crate** — all 14 opt-in features, gated by `MarkdownConfig.features` at runtime. - **`Pipeline::with_defaults_and_features`** — the new primary pipeline constructor; accepts a `MarkdownFeatures` config struct and appends only the visitors whose flags are set. - **`BuildContext`** — passed to opt-in features that need project-level context (source map, content root, site URL) at visit time. - **Typed attribute schemas** (from #584) — `DirectiveRegistry` now accepts typed attribute schemas; unknown attributes or type mismatches emit a build-time warning. - **Test infrastructure** — `zfb-test-utils` and a dedicated test harness for `zfb-md-extras`; cross-feature integration tests in Wave 7 (#582). ### 14 new Opt-in features Enable any combination via `markdown.features.*` in `zfb.config.ts`. - **`admonitionsPreset`** — the six built-in admonition directive types (`note`, `tip`, `warning`, `danger`, `info`, `details`). Doc: [Admonitions preset](/markdown-features/admonitions-preset). - **`mermaid`** — render Mermaid flowcharts from fenced code blocks at build time. Doc: [Mermaid diagrams](/markdown-features/mermaid). - **`imageEnlarge`** — wrap block-level images in an enlargeable figure with a zoom button. Doc: [Image enlarge](/markdown-features/image-enlarge). - **`headingMarkerToc`** — auto-insert a `/` TOC after a designated heading. Options: `heading`, `maxDepth`. Doc: [Heading-marker TOC](/markdown-features/heading-marker-toc). - **`githubAlerts`** — render `> [!NOTE]` / `> [!TIP]` / `> [!WARNING]` / `> [!IMPORTANT]` / `> [!CAUTION]` blockquotes as styled JSX components. Doc: [GitHub alerts](/markdown-features/github-alerts). - **`readingTime`** — compute estimated reading time and inject it into the compiled frontmatter. Doc: [Reading time](/markdown-features/reading-time). - **`githubAutolinks`** — automatically link GitHub issue/PR/commit references (`#123`, `org/repo#456`, `abc1234`). Options: `repo`. Doc: [GitHub autolinks](/markdown-features/github-autolinks). - **`codeEnrichment`** — diff markers (`// [!code ++]` / `// [!code --]`) and per-line highlighting (`{1,3-5}` in fence info). Options: `diffMarkers`, `lineHighlight`. Doc: [Code-block enrichment](/markdown-features/code-enrichment). - **`codeTabs`** — group consecutive fenced code blocks into a tabbed switcher. Doc: [Code tabs](/markdown-features/code-tabs). - **`ruby`** — render `{漢字|かんじ}` syntax as HTML `` elements. Doc: [Ruby annotation](/markdown-features/ruby). - **`tocExport`** — export a structured heading tree into the compiled JSX module for framework-side TOC rendering. Doc: [TOC export](/markdown-features/toc-export). - **`imageDimensions`** — inject `width` and `height` attributes on `` elements from the actual source file at build time. Doc: [Image dimensions](/markdown-features/image-dimensions). - **`linkValidation`** — treat broken internal links as hard build errors (configurable). Doc: [Link validation](/markdown-features/link-validation). - **`transclude`** — inline the content of another file using the `:::include` directive, with cycle detection and depth limiting. Options: `maxDepth`. Doc: [Transclusion](/markdown-features/transclude). ### Math Math support (TeX / LaTeX) is documented as a recipe: embed raw TeX in a fenced code block tagged `math` and render it client-side with KaTeX or MathJax. This keeps math off the server-rendered critical path and avoids shipping a TeX parser into the binary for the minority of sites that need it. ### Cross-feature integration tests (#582) Wave 7 sibling issue #582 added cross-feature integration tests covering interactions between Opt-in features (e.g. `githubAlerts` + `admonitionsPreset`, `transclude` + `headingMarkerToc`, `codeEnrichment` after `SyntectPlugin`). These tests live in `crates/zfb-md-extras/tests/`. ### Caller migration landed (#586) The build, dev, and snapshot pipelines now thread `markdown.features` end-to-end through a single feature-aware entry point (`Pipeline::with_defaults_and_full_config`). This is what makes the opt-in behavior above **actually take effect**: - A default `zfb.config.ts` with **no** `features` key builds with the four former-Core framework features (`mermaid`, `imageEnlarge`, `admonitionsPreset`, `headingMarkerToc`) **off**. - Setting e.g. `features: { mermaid: true }` now emits the mermaid wrapper; `features: { mermaid: false }` (or omitting it) does not. - `zfb build`, `zfb dev`, and the content-collection snapshot walker all share the same dispatch, so the JSX `content_hash` stays byte-identical across the build and snapshot surfaces. The legacy `Pipeline::with_defaults*` constructors are retained unchanged for backwards-compatible direct (library / test) callers. ### Docs - New [Markdown Features](/markdown-features) sidebar category with one page per feature. - 8 Core feature pages documenting always-on plugins. - [Customizing Markdown](/guides/customizing-markdown) slimmed down to a pipeline overview with a pointer to the new category. - [Extending the Markdown Pipeline](/guides/extending-the-markdown-pipeline) updated with Core-vs-Opt-in guidance and complete visitor ordering rules. - [Design Philosophy](/concepts/design-philosophy) — new section on the opt-in extras catalog and the three-consumer threshold for future additions. - [Engine vs Framework](/concepts/engine-vs-framework) — notes that framework-flavored Markdown features ship as opt-in extras. --- # v0.1.0-next.13 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.13 # v0.1.0-next.13 Released: 2026-05-28 Release-pipeline and CI hardening, plus a release-skill documentation update. No user-facing engine or SDK changes. ## Bug Fixes - fix(release): make build-macos-x64-local.sh work on Apple Silicon (997f4c8) - fix(pnpm): auto-confirm node_modules purge under no-TTY (2273764) ## Other Changes - ci: smoke-clean-room waits for platform tarball propagation, not just metadata (340997b) - ci: update stale v7/Node-20 comments after github-script bump (c310c36) - ci: migrate workflow actions off deprecated Node 20 runtime (91e1e90) - docs(release-skill): capture host gotchas hit during the v0.1.0-next.12 release (069fb6d) --- # v0.1.0-next.14 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.14 # v0.1.0-next.14 Released: 2026-05-29 ## Features - feat(ruby): implement caret `{base}^{ruby}` syntax, keep pipe as legacy alias (121a511) ## Bug Fixes - fix(zfb-content): hoist module-level ESM JsxRaw to column 0 on JSX-emit path (#602) (fc187bc) - fix(zfb-runtime): surface real SSR render error instead of generic 500 (46e7f8f) - fix(l-make-release): make Step 9 prerelease detection zsh-compatible (5ad8235) ## Other Changes - test(zfb-content): add cross-feature confirm test for ruby caret + tocExport (#605) (7f376fa) - ci: add actionlint workflow to lint GitHub Actions files (0258add) --- # v0.1.0-next.15 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.15 # v0.1.0-next.15 Released: 2026-05-29 ## Features - feat(islands): add React framework support to the islands pipeline (f663b19) Projects with `framework: "react"` in `zfb.config` can now hydrate React islands. The configured framework is threaded through the islands pipeline: the shared client bundle emits React mount glue (`createElement` + `hydrateRoot`/`createRoot`) instead of the hardcoded Preact glue, the SSR/main bundle defines `process.env.NODE_ENV` and (React-only) passes `--conditions=worker` and `--main-fields=main,module` so `react-dom/server` and main-only CJS deps (e.g. `tabbable` via `@headlessui/react`) resolve under `--platform=neutral`. The `@takazudo/zfb` SDK and `@takazudo/zfb-runtime` now mint all SSR-reachable elements (the `Island` wrapper, the `ClientRouter` head tags, and the MDX component overrides) through the per-project JSX runtime, so esbuild's `--jsx-import-source` produces the right element shape for either framework instead of a Preact-only VNode literal (which React's renderer rejected); the published type surface stays framework-agnostic. Preact remains the unchanged default. --- # v0.1.0-next.16 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.16 # v0.1.0-next.16 Released: 2026-05-29 ## Features - feat(render): polyfill MessageChannel/setTimeout in the V8 host for React 19 SSR (c9a38d3) Completes the React island support shipped in next.15 for **React 19**. React 19's `react-dom/server.browser` bundle constructs a `MessageChannel` at module load (its Fizz scheduler) and references `setTimeout` in its error-retick path — neither existed in the embedded V8 SSR host, so `zfb build` with React 19 failed at module load with `ReferenceError: MessageChannel is not defined` (React 18's server bundle did not touch MessageChannel, which is why next.15, verified on React 18, missed it). Adds an install-if-absent `MessageChannel`/`MessagePort` (Promise/microtask-backed) and `setTimeout`/`clearTimeout` to the host polyfills; Preact bundles are byte-unaffected. Verified under React 19 across all four island shapes (basic, Headless UI `ssrFallback`, in-MDX, ClientRouter head tags); React 18, Preact, and node-free non-regressed. The engine's own react test baseline is bumped to 19 to guard the class going forward. --- # v0.1.0-next.17 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.17 # v0.1.0-next.17 Released: 2026-05-30 ## Bug Fixes - fix(preview): preserve query string on directory 301 redirects (616627b) - fix(ssr): env-gate SSR 500 stack trace to embedded V8 host only (37647b1) ## Other Changes - docs(l-make-release): modernize bash pattern matching in release command (a686e41) --- # v0.1.0-next.18 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.18 # v0.1.0-next.18 Released: 2026-05-30 ## Features - feat(content): add precedence-merge seam for MDX component overrides (A1) (a3b4f2c) - feat(build): root mdx-components.tsx global override map convention (A2) (83e1b09) - feat(rm): hard-remove image_enlarge feature entirely (B1) (41825a1) ## Bug Fixes - fix(test): remove stale "also wrapped" prose from 07-image fixture (382cc16) ## Other Changes - refactor(content): move mergeMdxComponents after defaultComponents (b0f3290) - docs(concepts): document the full markdown element-override system (#618) (4a19a7d) - docs(recipes): add enlargeable images recipe (B2, #619) (0e771b9) - docs(concepts): clarify wrapper deferral wording in mdx-components.mdx (9a1fb51) - test(c1): add composed precedence chain integration tests (#617) (e54893c) - test(c1): add captured.toHaveLength(1) guards in layer tests (38f513d) - docs(concepts): fix Island usage in mdx-components example (d59af59) - docs(comments): scrub stale image_enlarge references (B1 follow-up) (46f2e7e) - docs(zfb-runtime): drop stale image-enlarge row from port-spec persistence table (52dab71) - test(zfb): add set-level passthrough output-neutrality guard for defaultComponents (4382eca) - refactor(zfb-build): group write_entry_module emission inputs into a struct (daad784) - docs(ja): translate new mdx-components override-system sections (67a9296) - test(zfb): make v8_eval spawn_blocking test load-robust via heartbeat probe (6701102) --- # v0.1.0-next.19 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.19 # v0.1.0-next.19 Released: 2026-05-30 ## Bug Fixes - fix(zfb-islands): apply react/jsx-runtime→preact alias in islands esbuild (#633) (766a859) ## Other Changes - fix(ci): pin node-free released-mode smoke to the released tag (2a44a0b) - docs(readme): note pre-1.0 install needs ZFB_VERSION=latest-prerelease (bf792bb) - test(zfb-islands): assert alias rewrite target + de-rot bundler refs per review (7feb4b9) - test(zfb-build): harden normalize_shadow_paths per review (d55b6b9) - test(zfb-build): de-flake bundler determinism check against random shadow tempdir (66a8f87) --- # v0.1.0-next.20 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.20 # v0.1.0-next.20 Released: 2026-05-31 ## Bug Fixes - fix(dev): eager initial render so zfb dev serves 200 on cold boot (#644) (e7ee98a) ## Other Changes - test(zfb-watcher): make smoke test macOS-portable (#645) (cb04bfc) - fix(lint): clean all rustc + clippy warnings across the workspace (#646) (f83b8e6) - ci(health): add clippy -D warnings gate after cargo build (#646) (d7a5f09) - fix(lint): clean clippy 1.96 lints surfaced by the new -D warnings gate (#646) (bbf48f9) --- # v0.1.0-next.21 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.21 # v0.1.0-next.21 Released: 2026-05-31 ## Features - feat(orchestrator): carry ChangeKind via additive tick_with_kinds for watch-ADD discovery (#659) (ec6dd5a) ## Bug Fixes - fix(config): bound config-loader esbuild/node output() waits (#658) (90eb42d) - fix(dev): discover newly-added content files on a live tick via in-place SSR-host reload (#659) (bc1421b) - fix(watcher): preserve Created kind across burst window; add real-watcher e2e (#660) (0022871) - fix(adapter): bound dispatch output() via tempfile redirect + direct-child wait (#651) (4bc261f) - fix(plugin-runner): bound hook awaits with tokio timeout + fail-fast (#653) (c9e3eee) - fix(v8-host): bound Drop join via watcher-thread join_with_deadline (#652) (b630b28) - fix(build-exit-hang): apply deep-review findings (84472bb) ## Other Changes - test(watch-add): fix flaky ADD e2e with watcher-live handshake (eeb2383) - test(dev-loop): red→green watch-ADD discovery test modelling route-table fan-out (#659) (a6a431b) - test(e2e): assert zfb build terminates via process-group watchdog (#654) (151ce3a) - build: lock wait-timeout 0.2.1 (Cargo.lock for #651) (30d2144) --- # v0.1.0-next.22 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.22 # v0.1.0-next.22 Released: 2026-05-31 Bundler, config-eval, and markdown parity fixes for Astro/Vite → zfb migrations (the zzmod styleguide-v2 migration surfaced all of these), plus a follow-up fix so the new transforms compose with tsconfig path aliases in a single build. ## Features - feat(bundler): implement Vite `import.meta.glob` eager transform — `import.meta.glob('…', { eager: true })` with a string-literal pattern is expanded Rust-side during shadow materialisation; unsupported forms (non-eager / dynamic pattern / `import()` mode) error with a clear message (#665) (9fe4dfa) - feat(bundler): add `bundle.exclude` glob knob — skip materialise + glob-expansion for matched files so a single un-bundlable file under `components/**` cannot fail the whole build (#664) (08168ba) - feat(markdown): add `hardBreaks` option (remark-breaks parity) — converts every soft line break to ``; default `false` (#662) (401c5b6) ## Bug Fixes - fix(config-eval): install web polyfills (`URL`, `URLSearchParams`, `TextEncoder`/`TextDecoder`) into the config-eval V8 isolate so every `zfb.config.ts` loads — previously failed with `ReferenceError: URL is not defined` (#663) (0258c48) - fix(bundler): follow tsconfig `extends` chains and resolve non-`.` `baseUrl` when reading `compilerOptions.paths` (#666) (9970155) - fix(bundler): make in-shadow transforms (`import.meta.glob`, CSS-modules) compose with tsconfig paths + a real `node_modules` in a single build — via copy-not-symlink source materialisation (when `--preserve-symlinks` is omitted) plus a dual-target alias rebase (shadow-first, real-fallback). The `--preserve-symlinks` gate is unchanged, so workspace path-alias resolution is preserved (#675) (7e34145) - fix(bundler): emit a clean shadow target for the whole-root `@/* → ./*` alias (no double-slash) (#675) (4706ea7) ## Other Changes - test(migration): end-to-end confirm that all five fixes compose in one build (#673) (8ba6ead) - test(migration): make the `bundle.exclude` leg load-bearing in the combined confirm test (#675) (8e93a58) ## Notes The composition fix (#675) covers relative imports and `@/`-style aliases that resolve into mirrored source dirs (including the common whole-root `@/* → ./*`). One documented residual: alias targets that are **out-of-root** (`../shared/*`), **gitignored**, or **top-level files** still resolve (via the real-fallback) but their in-shadow transforms are not applied — unchanged from prior behaviour, never a build failure. --- # v0.1.0-next.23 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.23 # v0.1.0-next.23 Released: 2026-06-01 Adds opt-in `bundle.mainFields` / `bundle.external` knobs for the page/SSR (`--platform=neutral`) bundler, so a project can resolve (or externalize) a CJS-`main`-only dependency without excluding the importing file. ## Features - feat(bundler): add `bundle.mainFields` / `bundle.external` knobs for the page/SSR pass — under `--platform=neutral` esbuild's main-fields list is empty, so a dep resolved only via `package.json` `main`/`module` (no `exports` map, e.g. `msw` → `path-to-regexp@6`) fails to resolve. `bundle.mainFields` sets esbuild `--main-fields` for every framework (previously a React-only `main,module` shim); `bundle.external` appends `--external` specifiers. Both are opt-in and threaded through `zfb build` and `zfb dev`; unset → byte-identical to before (#676) (5c434e1) ## Other Changes - test(bundler): cover `bundle.mainFields` / `bundle.external` resolution with fail-without / pass-with negative-control integration tests (#676) (6a3ffa5) - fix(bundler): correct `main_fields` field placement in existing `BundlerInput` test literals (#676) (01a6a84) --- # v0.1.0-next.24 > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/changelog/v0.1.0-next.24 # v0.1.0-next.24 Released: 2026-06-02 Makes the markdown admonitions preset configurable: the `:::caution` admonition joins the built-in preset, and a new `AdmonitionsPresetFeature` accepts either a `bool` (on/off) or an object to tune individual directives — back-compatible with the previous always-on behaviour. ## Features - feat(md-extras): add `:::caution` admonition to the preset (#682) (0b38c9b) - feat(md-ast): add AdmonitionsPresetFeature (bool|object) + pipeline wiring (42f2def) - feat(ts): add AdmonitionDirectiveSpec, AdmonitionsPresetOptions, AdmonitionsPresetFeature types (afc77cb) ## Bug Fixes - fix(tests): update 7 struct-literal test sites from FeatureToggle to AdmonitionsPresetFeature (6baef38) ## Other Changes - docs: document configurable admonitions preset + caution (#685) (6af3993) - test(md-extras): integration + back-compat coverage for configurable admonitions (#684) (87b4bcb) --- # zfb-wisdom > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/claude-skills/zfb-wisdom # zfb Documentation Reference Look up documentation from the zfb (zudo-front-builder) project. Documentation base path: `docs/src/content/docs` (relative to repo root) ## Mode Detection Parse the argument string for flags: - If args start with `-u` or `--update`: enter **Update mode** (see below) - Otherwise: enter **Lookup mode** (default) Strip the flag from the remaining argument to get the topic keyword. ## Lookup Mode (default) 1. Find the relevant article(s) from the `docs/` directory based on the topic 2. Read ONLY the specific article(s) you need — do NOT load all articles at once 3. Apply the information from the article when answering the user's question 4. Mention the source article path so the user can find it for further reading ## Update Mode (`-u` / `--update`) The user has new information and wants to add or update documentation in this repo. ### Workflow 1. **Understand the new info**: Ask the user what they learned or want to document. The topic keyword (if provided) hints at the subject area. 2. **Find existing docs**: Search the `docs/` directory for articles related to the topic. Read them to understand what is already covered. 3. **Decide create vs update**: If an existing article covers the topic, update it. Otherwise, create a new `.mdx` file in the appropriate subdirectory. 4. **Write the content**: Follow the doc-authoring rules in `docs/CLAUDE.md`: - Required frontmatter: `title` (string). Always set `sidebar_position`. Optional: `description`, `sidebar_label`, `tags`, etc. - Do NOT use `# h1` in content — the frontmatter `title` renders as h1. Start with `## h2` headings. - Use available MDX components (``, ``, ``, ``, ``) where appropriate. 5. **Update Japanese docs**: Create or update the corresponding file under `docs-ja/` mirroring the English directory structure. Keep code blocks and diagrams identical — only translate surrounding prose. 6. **Format**: Run `pnpm format:md` inside `docs/` to format the changed files. 7. **Verify**: Run `pnpm build` inside `docs/` to confirm the site builds correctly. ## Documentation Structure The documentation is organized in MDX files under `docs/`: ``` - api/ - architecture/ - changelog/ - claude-md/ - claude-skills/ - claude/ - concepts/ - getting-started/ - guides/ ``` Browse the `docs/` directory to discover available articles. Each `.mdx` file has YAML frontmatter with `title` and `description` fields that help identify the right article to read. ## Japanese Documentation Japanese translations are available under `docs-ja/`. When the user is working in Japanese or asks for Japanese content, prefer articles from `docs-ja/`. --- # zudo-doc-design-system > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/claude-skills/zudo-doc-design-system # zudo-doc CSS & Component Rules **IMPORTANT**: These rules are mandatory for all code changes in this project that touch CSS, Tailwind classes, color tokens, or component markup. Read the relevant section before making changes. ## How to Use Based on the topic, read the specific reference doc: | Topic | File | |-------|------| | Spacing, typography, layout tokens | `src/content/docs/reference/design-system.mdx` | | Component-first methodology | `src/content/docs/reference/component-first.mdx` | | Color tokens, palette, schemes | `src/content/docs/reference/color.mdx` | Read ONLY the file relevant to your task. Apply its rules strictly. ## Quick Rules (always apply) ### Component First (no custom CSS classes) - **NEVER** create CSS module files, custom class names, or separate stylesheets - **ALWAYS** use Tailwind utility classes directly in component markup - The component itself is the abstraction — `.card`, `.btn-primary` are forbidden - Use props for variants, not CSS modifiers ### Design Tokens (no arbitrary values) - **NEVER** use Tailwind default colors (`bg-gray-500`, `text-blue-600`) — they are reset to `initial` - **NEVER** use arbitrary values (`text-[0.875rem]`, `p-[1.2rem]`) when a token exists - **ALWAYS** use project tokens: `text-fg`, `bg-surface`, `border-muted`, `p-hsp-md`, `text-small` - Spacing: `hsp-*` (horizontal), `vsp-*` (vertical) — see design-system.mdx for full list - Typography: `text-caption`, `text-small`, `text-body`, `text-heading` etc. ### Color Tokens (three-tier system) - **Tier 1** (palette): `p0`–`p15` — raw colors, use only when no semantic token fits - **Tier 2** (semantic): `text-fg`, `bg-surface`, `border-muted`, `text-accent` — prefer these - **NEVER** use hardcoded hex values in components - Palette index convention (consistent across all themes): - p1=danger, p2=success, p3=warning, p4=info, p5=accent - p8=muted, p9=background, p10=surface, p11=text primary ### Search & highlight tokens (role-split) Highlight roles are deliberately split across dedicated semantic tokens — do **not** share one token across unrelated highlight UIs. - `matched-keyword-bg` / `matched-keyword-fg` — background and foreground of the search panel `` element. Driven by `--color-matched-keyword-bg` / `--color-matched-keyword-fg`; live-editable in the Design Token Panel. This is the single source of truth for "why is this color yellow in the search results" — the panel swatch matches the rendered highlight 1:1. - `warning` — drives admonitions (`:::warning`), find-in-page (`.find-match`, `.find-match-active`), and any UI that is semantically a warning. Do **not** reuse it for new UI-chrome highlights. **Rule**: when a new highlight role appears (new kind of mark, new pill, new callout), add a dedicated semantic token rather than bolting it onto `--color-warning` or another existing token. Each visible highlight color should map to exactly one panel swatch. ### hover:underline on link-like elements Any element that navigates (rendered as `` or behaves as a link) MUST have `hover:underline focus-visible:underline`. Keyboard users need the same affordance as mouse users — never add `hover:underline` without the `focus-visible:underline` pair. - **Links (do underline)**: doc content links, sidebar items, header main-nav, header overflow menu items, color-tweak panel unselected tabs, search result rows, footer links, doc history entries, breadcrumb trails, mobile TOC entries. - **Controls (do NOT underline)**: buttons, toggles, sidebar resizer, palette selectors, color swatches, close icons. These use border/bg hover instead. Precedents to copy the pattern from: `src/components/header.astro`, `src/components/site-tree-nav.tsx`, `src/components/footer.astro`. See also: `/css-wisdom` for light-mode / dark-mode contrast rules and the broader three-tier token strategy. ### Astro vs React - Default to **Astro components** (`.astro`) — zero JS, server-rendered - Use **React islands** (`client:load`) only when client-side interactivity is needed - Both follow the same utility-class approach --- # zudo-doc-translate > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/claude-skills/zudo-doc-translate # zudo-doc Translation Skill Translate documentation between English and Japanese following project-specific conventions. ## i18n Structure - English docs: `src/content/docs/` — routes at `/docs/...` - Japanese docs: `src/content/docs-ja/` — routes at `/ja/docs/...` - Directory structures must mirror each other exactly (same filenames, same folder hierarchy) - Locale settings: `locales` in `src/config/settings.ts` - Astro i18n config: `astro.config.ts` with `prefixDefaultLocale: false` (English has no prefix, Japanese uses `/ja/`) ## Translation Rules ### Keep in English (do NOT translate) - Component names: ``, ``, ``, ``, ``, ``, ``, `` - Code blocks — code is universal - File paths: `src/content/docs/...`, `.claude/skills/...`, etc. - CLI commands: `pnpm dev`, `pnpm build`, etc. - Technical terms that are standard in English (e.g., component, props, frontmatter, slug) - Frontmatter field keys (`title`, `description`, `sidebar_position`, `category`) ### Translate - Frontmatter field values (e.g., the `title` value, the `description` value) - The `title` prop of admonition components (e.g., ``) - Prose content, headings, list items, table cells (except as noted below) ### Table conventions - In tables with a "Required" column: use **"Yes"** / **"No"** directly, NOT "はい" / "いいえ" — Japanese conversational yes/no is unnatural in technical documentation ### Internal links - Adjust link paths when translating: - En→Ja: `/docs/getting-started` → `/ja/docs/getting-started` - Ja→En: `/ja/docs/getting-started` → `/docs/getting-started` ## File Naming - Japanese files use the **same filenames** as English (e.g., `writing-docs.mdx`) - Only the parent directory differs: `docs/` vs `docs-ja/` - Example: `src/content/docs/guides/writing-docs.mdx` → `src/content/docs-ja/guides/writing-docs.mdx` ## Workflow ### En→Ja Translation 1. Read the English source file from `src/content/docs/` 2. Check if the corresponding Japanese file already exists in `src/content/docs-ja/` - If it exists, read it first — use it as a base and update from the English source rather than overwriting from scratch - If it does not exist, create the file at the equivalent path in `src/content/docs-ja/` 3. Translate the content following the rules above 4. Verify internal links point to `/ja/docs/...` ### Ja→En Translation 1. Read the Japanese source file from `src/content/docs-ja/` 2. Check if the corresponding English file already exists in `src/content/docs/` - If it exists, read it first — use it as a base and update from the Japanese source rather than overwriting from scratch - If it does not exist, create the file at the equivalent path in `src/content/docs/` 3. Translate the content following the rules above 4. Verify internal links point to `/docs/...` (no `/ja/` prefix) ### Post-Translation Checks - Frontmatter keys are unchanged (only values translated) - All admonition component names remain in English - Code blocks are untouched - Internal links use the correct locale prefix - Directory structure mirrors the source language --- # zudo-doc-version-bump > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/claude-skills/zudo-doc-version-bump # /zudo-doc-version-bump Bump the version, generate changelog doc pages, commit, tag, and create a GitHub release. ## Preconditions Before doing anything else, verify ALL of the following. If any check fails, stop and tell the user. 1. Current branch is `main` 2. Working tree is clean (`git status --porcelain` returns empty) 3. At least one `v*` tag exists (`git tag -l 'v*'`). If no tag exists, tell the user to create the initial tag first (e.g. `git tag v0.1.0 && git push --tags`). Find the latest version tag: ```bash git tag -l 'v*' --sort=-v:refname | head -1 ``` ## Analyze changes since last tag Run: ```bash git log ..HEAD --oneline ``` and ```bash git diff ..HEAD --stat ``` Categorize each commit by its conventional-commit prefix: - **Breaking Changes**: commits with an exclamation mark suffix (e.g. `feat!:`) or BREAKING CHANGE in body - **Features**: `feat:` prefix - **Bug Fixes**: `fix:` prefix - **Other Changes**: everything else (`docs:`, `chore:`, `refactor:`, `ci:`, `test:`, `style:`, `perf:`, etc.) ## Propose version bump Based on the changes: - If there are breaking changes → propose **major** bump - If there are features (no breaking) → propose **minor** bump - Otherwise → propose **patch** bump If the user passed an argument (`major`, `minor`, or `patch`), use that directly instead of proposing. Present the proposal to the user: ``` Proposed bump: {current} → {new} ({type}) Breaking Changes: - description (hash) Features: - description (hash) Bug Fixes: - description (hash) Other Changes: - description (hash) ``` Only show sections that have entries. **Wait for user confirmation before proceeding.** If this is a **major** version bump, ask the user whether they want to archive the current docs as a versioned snapshot (i.e. run with `--snapshot`). Explain that this copies the current docs to a versioned directory for the old version. ## Run version-bump.sh Run the existing version bump script to update package.json and create changelog entry files: ```bash ./scripts/version-bump.sh {NEW_VERSION} # Or with snapshot for major bumps: ./scripts/version-bump.sh {NEW_VERSION} --snapshot ``` This script: 1. Updates `version` in `package.json` 2. Creates `src/content/docs/changelog/{NEW_VERSION}.mdx` (EN) 3. Creates `src/content/docs-ja/changelog/{NEW_VERSION}.mdx` (JA) 4. With `--snapshot`: copies current docs to versioned directories and prints settings.ts entry to add ## Fill in changelog content After the script creates the template files, **replace the placeholder content** with the actual categorized changes from the commit analysis. ### English changelog (`src/content/docs/changelog/{NEW_VERSION}.mdx`) ```mdx --- title: {NEW_VERSION} description: Release notes for {NEW_VERSION}. sidebar_position: {value from script} --- Released: {YYYY-MM-DD} ### Breaking Changes - Description (commit-hash) ### Features - Description (commit-hash) ### Bug Fixes - Description (commit-hash) ### Other Changes - Description (commit-hash) ``` ### Japanese changelog (`src/content/docs-ja/changelog/{NEW_VERSION}.mdx`) ```mdx --- title: {NEW_VERSION} description: {NEW_VERSION}のリリースノート。 sidebar_position: {value from script} --- リリース日: {YYYY-MM-DD} ### 破壊的変更 - Description (commit-hash) ### 機能 - Description (commit-hash) ### バグ修正 - Description (commit-hash) ### その他の変更 - Description (commit-hash) ``` Rules: - Only include sections that have entries - Use today's date for the release date - Each entry should be the commit subject with the short hash in parentheses ## Build and test Run the full build and test suite to make sure everything is good: ```bash pnpm b4push ``` If anything fails, fix the issue and re-run. Do not proceed with committing until all checks pass. ## Commit changes Stage and commit **all** version bump changes — include any files modified by b4push formatting fixes: ```bash git add package.json src/content/docs/changelog/{NEW_VERSION}.mdx src/content/docs-ja/changelog/{NEW_VERSION}.mdx # Also stage any other modified files (e.g. formatting fixes from b4push) git diff --name-only | xargs git add git commit -m "chore: Bump version to v{NEW_VERSION}" ``` ## Push and wait for CI Push the commits first (without the tag) and wait for CI to pass: ```bash git push ``` Then check CI status. Use `gh run list --branch main --limit 1 --json status,conclusion,headSha` and verify the `headSha` matches the pushed commit. Poll every 30 seconds, with a **maximum of 10 minutes**. If CI is still running after 10 minutes, ask the user whether to keep waiting or proceed. If CI fails, investigate the failure with `gh run view --log-failed`, fix the issue, commit, and push again. **Do not tag or publish until CI is green.** ## Tag, push tag, and create GitHub release **Ask the user for confirmation before tagging.** ```bash git tag v{NEW_VERSION} git push --tags ``` After pushing the tag, create a GitHub release. Use `awk` to strip only the YAML frontmatter (first `---` to second `---`) from the changelog file: ```bash NOTES=$(awk 'BEGIN{f=0} /^---$/{f++; next} f>=2' src/content/docs/changelog/{NEW_VERSION}.mdx) gh release create v{NEW_VERSION} --title "v{NEW_VERSION}" --notes "$NOTES" ``` ## Publish to npm (if applicable) If the package is **not** marked as `"private": true` in `package.json`, tell the user to publish: ``` The package is ready for npm publishing. Run: pnpm publish (This requires browser-based 2FA and must be done manually.) ``` If the package is `"private": true`, skip this step and inform the user: ``` Package is marked as private — skipping npm publish. ``` ## Done Report the summary: - Version bumped: `{OLD_VERSION}` → `{NEW_VERSION}` - Changelog created (EN + JA) - Git tag: `v{NEW_VERSION}` - GitHub release: link to the release - npm publish status (published / skipped for private package) --- # Admonitions preset > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/admonitions-preset The `admonitionsPreset` feature registers the seven built-in admonition directive types: `note`, `tip`, `warning`, `danger`, `info`, `details`, and `caution`. It also accepts an object form to register project-specific directives and optionally suppress the defaults. ## Enable ```ts // zfb.config.ts markdown: { features: { admonitionsPreset: true, }, }, }); ``` ## Usage Use the CommonMark Directives syntax with `:::` fences, separating each fence from surrounding content with blank lines: ```md :::note[Optional title] Note body text. ::: :::tip Tip body text. ::: :::warning Warning body text. ::: ``` All seven kinds: `note`, `tip`, `warning`, `danger`, `info`, `details`, `caution`. The bracketed `[label]` is promoted to a `title="…"` attribute on the emitted JSX element: ```jsx Note body text. ``` The `:::details` kind also accepts the legacy unbraced form for `title`: ```md :::details title="Click me" Hidden content. ::: ``` ## Blank-line requirement Each fence line (`:::note` and the closing `:::`) **must** be separated from surrounding content by blank lines so the Markdown parser treats them as separate paragraphs. Without blank lines the content is not recognised as a directive and a warning diagnostic is emitted at build time. ## Registering your own directives Pass an options object instead of `true` to add project-specific `:::name` directives or replace built-ins. ### Short form — bare component name The simplest entry maps a directive name to a component identifier string. The directive is registered as a container with `title_from_label: true` (the `[label]` becomes a `title="…"` attribute). ```ts // zfb.config.ts markdown: { features: { admonitionsPreset: { extraDirectives: { spoiler: "Spoiler", }, }, }, }, }); ``` `:::spoiler` in Markdown now emits ``. ### Object value form — full spec Use an object value to control the directive shape (`container`, `leaf`, or `text`) and whether the bracketed label becomes a `title` attribute. ```ts // zfb.config.ts markdown: { features: { admonitionsPreset: { extraDirectives: { kbd: { component: "Kbd", kind: "text", titleFromLabel: false, }, }, }, }, }, }); ``` `:kbd[Ctrl+C]` in Markdown emits `Ctrl+C`. ### Overriding a built-in `extraDirectives` entries are registered after the standard seven, so a name collision replaces the built-in: ```ts // zfb.config.ts markdown: { features: { admonitionsPreset: { extraDirectives: { caution: "MyCaution", }, }, }, }, }); ``` `:::caution` now emits `` instead of ``. ### Suppressing the built-ins entirely Set `extendDefaults: false` to skip the standard seven and register only what you declare: ```ts // zfb.config.ts markdown: { features: { admonitionsPreset: { extendDefaults: false, extraDirectives: { callout: "Callout", aside: "Aside", }, }, }, }, }); ``` Only `:::callout` and `:::aside` are registered. None of the standard seven kinds work. ### Important: registering a directive does not provide a component zfb is framework-agnostic and ships no admonition React (or other) components. Registering a directive only tells the pipeline to emit `` in compiled MDX. You must: 1. Author the component yourself (e.g. in `_mdx-components.ts`). 2. Add it to your project's [MDX components map](/concepts/mdx-components). 3. Style it with your own CSS. ### Casing and verbatim emit The value you supply (`"Spoiler"`, `"MyCaution"`, `"Kbd"`) is the JSX component identifier emitted verbatim — there is no auto-PascalCasing and no validation. `caution: "Caution"` emits ``; `caution: "my-caution"` emits `` (a DOM element, likely not what you want). The directive-name key follows the directive grammar `[A-Za-z_][A-Za-z0-9_-]*`. A key that does not match this pattern will simply never match any `:::name` in source. The object value form (`{ component, kind, titleFromLabel }`) is forward-compatible with a planned top-level generic `directives` feature that will unify all directive registrations in one place. No migration will be needed. ## See also - [Directives registry](/markdown-features/directives-registry) — the underlying Core primitive. - [Custom Directives](/concepts/custom-directives) — register directives via the Rust pipeline API. --- # Code-block enrichment > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/code-enrichment The `codeEnrichment` feature adds two interactive code-block enrichments: 1. **Diff markers** — annotate added/removed lines via `// [!code ++]` / `// [!code --]` comments. 2. **Line highlighting** — highlight specific lines by adding a range like `{1,3-5}` to the fence info-string. Both behaviors run as a hast-phase visitor after the syntect highlighter, operating on the per-line `` structure it emits. Reference: [rehype-pretty-code](https://rehype-pretty-code.netlify.app/). ## Enable ```ts // zfb.config.ts markdown: { features: { codeEnrichment: {}, }, }, }); ``` Both `diffMarkers` and `lineHighlight` are **on by default** when the feature is enabled. To disable either independently: ```ts codeEnrichment: { diffMarkers: false, // disable diff-marker processing lineHighlight: true, }, ``` ## Diff markers Add `// [!code ++]` or `// [!code --]` as a comment at the end of a line. The marker comment is stripped from the visible output. The matching `` receives `data-line-diff="added"` or `data-line-diff="removed"`. ````md ```js const unchanged = 1; const removed = 2; // [!code --] const added = 3; // [!code ++] ``` ```` Use these data attributes in CSS to style the diff: ```css span.line[data-line-diff="added"] { background: rgba(0, 255, 0, 0.1); } span.line[data-line-diff="removed"] { background: rgba(255, 0, 0, 0.1); } ``` Supported comment styles: `//` (JS/TS/Rust), `#` (Python/Ruby/Shell), `--` (SQL/Lua). ## Line highlighting Add a brace-delimited range after the language identifier in the fence info-string. Matching lines receive `data-line-highlight="true"` on their ``. ````md ```js {1,3-5} const a = 1; const b = 2; const c = 3; const d = 4; const e = 5; const f = 6; ``` ```` Lines 1, 3, 4, and 5 get `data-line-highlight="true"`. Style them in CSS: ```css span.line[data-line-highlight="true"] { background: rgba(255, 255, 0, 0.1); } ``` The range syntax supports: - Single numbers: `{3}` - Ranges: `{3-5}` (inclusive) - Combinations: `{1,3-5,8}` ## Combining both Diff markers and line highlighting work independently on each line, so the same line can have both: ````md ```js {2} const x = 1; // [!code ++] const y = 2; ``` ```` Line 1 gets `data-line-diff="added"` (diff marker). Line 2 gets `data-line-highlight="true"` (highlight range). --- # Code tabs > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/code-tabs The `codeTabs` feature converts a `:::code-group` container into a `` JSX element. Code blocks inside the container become tab panels. The tab label comes from the `title="…"` meta on each fence, falling back to the language ID, and finally `"tab"` when neither is set. ## Enable ```ts // zfb.config.ts markdown: { features: { codeTabs: true, }, }, }); ``` ## Usage Wrap two or more fenced code blocks in a `:::code-group` container. Use `title="…"` in the opening fence to give each tab a human-readable label. ~~~md :::code-group ```ts title="index.ts" ``` ```js title="index.js" ``` ```py title="index.py" greeting = "hello" ``` ::: ~~~ ## Output shape The directive emits a `` JSX element. The tab labels are passed as a JS array expression on the `tabs` prop. Each code block becomes a `` child with a `data-lang` attribute. ```html export const greeting = "hello"; export const greeting = "hello"; greeting = "hello" ``` The framework does not ship a built-in `` component — you supply one in your project and register it with zfb's component map. Here is a minimal Preact example: ```tsx title="src/components/CodeGroup.tsx" interface Props { tabs: string[]; children: preact.ComponentChildren[]; } const [active, setActive] = useState(0); return ( {tabs.map((label, i) => ( setActive(i)} > {label} ))} {children.map((panel, i) => ( {panel} ))} ); } ``` ## Tab label fallback If a code block has no `title=` meta, the language ID is used as the tab label. If neither is available, the label falls back to the literal string `"tab"`. ~~~md :::code-group ```ts // Tab label → "ts" const x = 1; ``` ``` // Tab label → "tab" (no lang, no title) plain text ``` ::: ~~~ ## Typed attribute schema The `code-group` directive declares a `name` attribute in its typed schema (`AttrType::String`, optional). This attribute is reserved for consumer-side use such as persisting the active tab across page navigation. It has no effect on the rendered output in the current release. ## Edge cases - **Empty container** — a `:::code-group` with no fenced code blocks is left unchanged in the tree. The pipeline treats it as an unknown directive and emits it as plain paragraph text. - **Non-code children** — only fenced code blocks contribute tab panels. Any other block content inside the container (paragraphs, lists, etc.) is silently dropped. - **Nested containers** — the inner `:::` closes the outer group. Nesting `:::code-group` inside another `:::code-group` is not supported and produces an empty inner group. --- # GitHub alerts > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/github-alerts The `githubAlerts` feature rewrites GitHub-style alert blockquotes into JSX components. This is the same syntax that GitHub renders natively in README files and issue comments. ## Enable ```ts // zfb.config.ts markdown: { features: { githubAlerts: true, }, }, }); ``` ## Usage Use the `> [!TYPE]` prefix on a blockquote. The type is case-insensitive. ```md > [!NOTE] > Useful information that users should know. > [!TIP] > Helpful advice for doing things better or more easily. > [!IMPORTANT] > Key information users need to know to achieve their goal. > [!WARNING] > Urgent info that needs immediate user attention to avoid problems. > [!CAUTION] > Advises about risks or negative outcomes of certain actions. ``` ## Supported types | Prefix | Component | |---------------|----------------| | `[!NOTE]` | `` | | `[!TIP]` | `` | | `[!IMPORTANT]`| `` | | `[!WARNING]` | `` | | `[!CAUTION]` | `` | ## Notes - **No inline titles.** The `[!TYPE]` prefix is the only supported syntax. A trailing label like `[!NOTE] My Title` is treated as a plain blockquote (not an alert). - **Multiple paragraphs** inside an alert are supported — all body content is placed inside the JSX element. - **Coexists with `admonitionsPreset`.** Both features can be enabled simultaneously. GitHub-style alerts are converted before the admonitions pass runs, so the two syntaxes are completely independent. --- # GitHub autolinks > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/github-autolinks The `githubAutolinks` feature rewrites bare issue, pull-request, and commit-SHA references in plain text into `` hyperlinks pointing at a configured GitHub repository. This mirrors the autolink behaviour that GitHub renders natively in README files and issues. ## Enable The `repo` field is required. The feature is silently inactive when it is absent. ```ts // zfb.config.ts markdown: { features: { githubAutolinks: { repo: "owner/repo", }, }, }, }); ``` ## Recognised patterns | Pattern | Example | Output URL | |---------|---------|------------| | Bare issue / PR | `#123` | `https://github.com/{repo}/issues/123` | | Cross-repo issue / PR | `user/repo#456` | `https://github.com/user/repo/issues/456` | | Commit SHA (7–40 hex chars) | `abc1234` | `https://github.com/{repo}/commit/abc1234` | ### Bare issue reference ```md See #123 for the fix. ``` Renders as: `See #123 for the fix.` ### Cross-repo reference ```md Backported from other/project#7. ``` Renders using the inline owner/repo, not the configured repo. ### Commit SHA A 7–40 character lowercase hex string surrounded by word boundaries is treated as a commit SHA. ```md Fixed in abc1234. ``` ## Exclusions References inside code spans and fenced code blocks are **never rewritten**: ````md Use `#123` as a literal reference — no link produced. ``` #123 and abc1234 are not linked in code blocks. ``` ```` ## Notes - **Cross-repo references** always use the owner/repo embedded in the text, not the configured `repo`. - **SHA disambiguation**: a run of 7–40 characters that is all decimal digits is treated as a plain number, not a SHA. Mixed hex (e.g. `abc1234`) is linked. - **Existing links** are not double-wrapped — text inside `` elements is left unchanged. --- # Heading-marker TOC > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/heading-marker-toc The `headingMarkerToc` feature inserts a nested `/` table of contents immediately after the first heading whose text matches a configured anchor string (default `"TOC"`). ## Enable ```ts // zfb.config.ts markdown: { features: { headingMarkerToc: { heading: "TOC", // default — heading text that triggers insertion maxDepth: 2, // default — 1 = h2 only, 2 = h2+h3, …, max 5 }, }, }, }); ``` Pass `true` to use all defaults: ```ts headingMarkerToc: true, ``` ## Usage Place a heading with the configured anchor text in your Markdown file: ```md ## TOC ## Introduction ### Background ## Conclusion ``` The plugin inserts a `` list after the `## TOC` heading containing links to all subsequent headings within `maxDepth` levels: ```html Introduction Background Conclusion ``` ## Options | Option | Default | Description | |--------|---------|-------------| | `heading` | `"TOC"` | Heading text that triggers TOC insertion. Matched case-insensitively after whitespace trimming. | | `maxDepth` | `2` | Heading levels to include starting from ``. `1` → h2 only, `2` → h2+h3, `3` → h2–h4, max `5` (h2–h6). | ## Visitor ordering `TocPlugin` runs **after** `HeadingLinksPlugin` in the hast phase so that `id` attributes (deduplicated slugs) are already set on each heading when the TOC links are built. Each `` in the TOC mirrors the final, deduplicated slug. --- # Image dimensions > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/image-dimensions The `imageDimensions` feature reads image file headers at build time and injects `width` and `height` attributes on `` elements that reference local files. Providing these attributes lets browsers allocate the correct space before the image loads, eliminating Cumulative Layout Shift (CLS). Reference: [`rehype-img-size`](https://www.npmjs.com/package/rehype-img-size). ## Enable ```ts // zfb.config.ts markdown: { features: { imageDimensions: {}, }, }, }); ``` ## Behaviour For each `` element, the plugin: 1. Skips elements that already carry `width` or `height` attributes. 2. Skips remote URLs (`http://`, `https://`, `//`) and `data:` URLs. 3. Resolves the `src` to an absolute path (see **Source resolution** below). 4. Reads only the image file header — no full decode occurs — and injects `width="W" height="H"`. 5. If the file cannot be found or is not a recognised image format, emits a build warning and leaves the element unchanged. Supported formats: **PNG, JPEG, GIF, WebP, AVIF**. ## Source resolution The plugin resolves `src` values against the file system using `BuildContext`: - **Absolute paths** (e.g. `/img/hero.png`) are resolved against the project's `public` directory. - **Relative paths** (e.g. `./assets/hero.png`, `assets/hero.png`) are resolved relative to the markdown source file's directory. - **Remote and `data:` URLs** are skipped silently. ## Options - `skipRemote` (default `true`) — when `true`, `http://` and `https://` image sources are skipped. Set to `false` only for unusual setups; probing remote images requires network access at build time. ```ts imageDimensions: { skipRemote: false }, ``` ## Example Given a 400×300 PNG at `public/images/hero.png` and this markdown: ```md ![Hero image](/images/hero.png) ``` The plugin produces: ```html ``` An `` with explicit dimensions is left unchanged: ```md ``` ## Caching The plugin caches `(path, mtime) → (width, height)` in memory. A second `` reference to the same file within the same build hits the cache rather than re-reading the file header. ## Ordering `ImageDimensionsPlugin` runs in the hast phase, before `SyntectPlugin`. It injects `width` and `height` on `` elements found in the document. ## Requirements This feature uses the wave-6 `BuildContext` seam. The pipeline must be invoked via `Pipeline::run_with_context` (or `Pipeline::apply_hast_visitors_with_context`) for dimensions to be injected. When the feature is wired but `run` (without context) is called, the plugin is a no-op. --- # Link validation > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/link-validation The `linkValidation` feature walks every `` and `` in the build and validates that internal links and anchor fragments resolve correctly. Broken links produce build diagnostics — warnings by default, errors when `failOnBroken: true` is set. External URLs (`http://`, `https://`, `mailto:`, etc.) are silently skipped by default. ## Enable ```ts // zfb.config.ts markdown: { features: { linkValidation: {}, // defaults: warn-only, skip external URLs }, }, }); ``` To make broken links fail the build: ```ts linkValidation: { failOnBroken: true }, ``` ## What is validated - **Bare anchor fragments** — `#section-id` must match a heading ID in the current file. - **File links without anchor** — `./other.md` must resolve to an existing file under the project root. - **File links with anchor** — `./other.md#section-id` requires both a resolvable file and a matching heading ID in that file. Heading IDs come from [`HeadingLinksPlugin`](/docs/markdown-features/heading-links), which runs earlier in the same hast phase. The cross-file heading-ID registry is populated during the build so anchor validation works across files. ## What is skipped - External URLs starting with `http://`, `https://`, `mailto:`, or `tel:`. - Links in files rendered without a `BuildContext` (e.g. simple in-memory pipeline calls without context). ## Options - `failOnBroken` — when `true`, broken links emit `Error` diagnostics (build fails). Default: `false` (warnings only). - `allowExternal` — when `false`, external URLs are no longer silently skipped (network validation is still out of scope; the flag is reserved for future use). Default: `true`. ## Diagnostic format Diagnostics follow the shared `BrokenLink` variant in `MarkdownDiagnostic`: - `severity` — `Warning` or `Error` depending on `failOnBroken`. - `url` — the raw href or src value as written by the author. - `location.path` — absolute path of the source file containing the broken link. ## Phase Runs in the **hast phase**, very late — after `HeadingLinksPlugin` has assigned stable IDs to all headings and populated the cross-file registry. Order in `register_features`: after `tocExport`. --- # Mermaid diagrams > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/mermaid The `mermaid` feature renders [Mermaid](https://mermaid.js.org/) diagrams from fenced code blocks at build time. ## Enable ```ts // zfb.config.ts markdown: { features: { mermaid: true, }, }, }); ``` ## Usage Write a fenced code block with the `mermaid` language tag: ````md ```mermaid graph TD; A-->B; A-->C; B-->D; C-->D; ``` ```` The plugin replaces the `` block with: ```html graph TD; ... ``` The `data-mermaid` attribute signals a client-side Mermaid renderer to process the block. The raw diagram source is embedded verbatim (angle brackets and other characters are NOT HTML-escaped) so the renderer receives the unmodified Mermaid DSL. ## Visitor ordering `MermaidPlugin` runs **before** `SyntectPlugin` in the hast phase. This ensures syntect does not attempt to syntax-highlight mermaid blocks; once the `` is replaced by a ``, syntect's `` selector no longer matches it. --- # Reading time > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/reading-time The `readingTime` feature walks the parsed markdown AST, counts words, and injects `export const readingTimeMinutes = N;` into the compiled MDX module. The export is then available as `entry.readingTimeMinutes` in any TSX page that imports the collection. ## Enable ```ts // zfb.config.ts markdown: { features: { readingTime: true, }, }, }); ``` To customise the words-per-minute rate: ```ts // zfb.config.ts markdown: { features: { readingTime: { wpm: 250 }, }, }, }); ``` ## Usage in a TSX page ```tsx // src/pages/blog/[...slug].tsx const { readingTimeMinutes, ...rest } = await entry.render(); // readingTimeMinutes is a number — use it in your layout: {readingTimeMinutes} min read ``` ## Word counting formula The plugin combines two passes: 1. **Latin-script text** — the text is split on whitespace; each token counts as one word. Punctuation attached to a token (e.g. `don't`, `hello-world`) counts as one word, consistent with `remark-reading-time`. 2. **CJK characters** — each character in the following Unicode blocks counts as one word, because CJK text does not use spaces between words: - CJK Unified Ideographs + Extension A (U+3400–U+9FFF) - CJK Compatibility Ideographs (U+F900–U+FAFF) - Hiragana (U+3040–U+309F) - Katakana (U+30A0–U+30FF) - Hangul Syllables (U+AC00–U+D7AF) Source: [remark-reading-time](https://www.npmjs.com/package/remark-reading-time) — "It also uses CJK character count for languages that don't use spaces between words." The total word count (Latin tokens + CJK characters) is divided by the WPM value, ceiling-rounded to the nearest whole minute, with a minimum of 1. ### Code blocks are excluded Fenced code blocks and inline code are **not** counted. A document whose code block contains hundreds of lines will not be over-estimated based on code content. ## Examples | Input | WPM | Result | |-------|-----|--------| | 200 Latin words | 200 (default) | 1 minute | | 600 Latin words | 200 (default) | 3 minutes | | 200 CJK characters | 200 (default) | 1 minute | | 5 words | 200 (default) | 1 minute (minimum) | | 400 words | 250 | 2 minutes | ## Notes - The minimum returned value is always **1 minute**, regardless of article length. - Reading time is computed against the **prose content only** — headings, paragraphs, lists, blockquotes, and emphasis all contribute; code blocks, raw HTML, and math blocks do not. - The export is injected at the **mdast phase** (before HTML/JSX conversion) so it is always consistent with the document content. --- # Ruby annotations > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/ruby The `ruby` feature parses `{base|ruby}` inline syntax and renders it as `baseruby` HTML — the standard markup for phonetic annotations such as Japanese furigana. ## Enable ```ts // zfb.config.ts markdown: { features: { ruby: true, }, }, }); ``` ## Usage Wrap the base text and its annotation in `{...}` separated by `|`: ```md {漢字|かんじ}を{学ぶ|まなぶ} ``` Renders as: ```html 漢字かんじを学ぶまなぶ ``` Multiple annotations in one paragraph are supported. Annotations can appear anywhere inline — at the start, middle, or end of a sentence: ```md 私は{東京|とうきょう}に住んでいます。 ``` ## Edge cases Inputs as authored in markdown (not in a table because the pipe in the ruby syntax collides with markdown table cell separators): - `` `{漢字|かんじ}` `` → `` element - `` `{|ruby}` `` (empty base) → left as literal text - `` `{base|}` `` (empty annotation) → left as literal text - `` `{text}` `` (no pipe) → left as literal text ## Scope limitation — no auto-furigana This feature **only** parses the explicit `{base|ruby}` syntax. It does **not** automatically detect kanji and add furigana through morphological analysis (e.g. kuromoji-style segmentation). Automatic furigana assignment is a separate, much harder problem that is intentionally out of scope. If you need auto-furigana, a separate dedicated feature would be required. --- # TOC export > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/toc-export The `tocExport` feature walks the document's headings and injects an MDX named export at the top of each processed file: ```ts ``` Frameworks consume `entry.toc` to render sidebars and floating-TOC components without scraping rendered HTML — the same pattern used by [Fumadocs](https://fumadocs.vercel.app/) and similar documentation frameworks. ## Enable ```ts // zfb.config.ts markdown: { features: { tocExport: {}, // defaults: maxDepth 3 (h2 + h3) }, }, }); ``` To cap the export at h2 only: ```ts tocExport: { maxDepth: 2 }, ``` ## Output shape Each entry in the exported array has the following fields: ```ts type TocEntry = { depth: number; // absolute heading depth: 2 | 3 | 4 | 5 id: string; // slug assigned by HeadingLinksPlugin text: string; // plain-text heading content (hash-link stripped) children: TocEntry[]; // nested sub-headings within maxDepth }; ``` ### Example For the Markdown source: ```md ## Introduction ### Background ## Conclusion ``` The plugin injects (before the document HTML): ```ts { "depth": 2, "id": "introduction", "text": "Introduction", "children": [ { "depth": 3, "id": "background", "text": "Background", "children": [] } ] }, { "depth": 2, "id": "conclusion", "text": "Conclusion", "children": [] } ]; ``` ## Options | Option | Default | Description | |--------|---------|-------------| | `maxDepth` | `3` | Maximum heading depth to include (absolute, 2–6). `2` → h2 only; `3` → h2 + h3; `4` → h2–h4; etc. | `maxDepth` for `tocExport` is an **absolute** depth (2 = h2, 3 = h3, …), unlike `headingMarkerToc.maxDepth` which counts levels *starting from h2*. The two features use different semantics intentionally — consult each feature's option reference. ## Relationship with `headingMarkerToc` `tocExport` and `headingMarkerToc` are **independent** features — either, both, or neither may be enabled. They do not interfere with each other: - `headingMarkerToc` inserts a `/` list into the document body after a designated anchor heading. - `tocExport` emits a structured `export const toc = [...]` for consumption by the framework's sidebar/TOC component. Enable both if you want in-body insertion AND a sidebar-ready data export simultaneously. ## Consuming `toc` in a TSX page After enabling `tocExport`, import the generated export in your framework's page wrapper: ```tsx return ( <> ); } ``` ## Visitor ordering `TocExportPlugin` runs **after** `HeadingLinksPlugin` in the hast phase so that the `id` attributes placed on each ``–`` are the final, deduplicated slugs. The `id` values in the exported `toc` array exactly match those in the rendered HTML — there is no drift. The export node (`JsxRaw`) is inserted at the **front** of the document root, matching the ESM convention that `export` statements appear at module top level. --- # Transclusion > Source: https://takazudomodular.com/pj/zudo-front-builder/docs/markdown-features/transclude The `transclude` feature lets you inline content from other files using the `:::include` directive. The referenced file is parsed as Markdown and its AST is merged into the including document before all other processing runs. ## Enable ```ts // zfb.config.ts markdown: { features: { transclude: {}, }, }, }); ``` Optional config: ```ts transclude: { maxDepth: 5, // maximum include chain depth (default 5) }, ``` ## Basic usage Use `:::include` with a `file` attribute pointing to the file you want to inline. The path is resolved relative to the **current source file**. ```md # My Document :::include{file="./snippets/intro.md"} More content follows. ``` The included file is inserted in place of the directive, so its headings, paragraphs, code blocks, and other elements appear directly in the output. ## Including source code Set `code=true` to treat the file contents as a code block instead of parsed Markdown. The `lang` attribute sets the syntax-highlighting language. ```md :::include{file="./examples/hello.rs" code=true lang="rust"} ``` ## Line ranges The `lines` attribute slices the file to the specified lines before including. Use with `code=true` to show a specific excerpt from a source file. ```md :::include{file="./src/lib.rs" code=true lang="rust" lines="10-30"} ``` Line numbers are 1-based and both endpoints are inclusive. ## Chaining Included files can themselves contain `:::include` directives. The default `maxDepth` of 5 allows chains up to five levels deep. Deeper chains are rejected with an error diagnostic. ## Cycle detection If file A includes file B and file B includes file A (directly or transitively), the build emits an Error diagnostic and skips the offending directive rather than looping indefinitely. ## Path restrictions - **Relative paths only** — absolute paths are rejected with an error diagnostic. - **Project-root confinement** — the resolved path must remain inside the project root. Path traversal (e.g. `../../outside`) is rejected. ## Blank-line requirement The `:::include` fence line must be separated from surrounding content by blank lines so the Markdown parser treats it as a block directive, not inline content.