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 full/root API (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).
Select the smallest entry
The root entry (@takazudo/zfb-md-wasm) remains the complete compatibility surface and the only entry that exports compile. Existing root imports and @takazudo/ imports require no migration. The additive / and / entries are isolated SWC-free graphs: they omit swc_core and zfb-render while intentionally retaining zfb-content and syntect-fancy; parse is not syntect-free.
| Import | gzip-9 wasm (2.15.0) | Runtime values | Type surface | Choose it for |
|---|---|---|---|---|
@takazudo/zfb-md-wasm | 1,516,383 B | init, compile, renderHtml, parseToAst, highlightCode, version, toMdastRoot, MdastAdapterError, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTests | Full current compile/render/parse/raw-mdast/highlight types | Compile MDX or use several current APIs |
@takazudo/ | 817,951 B | init, highlightCode, version, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTests | HighlightRole, HighlightCodeOptions, HighlightCodeResult, HighlightDiagnostic, HighlightDiagnosticSource | Highlight code only; existing API/resources stay compatible |
@takazudo/ | 1,091,678 B | init, renderHtml, version, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTests | RenderHtmlResult, Diagnostic, DiagnosticSource, ZfbMdWasmOptions, ParseDialect, PipelineOptions, GfmOptions, CodeHighlightMode, CodeHighlightOptions, MarkdownFeaturesConfig, JsxRuntime, HighlightRole | Render Markdown to HTML without compiler bytes |
@takazudo/ | 283,991 B | init, parseToAst, toMdastRoot, MdastAdapterError, version, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTests | ParseToAstResult, ParseToAstOptions, ParseDialect, FrontmatterPolicy, ParsePipelineOptions, Diagnostic, DiagnosticSource, AstPoint, AstPosition, RawMdastData, MarkdownRsStop, MdastNode, MdastRoot, UnknownMdastNode, Root, Paragraph, Heading, ThematicBreak, Blockquote, List, ListItem, Html, Code, Definition, Text, DirectiveNodeBase, ContainerDirective, LeafDirective, TextDirective, Emphasis, Strong, InlineCode, Break, Link, Image, ReferenceKind, LinkReference, ImageReference, FootnoteDefinition, FootnoteReference, TableAlign, Table, TableRow, TableCell, Delete, Yaml, MdxFlowExpression, MdxTextExpression, MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttributeContent, MdxJsxAttribute, MdxJsxAttributeValueExpression, MdxJsxExpressionAttribute | Parse and optionally interpret an AST in controlled consumer code |
Each focused entry owns a private resource pair:
wasm-render/zfb_md_wasm_render_glue.zfb-resource.mjs + zfb_md_wasm_render_bg.wasm
wasm-parse/zfb_md_wasm_parse_glue.zfb-resource.mjs + zfb_md_wasm_parse_bg.wasmThe declaration sidecars stay in their matching directories. Every entry has an independent compiled module, instance, generation, retry state, and terminal state. Importing multiple entries intentionally loads independent pairs and instances; select one entry per bundle when possible.
For direct Node use and browser-aware bundlers, import the focused subpath:
import { renderHtml } from "@takazudo/zfb-md-wasm/render";
import { parseToAst, toMdastRoot } from "@takazudo/zfb-md-wasm/parse";For lazy browser loading, import it only from the user action. The browser conditional export uses static URL edges to only that entry's pair:
renderButton.addEventListener("click", async () => {
const { renderHtml } = await import("@takazudo/zfb-md-wasm/render");
const { html } = await renderHtml(source, { filename: "preview.md" });
preview.innerHTML = html ?? "";
});
parseButton.addEventListener("click", async () => {
const { parseToAst, toMdastRoot } = await import("@takazudo/zfb-md-wasm/parse");
const parsed = await parseToAst(source, { filename: "preview.md" });
const root = parsed.ast === null ? null : toMdastRoot(parsed.ast);
inspect(root);
});compile() remains root-only and returns module source requiring host evaluation, a JSX runtime, and components. toMdastRoot() is a controlled adapter; consumer code may interpret parsed data in an AST-to-React renderer. No slim entry evaluates author JavaScript. renderHtml is not a sanitizer, raw HTML remains untrusted, and MDX JSX/expression/ESM-shaped AST nodes remain inert data.
Full/root API surface
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("Budget <8 ms\n", {
filename: "post.md",
});
// html -> "<p>Budget <8 ms</p>"renderHtml infers CommonMark for .md and MDX for .mdx; an explicit dialect: "markdown" | "mdx" overrides either valid extension. With no filename it uses <anonymous>.md, hence CommonMark. compile remains MDX-only and accepts/ignores dialect, while renderHtml accepts/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
Every package entry declares only its own static glue/wasm pair. Root keeps wasm/ and wasm/; the focused pairs are:
wasm-render/zfb_md_wasm_render_glue.zfb-resource.mjs
wasm-render/zfb_md_wasm_render_bg.wasm
wasm-parse/zfb_md_wasm_parse_glue.zfb-resource.mjs
wasm-parse/zfb_md_wasm_parse_bg.wasmA zfb production build emits each selected pair as hashed island assets:
assets/islands-resource-zfb_md_wasm_glue.zfb-resource-<hash>.mjs
assets/islands-resource-zfb_md_wasm_bg-<hash>.wasmThe focused assets follow the same naming scheme with render or parse in the stem (for example, islands-resource-zfb_md_wasm_render_bg-<hash>.wasm).
The . subpath retains 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 multiple entries in the same bundle intentionally loads each private pair and creates independent compiled-module/instance state. Pick one entry per bundle when possible; importing both is supported when both capabilities are required.
To keep them out of the initial page load, dynamically import the selected entry only from a user action. Its first public API call then fetches the glue and wasm:
button.addEventListener("click", async () => {
const { renderHtml } = await import("@takazudo/zfb-md-wasm/render");
const result = await renderHtml(editor.value, { filename: "preview.md" });
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.
Shipped artifact sizes and locked ceilings
These are the shipped 2.15.0 artifact rows — optimized final wasm after wasm-bindgen and wasm-opt, Node gzipSync(..., { level: 9 }), and glue bytes/gzip:
| Entry/graph | final wasm | gzip-9 | glue | glue gzip-9 |
|---|---|---|---|---|
| root (full) | 3,399,954 B | 1,516,383 B | 14,998 B | 4,199 B |
| highlight | 1,539,334 B | 817,951 B | 8,758 B | 2,637 B |
| render | 2,196,095 B | 1,091,678 B | 8,772 B | 2,661 B |
| parse | 700,364 B | 283,991 B | 11,159 B | 3,797 B |
The #2447 decision snapshot found the split package layout at 3,638,607 B versus 2,314,818 B for root plus highlight, a 210-second clean production reference ceiling, and a selected-snapshot median of 155.015 s [153.496, 165.977]. Locked gzip-9 ceilings are root 1,600,000 B, highlight 880,000 B, render 1,100,000 B, and parse 325,000 B; the complete packed tarball ceiling is 3,900,000 B. All four ship inside their ceilings, with 83,617 B (root), 62,049 B (highlight), 8,322 B (render), and 41,009 B (parse) of headroom. These are 2.15.0 measurements, not permanent promises — re-measure against the version you actually install.
Sizes are guarded; content digests are not. The byte sizes above are held by shipped-sizes.json and asserted in CI, so they move only on a deliberate artifact change. SHA-256 digests are a different matter: every release stamps its own version string into each .wasm — the value version() returns — so all four digests change on every release, including a documentation-only patch whose compiled code is identical and whose byte sizes do not move at all. If you verify these artifacts by content digest rather than by semver, re-pin on every upgrade; never read "sizes unchanged" as "nothing to re-verify".
Gating swc_core out of the highlight graph (#2449/#2450) was a provability win, not a size win. The #2447 SWC-retaining baseline was 1,484,705 B raw and 767,009 B gzip-9; the #2450 result was 7,965 B smaller raw and 8,765 B smaller gzip-9 (758,244 B) — wasm-opt was already dead-stripping the unreachable swc_core, and #2450's exact-parity and no-swc_core assertions turned that emergent property into a guaranteed one. The delta that matters to a highlight-only consumer is root versus highlight: the highlight artifact is 1,860,620 B smaller raw and 698,432 B smaller gzip-9, landing at about 45% of root's raw bytes and 54% of its gzipped bytes.
Node usage (tests and tooling)
The entries run under Node ≥ 20 with no extra setup. Use direct subpath imports to choose the matching resource pair for tooling and snapshot tests:
import { renderHtml } from "@takazudo/zfb-md-wasm/render";
import { parseToAst, toMdastRoot } from "@takazudo/zfb-md-wasm/parse";
const { html } = await renderHtml("# Hello from Node\n");
const parsed = await parseToAst("# Hello from Node\n", { filename: "post.md" });
const root = parsed.ast === null ? null : toMdastRoot(parsed.ast);Keep the root import when the same consumer also needs compile; root and highlight imports are backward-compatible.
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 root artifact is the compatibility/compiler entry. It carries the full graph because
compileis the only author-JavaScript execution path. Select/orrender /for focused SWC-free calls, andparse /for direct highlighting. The focused entries intentionally keephighlight zfb-content/syntect-fancy; parse is not syntect-free.renderHtmlis not a sanitizer. Raw HTML remains untrusted. MDX JSX, expression, and ESM-shaped AST nodes remain inert data; only controlled consumer code may interpret an already-parsed AST, and no slim entry evaluates author JavaScript.renderHtmlselects syntax from the filename..mduses CommonMark,.mdxuses MDX, and an explicitdialectoverrides either valid extension.compileremains MDX-only.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
These are the shipped 2.15.0 artifact rows — optimized final wasm after wasm-bindgen and wasm-opt, Node gzipSync(..., { level: 9 }), and glue bytes/gzip:
| Entry/graph | final wasm | gzip-9 | glue | glue gzip-9 |
|---|---|---|---|---|
| root (full) | 3,399,954 B | 1,516,383 B | 14,998 B | 4,199 B |
| highlight | 1,539,334 B | 817,951 B | 8,758 B | 2,637 B |
| render | 2,196,095 B | 1,091,678 B | 8,772 B | 2,661 B |
| parse | 700,364 B | 283,991 B | 11,159 B | 3,797 B |
The #2447 decision snapshot found the split package at 3,638,607 B versus 2,314,818 B for root plus highlight, a 210 s clean production ceiling, and a decision median of 155.015 s [153.496, 165.977]. Locked gzip-9 ceilings are root 1,600,000 B, highlight 880,000 B, render 1,100,000 B, and parse 325,000 B; the complete packed tarball ceiling is 3,900,000 B. All four ship inside their ceilings, with 83,617 B (root), 62,049 B (highlight), 8,322 B (render), and 41,009 B (parse) of headroom. These are 2.15.0 measurements, not permanent promises — re-measure against the version you actually install.
The digest caveat under Shipped artifact sizes and locked ceilings applies to this table too: these byte sizes are guarded, but the artifacts' SHA-256 digests move on every release.
Gating swc_core out of the highlight graph (#2449/#2450) was a provability win, not a size win. The #2447 SWC-retaining baseline was 1,484,705 B raw and 767,009 B gzip-9; the #2450 result was 7,965 B smaller raw and 8,765 B smaller gzip-9 (758,244 B) — wasm-opt was already dead-stripping the unreachable swc_core, and #2450's exact-parity and no-swc_core assertions turned that emergent property into a guaranteed one. The delta that matters to a highlight-only consumer is root versus highlight: the highlight artifact is 1,860,620 B smaller raw and 698,432 B smaller gzip-9, landing at about 45% of root's raw bytes and 54% of its gzipped bytes.
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
Playground — try the browser-based Rust pipeline interactively.
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.