ferromark 0.3.3

Ultra-high-performance Markdown to HTML compiler
Documentation

ferromark

Powered by Sebastian Software CI crates.io docs.rs License: MIT Rust 1.85+ clippy

Markdown to HTML with a secure default and every GFM extension included. The reproducible benchmark protocol and current CommonMark conformance result are documented below.

Quick start

let html = ferromark::to_html("# Hello\n\n**World**");

One function call, no setup. When allocation pressure matters:

let mut buffer = Vec::new();
ferromark::to_html_into("# Reuse me", &mut buffer);
// buffer survives across calls — zero repeated allocation

Benchmarks

Numbers, not adjectives. Apple Silicon (M-series), July 2026. All parsers run with GFM tables, strikethrough, and task lists enabled; ferromark's non-GFM extras (heading IDs, callouts) are disabled. Output buffers are reused where APIs allow and binaries are non-PGO. Ferromark also keeps its secure default rendering in this published product lane, so it performs URL and raw-HTML safety work that pulldown-cmark does not.

CommonMark 5 KB (wiki-style, mixed content with tables)

Parser Throughput vs ferromark
ferromark 259.6 MiB/s baseline
pulldown-cmark 254.5 MiB/s 0.98x
md4c (C) 243.1 MiB/s 0.94x
comrak 67.9 MiB/s 0.26x

CommonMark 50 KB (same style, scaled)

Parser Throughput vs ferromark
ferromark 280.5 MiB/s baseline
pulldown-cmark 275.2 MiB/s 0.98x
md4c (C) 253.3 MiB/s 0.90x
comrak 71.8 MiB/s 0.26x

2% faster than pulldown-cmark. 11% faster than md4c. 4x faster than comrak. Competitor versions: pulldown-cmark 0.13.4, comrak 0.53, md4c @ 65c6c9d.

The fixtures are synthetic wiki-style documents with paragraphs, lists, code blocks, and tables. Nothing cherry-picked. The cross-parser harness is isolated from the library build and pins md4c at 65c6c9d for the published numbers:

cd benchmarks/md4c-comparison
MD4C_DIR=/path/to/md4c cargo bench --bench comparison

MD4C_DIR is required deliberately; normal cargo build, cargo test, and package consumers never inspect or compile a sibling C checkout.

For strict, named feature intersections between the two closest Rust parsers, use the md4c-independent harness:

cargo test --manifest-path benchmarks/pulldown-comparison/Cargo.toml
cargo bench --manifest-path benchmarks/pulldown-comparison/Cargo.toml

It provides CommonMark, GFM-overlap, and extended-overlap lanes with trusted raw-HTML semantics in both parsers. See the parity benchmark README for the exact feature matrix. Secure-default numbers remain separate because pulldown-cmark does not expose an equivalent trust boundary.

What you get

CommonMark conformance: The current default-policy report passes 577 of 652 spec examples (88.5%). Raw HTML is escaped by default as part of Ferromark's browser-facing safety boundary, so this is not a claim of full raw-HTML CommonMark parity. Run cargo test --test commonmark_spec -- --ignored --nocapture for the complete, current report.

All five GFM extensions: Tables, strikethrough, task lists, autolink literals, disallowed raw HTML.

Beyond GFM: Footnotes, front matter extraction (---/+++), heading IDs (GitHub-compatible slugs), math spans ($/$$), highlight/mark syntax (==text==), superscript (^text^), subscript (~text~), and callouts (> [!NOTE], > [!WARNING], ...).

MDX support (opt-in via mdx feature): Segment and render .mdx files without a JavaScript toolchain. Covers 90%+ of real-world MDX patterns in Next.js, Docusaurus, and Astro.

Fine-grained options let you turn on exactly what you need:

allow_html · allow_link_refs · tables · strikethrough · highlight · superscript · subscript · task_lists
autolink_literals · disallowed_raw_html · footnotes · front_matter
heading_ids · math · callouts

Syntax note: ferromark uses ~~text~~ for strikethrough, ~text~ for subscript, and ^text^ for superscript. Single-tilde strikethrough is intentionally not supported.

Markdown profiles

Profiles provide three curated, monotone feature sets without replacing the fine-grained options:

Feature Essentials Extended Full
Tables, strikethrough, task lists
Raw HTML parsing, reference links
Heading IDs, callouts
Autolink literals, footnotes, front matter
Math, highlight, subscript, superscript
  • Essentials covers common READMEs, product documentation, and simple content with the three everyday GFM extensions.
  • Extended adds the current default feature mix, including references, raw HTML parsing, heading IDs, and callouts.
  • Full enables every Markdown feature supported by this Ferromark version.
use ferromark::{Options, Profile, RenderPolicy};

let options = Options {
    heading_ids: true,
    render_policy: RenderPolicy::Trusted,
    ..Options::from(Profile::Essentials)
};

let html = ferromark::to_html_with_options(markdown, &options);

Options::default() remains backward-compatible and currently matches the Extended feature mix. Profiles never opt into trusted HTML: RenderPolicy is a separate security decision. The profile names describe syntax contracts, not a fixed speed promise. Measure your corpus with cargo bench --bench profiles.

Trade-offs

ferromark is built for one job: turning Markdown into HTML as fast as possible. That focus means some things it deliberately skips:

  • No AST access. You can't walk a syntax tree or write custom renderers against parsed nodes. If you need that, pulldown-cmark's iterator model or comrak's AST are better fits.
  • No source maps. No byte-offset tracking for mapping HTML back to Markdown positions.
  • HTML only. No XML, no CommonMark round-tripping, no alternative output formats.

These aren't planned. They'd compromise the streaming architecture that makes ferromark fast.

Rendering untrusted Markdown

The default RenderPolicy::Untrusted is the browser-facing safety boundary. It escapes all raw HTML and allows relative URLs plus a small set of non-script schemes (http, https, mailto, tel, and similar). URL schemes are checked after entity and control-character normalization, so spellings such as javascript: are blocked too.

let html = ferromark::to_html(user_supplied_markdown);

Trusted documents and MDX can opt into passthrough explicitly:

use ferromark::{Options, RenderPolicy};

let options = Options {
    render_policy: RenderPolicy::Trusted,
    ..Options::default()
};
let html = ferromark::to_html_with_options(trusted_markdown, &options);

disallowed_raw_html implements the narrower GFM tag filter in trusted mode. It is not a general-purpose HTML sanitizer and does not make arbitrary raw HTML safe by itself.

Upgrading from 0.1? See the 0.2 migration guide for the new rendering default and fallible UTF-8 and MDX APIs.

MDX support

MDX is the standard for component-driven docs in Next.js, Docusaurus, and Astro. Processing it usually requires a full JavaScript toolchain — Node.js, acorn, babel, the works.

ferromark takes a different approach: segment .mdx files into typed blocks and render them at native speed. No JS runtime. No AST.

ferromark = { version = "0.1", features = ["mdx"] }

Render — one call, full output

render() assembles the final output automatically: Markdown segments become HTML, JSX and expressions pass through unchanged, ESM and front matter are extracted separately.

use ferromark::mdx::render;

let input = r#"import { Card } from './card'

---
title: Hello
---

# Hello World

<Card title="Example">

Markdown **inside** a component.

</Card>

{new Date().getFullYear()}
"#;

let output = render(input);
// output.body        — HTML with JSX/expressions passed through
// output.esm         — vec!["import { Card } from './card'\n"]
// output.front_matter — Some("title: Hello\n")

Use render_with_options() for custom Markdown settings (heading IDs, math, footnotes, etc.).

Component — ready-to-use JSX module

to_component() wraps the output as a complete JSX/TSX module with a named export. Works with React 19, Preact, Solid, and any JSX framework.

let output = render(input);
let tsx = output.to_component("HelloWorld")?;
import { Card } from './card'

export function HelloWorld() {
  return (
    <>
      <h1 id="hello-world">Hello World</h1>
      <Card title="Example">
        <p>Markdown <strong>inside</strong> a component.</p>
      </Card>
      {new Date().getFullYear()}
    </>
  );
}

Segment — low-level control

When you need full control over each block, use segment() directly:

use ferromark::mdx::{segment, Segment};

for seg in segment(input) {
    match seg {
        Segment::Esm(s)              => { /* import/export — pass through */ }
        Segment::Markdown(s)         => { /* parse with ferromark::to_html(s) */ }
        Segment::JsxBlockOpen(s)     => { /* <Component> */ }
        Segment::JsxBlockClose(s)    => { /* </Component> */ }
        Segment::JsxBlockSelfClose(s)=> { /* <Component /> */ }
        Segment::Expression(s)       => { /* {expression} */ }
    }
}

The segmenter handles JSX attribute parsing (strings, expressions, spreads), brace-depth tracking (with string/comment/template-literal awareness), fragment syntax, member expressions (<Foo.Bar>), and multiline tags. Invalid constructs fall back to Markdown — no panics, always valid output.

Full example: cargo run --features mdx --example mdx_segment

The segmenter covers the block-level MDX patterns that make up 90%+ of real-world .mdx files: imports at the top, components wrapping content, expressions between paragraphs. This is what a typical Docusaurus, Next.js, or Astro page looks like — and it works out of the box.

What the segmenter deliberately skips — and why that's fine for most use cases:

What Our approach When it matters
Inline JSX (text <em>here</em>) Stays inside Markdown segments Only if you mix JSX and prose on the same line inside a paragraph — rare in practice
JS validation Heuristic detection (keyword + brace counting) instead of acorn/swc Only if you need to report syntax errors in user-authored MDX at parse time
Markdown grammar Standard CommonMark/GFM rules Official mdxjs disables indented code and HTML syntax — relevant if your content relies on <div> being JSX, not HTML
Container nesting > <Component> stays Markdown Only if you put JSX inside blockquotes or list items — uncommon
TypeScript generics <Component<T>> not parsed Only relevant for TSX-heavy content pages — very rare in docs
Error reporting Silent fallback to Markdown Means broken JSX renders as text instead of failing — arguably safer for content pipelines

The full @mdx-js/mdx compiler exists to produce a React component tree from MDX. It needs a JavaScript parser because it compiles to JSX. ferromark's segmenter exists to answer a simpler question: where does the Markdown stop and the JSX start? That question doesn't need a JS runtime.

For the detailed technical spec, see src/mdx/mod.rs.

How it works

No AST. Block events stream from the scanner to the HTML writer with nothing in between.

Input bytes (&[u8])
       │
       ▼
   Block parser (line-oriented, memchr-driven)
       │ emits BlockEvent stream
       ▼
   Inline parser (mark collection → resolution → emit)
       │ emits InlineEvent stream
       ▼
   HTML writer (direct buffer writes)
       │
       ▼
   Output (Vec<u8>)

What makes this fast in practice:

  • Block scanning runs on memchr for line boundaries. Container state is a compact stack, not a tree.
  • Inline parsing has three phases: collect delimiter marks, resolve precedence (code spans, math, links, emphasis, strikethrough, subscript, superscript, highlight), emit. No backtracking.
  • Emphasis resolution uses the CommonMark modulo-3 rule with a delimiter stack instead of expensive rescans.
  • SIMD scanning (NEON on ARM) detects special characters in inline content.
  • Zero-copy references: events carry Range pointers into the input, not copied strings.
  • Compact events: 24 bytes each, cache-line friendly.
  • Hot/cold annotation: #[inline] on tight loops, #[cold] on error paths, table-driven byte classification.

Design principles

  • Linear time. No regex, no backtracking, no quadratic blowup on adversarial input.
  • Low allocation pressure. Compact events, range references, reusable output buffers.
  • Operational safety. Enforced limits cap block nesting (32), inline marks (4,096), code-span backtick runs (32), link-destination parenthesis depth (32), ordered-list marker digits (9), and table columns (128). Footnote numbering has no arbitrary count cap; its definition-index lookup stays O(1) per reference.
  • Small dependency surface. Minimal crates, straightforward integration.

How ferromark compares to the other three top-tier parsers across architecture, features, and output. Ratings use a 4-level heatmap focused on end-to-end Markdown-to-HTML throughput. Scoring is relative per row, so each row has at least one top mark.

Legend: 🟩 strongest   🟨 close behind   🟧 notable tradeoffs   🟥 weakest

Ferromark optimization backlog: docs/arch/ARCH-PLAN-001-performance-opportunities.md

Building

cargo build            # development
cargo build --release  # optimized (recommended for benchmarks)
cargo test             # run tests
cargo test --test commonmark_spec -- --nocapture  # CommonMark spec
cargo bench            # benchmarks

Project structure

src/
├── lib.rs          # Public API (to_html, to_html_into, parse, Options)
├── main.rs         # CLI binary
├── block/          # Block-level parser
│   ├── parser.rs   # Line-oriented block parsing
│   └── event.rs    # BlockEvent types
├── inline/         # Inline-level parser
│   ├── mod.rs      # Three-phase inline parsing
│   ├── marks.rs    # Mark collection + SIMD integration
│   ├── simd.rs     # NEON SIMD character scanning
│   ├── event.rs    # InlineEvent types
│   ├── code_span.rs
│   ├── emphasis.rs      # Modulo-3 stack optimization
│   ├── strikethrough.rs # GFM strikethrough resolution
│   ├── subscript.rs     # Subscript resolution (~text~)
│   ├── superscript.rs   # Superscript resolution (^text^)
│   ├── math.rs          # Math span resolution ($/$$ delimiters)
│   └── links.rs         # Link/image/autolink parsing
├── mdx/            # MDX segmenter + renderer (feature = "mdx")
│   ├── mod.rs      # Public API — Segment enum, segment(), render()
│   ├── render.rs   # Assembly layer: segments → HTML body + ESM + front matter
│   ├── splitter.rs # Line-based state machine
│   ├── jsx_tag.rs  # JSX tag boundary parser
│   └── expr.rs     # Expression boundary parser (brace/string/comment tracking)
├── footnote.rs     # Footnote store and rendering
├── link_ref.rs     # Link reference definitions
├── cursor.rs       # Pointer-based byte cursor
├── range.rs        # Compact u32 range type
├── render.rs       # HTML writer
├── escape.rs       # HTML escaping (memchr-optimized)
└── limits.rs       # DoS prevention constants

License

MIT


The Ferramenta family

This project is part of Ferramenta — the family of Rust-native developer tools by Sebastian Software that keep the APIs the ecosystem already knows:

Tool Job
ferroni Oniguruma-compatible regex engine
ferriki Shiki-compatible syntax highlighting
ferromark CommonMark/GFM Markdown to HTML
ferrovia SVGO-compatible SVG optimizer
ferrocat Translation catalog engine
ferrolex Spell, dictionary, and brand validation
ferrugo Rust-native PDF previews