Browser Markdown Preview
Compile MDX to a runnable ES module or render Markdown to HTML entirely in the browser, with output parity to zfb's build-time pipeline, via the @takazudo/zfb-md-wasm package.
What this page covers
@takazudo/zfb-md-wasm, a WebAssembly build of zfb's md/mdx → JS/HTML pipeline for browser-side dynamic conversion. The two API tiers (compile() andrenderHtml()), parsing to a raw mdast tree with parseToAst() (plus the validated toMdastRoot() adapter), direct semantic highlightCode(), how to evaluate a compiled module in a browser, using the package with Vite, Node usage for tests and tooling, and the parity guarantee's explicit limitations.
What it is and why it exists
@takazudo/zfb-md-wasm compiles zfb's own markdown/MDX → JS conversion pipeline to WebAssembly so it can run in a browser tab, not only at build time on your machine.
The headline use case is CMS live preview: as an editor types Markdown or MDX into a CMS, the preview pane should show exactly what zfb build would eventually produce — not an approximation. Running @mdx-js/mdx in the browser for this would use a different pipeline, with its own parsing, plugin, and JSX-emission behavior, so its preview can drift from zfb's real build output. zfb-md-wasm avoids that drift by compiling the same Rust pipeline zfb itself uses at build time, so a preview stays faithful to what actually ships. Parity is the entire reason this package exists instead of just reaching for @mdx-js/mdx.
Node ≥ 20 can also load the package — that's what its own test suite uses — but the browser is the target. A server with shell access to run the zfb binary directly should keep doing that instead of loading this package.
Note
Install with pnpm add @takazudo/zfb-md-wasm (or your package manager of choice).
Two API tiers
Both compile() and renderHtml() take a markdown/MDX source string and an options object — every field optional, {} selects all defaults — and both resolve to a result object with a diagnostics array. Expected failures never throw. Parse errors, malformed options JSON, unknown themes, and similar problems all come back as Diagnostic[] entries with code/html set to null, never as a thrown error. (A thrown ZfbMdWasmTrapError means an actual bug in the wasm build — see the package's own README, "Error / trap / re-init contract", for that narrower case.)
compile() — MDX to ES-module JS
Full MDX → JSX → SWC → ES module. The result's code is ES-module source with an MDXContent default export (a component function) using the automatic JSX runtime.
import { compile } from "@takazudo/zfb-md-wasm";
const { code, frontmatter, diagnostics } = await compile(
"---\ntitle: Hello\n---\n\n# Welcome\n\n<Callout>Sum is {1 + 2}</Callout>\n",
{ filename: "post.mdx", jsxRuntime: "preact" },
);
// code -> ES-module JS source (string), or null on failure
// frontmatter -> { title: "Hello" }
// diagnostics -> []Frontmatter values come back in the result's frontmatter field — they are not exposed as an in-content binding. Reference them from your host code (the frontmatter object above), not with {frontmatter.title} inside the source: the compiled module has no frontmatter variable in scope, so an in-content reference to it would throw ReferenceError when the module runs.
Evaluating the compiled module in a browser
compile() hands back ES-module source text, not a runnable module. Turn it into one with a blob URL and a dynamic import():
const { code, diagnostics } = await compile(source, { filename: "preview.mdx" });
if (code === null) {
// Compilation failed — render the `diagnostics` array instead of a module.
return;
}
const url = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
const { default: MDXContent } = await import(/* @vite-ignore */ url);
URL.revokeObjectURL(url);
// render <MDXContent components={{ Callout }} /> with your frameworkPass your PascalCase components (<Callout> in the source above) through the module's components prop — the same convention zfb uses for its own MDX Components.
Supplying the JSX runtime
The compiled module imports its JSX runtime by bare specifier — preact/jsx-runtime or react/jsx-runtime — so the page evaluating it must resolve those specifiers itself, via an import map or your bundler. zfb-md-wasm does not ship or bundle either runtime.
The Fragment quirk — preact consumers need one alias
zfb's emitter takes the JSX factory from your chosen jsxRuntime, but italways imports Fragment from react/jsx-runtime, regardless of which runtime you picked. This is zfb's production emitter shape — parity-correct, not a bug — but it means a preact consumer must aliasreact/jsx-runtime onto preact's own runtime, or Fragment fails to resolve at runtime:
<script type="importmap">
{
"imports": {
"preact/jsx-runtime": "https://esm.sh/preact/jsx-runtime",
"react/jsx-runtime": "https://esm.sh/preact/jsx-runtime"
}
}
</script>A react consumer just maps react/jsx-runtime to React's own runtime and needs no alias.
renderHtml() — Markdown to HTML
Markdown → hast → HTML string, skipping SWC at runtime. Reach for this when you only need a plain-markdown preview and don't need to evaluate a component module.
import { renderHtml } from "@takazudo/zfb-md-wasm";
const { html, frontmatter, diagnostics } = await renderHtml("# Heading\n\nSome **bold** text.\n", {
filename: "post.md",
});
// html -> "<h1>Heading</h1><p>Some <strong>bold</strong> text.</p>"renderHtml accepts and ignores jsxRuntime / development, so one options object can serve both tiers if you build it dynamically.
Parsing to an AST (parseToAst)
parseToAst() is a third pipeline tier, sitting one step earlier than renderHtml(): it stops right after parsing and hands back the raw mdast tree as JSON instead of continuing on to HTML or JS. Reach for it when your host needs to walk or transform the tree itself — a table of contents, a custom renderer, word-count/reading-time metadata, or anything else that needs structured access to the document rather than a finished string.
import { parseToAst, type ParseToAstOptions, type ParseToAstResult } from "@takazudo/zfb-md-wasm";
const result: ParseToAstResult = await parseToAst(
"# Welcome\n\nHello **world**.\n",
{ filename: "post.md" } satisfies ParseToAstOptions,
);
// result.ast -> { type: "root", position: {...}, children: [...] }
// result.frontmatter -> null (no frontmatter in this source)
// result.diagnostics -> []ParseToAstOptions
parseToAst takes its own distinct, closed options document — it is not the compile/renderHtml options shape:
filename— must end in lowercase.mdor.mdx. With no explicitdialect,.mdselects CommonMark and.mdxselects MDX; omittingfilenameuses<anonymous>.mdx, hence MDX. An explicitdialectoverrides either valid extension, but does not waive the extension gate itself.dialect—"markdown"or"mdx", inferred fromfilenameas above when omitted.directives— parse generic remark-directive syntax (:::name/::name/:name). Defaultfalse; directive-looking text survives exactly as markdown-rs parsed it when this is off.frontmatter— YAML handling policy, default"extract":Policy Parsed source AST yamlnodefrontmattervalueMalformed YAML extract(default)frontmatter-stripped body none parsed JSON / nullone frontmatterdiagnosticnodefull logical source canonical yamlnodeparsed JSON / nullone frontmatterdiagnosticnonefull logical source, every byte as Markdown/MDX none always nullnever produces a frontmatterdiagnosticpipeline.gfm— the five booleanGfmOptionsswitches only (strikethrough,table,autolinkLiteral,taskListItem,footnoteDefinition).parseToAst's pipeline options are stricter thancompile/renderHtml's: it rejectstheme,cjkFriendly,hardBreaks,codeHighlight, andfeaturesoutright (deny_unknown_fields) rather than silently accepting and ignoring visitor/serializer-only knobs it can never apply.
ParseToAstResult
interface ParseToAstResult {
ast: MdastRoot | null; // serialized raw mdast root on success, null on failure
frontmatter: unknown; // parsed YAML frontmatter as JSON, per the policy table above
diagnostics: Diagnostic[]; // same Diagnostic shape as compile()/renderHtml()
}ast is markdown-rs's raw mdast tree converted through its serde shape into an open, unist-shaped carrier — every documented node keeps its type, position, and node-specific fields, but this is pre-zfb-visitors raw output, not the HTML/JS a rendered pipeline would produce.
UTF-16 positions
Every node in the tree carries position: { start, end }, each an AstPoint with line (1-based), column (1-based), and offset (0-based). column and offset are UTF-16 code units — the same indexing String.prototype.slice and remark/unist's own convention use — not Unicode scalar values: a non-BMP scalar (most emoji) is a surrogate pair, so it advances offset/column by 2, not 1. Positions are reported against the original source, frontmatter lines included, even under frontmatter: "extract" where the body markdown-rs actually parses has had those lines stripped.
The open node union does not narrow on `type` alone
MdastNode includes UnknownMdastNode as a catch-all for any node type not in the documented set, and that catch-all's type field is the generalstring — not a literal. That keeps unrecognized node types typed instead of falling back to unknown, but it also means a plain equality check doesnot narrow away the catch-all:
// child: MdastNode
if (child.type === "heading") {
child.depth; // still a type error — TypeScript can't rule out UnknownMdastNode here
}Assert the node kind explicitly once you've confirmed the shape at runtime, or use toMdastRoot() below, which returns a tree that narrows correctly without a cast.
toMdastRoot() — a validated, narrowing adapter
parseToAst's raw ast is forward-compatible by design: unrecognized nodes survive as UnknownMdastNode instead of breaking the call. toMdastRoot() is the opposite, stricter tool — it validates a raw tree and returns a detached, ecosystem-shaped mdast Root that narrows on type the way @types/mdast consumers expect:
import { parseToAst, toMdastRoot, MdastAdapterError } from "@takazudo/zfb-md-wasm";
const { ast } = await parseToAst("# Welcome\n\nHello **world**.\n");
try {
const root = toMdastRoot(ast);
const heading = root.children[0];
if (heading.type === "heading") {
heading.depth; // narrows correctly — no assertion needed
}
} catch (error) {
if (error instanceof MdastAdapterError) {
// error.path -> e.g. "$.children[0]"
// error.nodeType -> the offending node's `type`, or null for a null ast
}
}toMdastRoot() throws MdastAdapterError (a TypeError subclass carrying .path and .nodeType) instead of diagnosing — it does not return a result object. It throws on:
a
nullast(path"$"), andany unknown or unsupported node type, explicitly including
math,toml, andmdxjsEsm.
Because it throws, check diagnostics first whenever a parse failure is expected — toMdastRoot() is for turning an already-successful ast into a narrowing tree, not for distinguishing parse failure from adapter failure.
Documented divergences from remark
mdxJsxAttribute(and its value/expression-attribute siblings) carry noposition— markdown-rs does not model attribute positions.Top-level
import/exportdegrade to plain paragraphs (nomdxjsEsmnodes) — the wasm boundary cannot host a JS ESM/acorn parser. Consumers needing remark-mdx-equivalent ESM/estree data keep remark for those documents._markdownRsStops(carried by MDX expression/ESM-shaped nodes) is markdown-rs-internal bookkeeping, unstable, and UTF-8 byte-based — unlikeposition, it does not share the UTF-16 code-unit contract above. Never slice a string with it.
Using with Vite
parseToAst() (and every other @takazudo/zfb-md-wasm export) needs no Vite plugin, alias, or config to work — a plain vite devDependency is enough. The package's browser export condition and its ?url asset-import contract for the glue/wasm resources both resolve automatically under Vite's default dev server and production build (and equally under zfb's own esbuild pipeline). The server MIME requirement still applies in production: serve the emitted .mjs as application/javascript and the .wasm as application/wasm.
Direct semantic code highlighting
highlightCode() is the direct root API for arbitrary HTML, CSS, JavaScript, or another bundled syntax. It does not need a Markdown fence and never emits inline colours or Shiki classes; it returns escaped, semantic class-mode HTML.
import {
highlightCode,
type HighlightCodeOptions,
type HighlightCodeResult,
} from "@takazudo/zfb-md-wasm";
const output: HighlightCodeResult = await highlightCode("const answer = 42;", {
language: "javascript", // required
mode: "class", // optional; the only accepted mode
classPrefix: "hi-", // optional; defaults to "hi-"
roleClasses: { keyword: "text-violet-600 dark:text-violet-400" },
} satisfies HighlightCodeOptions);type HighlightRole =
| "escape"
| "operator"
| "comment"
| "string"
| "number"
| "constant"
| "keyword"
| "function"
| "type"
| "namespace"
| "property"
| "variable"
| "tag"
| "attribute"
| "punctuation"
| "inserted"
| "deleted"
| "heading";
interface HighlightCodeOptions {
language: string;
mode?: "class";
classPrefix?: string;
roleClasses?: Partial<Record<HighlightRole, string>>;
}
interface HighlightCodeResult {
html: string | null;
diagnostics: HighlightDiagnostic[];
}The default output is <pre class="hi-root"><code>…</code></pre>, with a <span class="line"> for every non-empty line. The fixed full-name role keys and default hi- classes are: escape → hi-esc, operator → hi-op, comment → hi-com, string → hi-str, number → hi-num, constant → hi-const, keyword → hi-kw, function → hi-fn, type → hi-ty, namespace → hi-ns, property → hi-prop, variable → hi-var, tag → hi-tag, attribute → hi-attr, punctuation → hi-punct, inserted → hi-ins, deleted → hi-del, and heading → hi-hd.
roleClasses keys are the full names in that list. For example, { keyword: "my-keyword" } replaces hi-kw; kw is not a valid override key. A classPrefix changes both the root (token-root for "token-") and all unoverridden token classes.
Invalid options (for example a missing language, unsupported mode, invalid prefix, unrecognised role key, or extra property) return html: null with an error diagnostic from source: "options"; they do not throw. An unknown non-empty language is a successful escaped fallback with one warning diagnostic from source: "highlight" and null location fields. Incomplete editor input is accepted and can return normal markup without a diagnostic.
Lazy browser resources and server MIME
The package's browser export declares two static resources: zfb_md_wasm_glue.zfb-resource.mjs and zfb_md_wasm_bg.wasm. A zfb production build emits hashed island assets named:
assets/islands-resource-zfb_md_wasm_glue.zfb-resource-<hash>.mjs
assets/islands-resource-zfb_md_wasm_bg-<hash>.wasmThe . subpath declares its own, separate pair of resources instead — zfb_md_wasm_highlight_glue.zfb-resource.mjs and zfb_md_wasm_highlight_bg.wasm — hashed the same way:
assets/islands-resource-zfb_md_wasm_highlight_glue.zfb-resource-<hash>.mjs
assets/islands-resource-zfb_md_wasm_highlight_bg-<hash>.wasmImporting both @takazudo/zfb-md-wasm and @takazudo/ in the same bundle loads both wasm artifacts — pick one entry per bundle.
To keep them out of the initial page load, dynamically import the root only from a user action. The first public API call then fetches the glue and wasm:
button.addEventListener("click", async () => {
const { highlightCode } = await import("@takazudo/zfb-md-wasm");
const result = await highlightCode(editor.value, { language: "javascript" });
preview.innerHTML = result.html ?? "";
});Serve emitted .mjs as application/javascript and .wasm as application/wasm. Do not manually copy the resources or import a package source path: use the packed browser entry so zfb can maintain the hashed URL graph.
Node usage (tests and tooling)
The same compile / renderHtml / highlightCode / version API runs under Node ≥ 20 with no extra setup — this is exactly how the package's own vitest suite exercises the wasm build. Useful for snapshot-testing preview output, or for tooling that wants parity checks without shelling out to the zfb binary.
import { renderHtml } from "@takazudo/zfb-md-wasm";
const { html } = await renderHtml("# Hello from Node\n");Parity guarantee and limitations
Output matches zfb's native pipeline on a fixed fixture corpus — the parity test suite gates on an exact match. That guarantee comes with deliberate limitations for the browser build:
Filesystem-bound plugins are inert.
transclude,imageDimensions, andlinkValidationare registered but never touch a filesystem — there is none in a browser tab — exactly as zfb's own MDX loader runs them with build-context roots unarmed. Host-callback versions (letting a browser host supply file contents on demand) are a possible future epic, not implemented here.Config is resolved JSON, not
zfb.config.ts. Evaluating a TypeScript config needs a JS engine, and that stays build-side. Resolve your config to JSON first — the same shape zfb derives fromzfb.config.tsat build time, see Customizing Markdown — and pass it as thepipelineoption.No cross-file features. Route-table link resolution and cross-file anchor resolution need the whole project's route graph, which a single-document browser call never has.
The default wasm artifact carries SWC even for
renderHtml-only use. A single cdylib can't tree-shake SWC out of the download just because a consumer only ever callsrenderHtml. A slimrenderHtml-only artifact is a documented possible follow-up, not implemented here.A slim
highlightCode-only artifact IS available via the.export subpath. It drops the entire markdown/MDX/JSX pipeline (SWC) and exports only/ highlight init,highlightCode, andversion— nocompileorrenderHtml. It is roughly half the download of the default artifact (~52% smaller raw, ~45% smaller gzipped) while keeping the full grammar set; both artifacts highlight byte-identically against the shared oracle. Import it as@takazudo/when a consumer only highlights code.zfb- md- wasm/ highlight Syntax highlighting uses syntect's
fancy-regexbackend, not native zfb'sonigurumabackend (onigurumacan't compile to wasm). See Syntax Highlighting for the native-side behavior. The two backends are byte-identical on zfb's fixture corpus; any grammar-level divergence is tracked by the crate's own informational backend-divergence test.
Bundle size
Shipping SWC in the wasm bytes makes this a large module by design — the build applies a size-optimized profile (opt-level = "z", LTO, one codegen unit, panic = "abort") plus wasm-opt to cut it down. Don't rely on a specific number here; it drifts release to release. The CI wasm-md job prints the authoritative gzipped size on every run — check that job's size line for the current figure rather than trusting a number written into this page.
Trap recovery
Expected highlightCode option errors and unknown-language fallbacks are normal result diagnostics. A real wasm trap is different: the affected call throws ZfbMdWasmTrapError, because that instance is poisoned. The wrapper immediately starts a fresh instance from its cached compiled wasm module, so the next compile, renderHtml, highlightCode, or version call uses the replacement without a second wasm fetch/compile. Browser recovery imports a new glue-module generation (?zfbMdWasmGen=N) and is capped at 16 recoveries to avoid unbounded module records. A trap is always a package bug; report the input that triggered it.
See also
MDX Components — the
componentsprop conventioncompile()'s output follows.Customizing Markdown — how zfb resolves the pipeline config that the
pipelineoption mirrors.Syntax Highlighting — native zfb's syntect setup, for comparison with the wasm build's
fancy-regexbackend.@takazudo/zfb-md-wasmAPI reference — the full option/result type reference forcompile,renderHtml,parseToAst,toMdastRoot, andhighlightCode.Importing Wasm — loading Wasm modules on the SSR side, for comparison with this page's browser-side usage.