zfb
GitHub repository

Type to search...

to open search from anywhere

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/zfb-md-wasm/highlight imports require no migration. The additive /render and /parse 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.

Importgzip-9 wasm (2.15.0)Runtime valuesType surfaceChoose it for
@takazudo/zfb-md-wasm1,516,383 Binit, compile, renderHtml, parseToAst, highlightCode, version, toMdastRoot, MdastAdapterError, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTestsFull current compile/render/parse/raw-mdast/highlight typesCompile MDX or use several current APIs
@takazudo/zfb-md-wasm/highlight817,951 Binit, highlightCode, version, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTestsHighlightRole, HighlightCodeOptions, HighlightCodeResult, HighlightDiagnostic, HighlightDiagnosticSourceHighlight code only; existing API/resources stay compatible
@takazudo/zfb-md-wasm/render1,091,678 Binit, renderHtml, version, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTestsRenderHtmlResult, Diagnostic, DiagnosticSource, ZfbMdWasmOptions, ParseDialect, PipelineOptions, GfmOptions, CodeHighlightMode, CodeHighlightOptions, MarkdownFeaturesConfig, JsxRuntime, HighlightRoleRender Markdown to HTML without compiler bytes
@takazudo/zfb-md-wasm/parse283,991 Binit, parseToAst, toMdastRoot, MdastAdapterError, version, ZfbMdWasmTrapError, ZfbMdWasmTrapRecoveryLimitError, __forceTrapForTests, __getTrapRecoveryStateForTestsParseToAstResult, 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, MdxJsxExpressionAttributeParse 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.wasm

The 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 framework

Pass 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 &lt;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 .md or .mdx. With no explicit dialect, .md selects CommonMark and .mdx selects MDX; omitting filename uses <anonymous>.mdx, hence MDX. An explicit dialect overrides either valid extension, but does not waive the extension gate itself.

  • dialect"markdown" or "mdx", inferred from filename as above when omitted.

  • directives — parse generic remark-directive syntax (:::name/::name/:name). Default false; directive-looking text survives exactly as markdown-rs parsed it when this is off.

  • frontmatter — YAML handling policy, default "extract":

    PolicyParsed sourceAST yaml nodefrontmatter valueMalformed YAML
    extract (default)frontmatter-stripped bodynoneparsed JSON / nullone frontmatter diagnostic
    nodefull logical sourcecanonical yaml nodeparsed JSON / nullone frontmatter diagnostic
    nonefull logical source, every byte as Markdown/MDXnonealways nullnever produces a frontmatter diagnostic
  • pipeline.gfm — the five boolean GfmOptions switches only (strikethrough, table, autolinkLiteral, taskListItem, footnoteDefinition). parseToAst's pipeline options are stricter than compile/renderHtml's: it rejects theme, cjkFriendly, hardBreaks, codeHighlight, and features outright (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 null ast (path "$"), and

  • any unknown or unsupported node type, explicitly including math, toml, and mdxjsEsm.

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 no position — markdown-rs does not model attribute positions.

  • Top-level import/export degrade to plain paragraphs (no mdxjsEsm nodes) — 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 — unlike position, 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: escapehi-esc, operatorhi-op, commenthi-com, stringhi-str, numberhi-num, constanthi-const, keywordhi-kw, functionhi-fn, typehi-ty, namespacehi-ns, propertyhi-prop, variablehi-var, taghi-tag, attributehi-attr, punctuationhi-punct, insertedhi-ins, deletedhi-del, and headinghi-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/zfb_md_wasm_glue.zfb-resource.mjs and wasm/zfb_md_wasm_bg.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.wasm

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

The 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 ./highlight 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>.wasm

Importing 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/graphfinal wasmgzip-9glueglue gzip-9
root (full)3,399,954 B1,516,383 B14,998 B4,199 B
highlight1,539,334 B817,951 B8,758 B2,637 B
render2,196,095 B1,091,678 B8,772 B2,661 B
parse700,364 B283,991 B11,159 B3,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, and linkValidation are 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 from zfb.config.ts at build time, see Customizing Markdown — and pass it as the pipeline option.

  • 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 compile is the only author-JavaScript execution path. Select /render or /parse for focused SWC-free calls, and /highlight for direct highlighting. The focused entries intentionally keep zfb-content/syntect-fancy; parse is not syntect-free.

  • renderHtml is 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.

  • renderHtml selects syntax from the filename. .md uses CommonMark, .mdx uses MDX, and an explicit dialect overrides either valid extension. compile remains MDX-only.

  • Syntax highlighting uses syntect's fancy-regex backend, not native zfb's oniguruma backend (oniguruma can'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/graphfinal wasmgzip-9glueglue gzip-9
root (full)3,399,954 B1,516,383 B14,998 B4,199 B
highlight1,539,334 B817,951 B8,758 B2,637 B
render2,196,095 B1,091,678 B8,772 B2,661 B
parse700,364 B283,991 B11,159 B3,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 components prop convention compile()'s output follows.

  • Customizing Markdown — how zfb resolves the pipeline config that the pipeline option mirrors.

  • Syntax Highlighting — native zfb's syntect setup, for comparison with the wasm build's fancy-regex backend.

  • @takazudo/zfb-md-wasm API reference — the full option/result type reference for compile, renderHtml, parseToAst, toMdastRoot, and highlightCode.

  • Importing Wasm — loading Wasm modules on the SSR side, for comparison with this page's browser-side usage.

Revision History

CreatedUpdated