Contents
Getting started
- Install — Cargo, source, optional features
- Requirements — toolchain floor, platforms
- Quick Start — Markdown to accessible HTML in five lines
Library reference
- Overview — what the pipeline does, step by step
- Features — capability matrix
- Library Usage — pipeline, front matter, TOC, SEO, accessibility
- Configuration — every
HtmlConfigoption - Math and diagrams — LaTeX to MathML, Mermaid passthrough
- WebAssembly — browser, Workers and Edge bindings
- Benchmarks — measured numbers with the host stated
- Examples — runnable example index
Operational
- When not to use html-generator — limitations
- Development — make targets, fuzzing, Miri, CI
- Security — hardening, fuzzing, supply chain
- Documentation — all reference docs
- Stability guarantees — SemVer axis, output stability
- Minimum-toolchain policy
- License
Install
[]
= "0.0.15"
Optional async support
[]
= { = "0.0.15", = ["async"] }
Build from source
Requires Rust 1.89.0+. Tested on Linux, macOS, and Windows.
Requirements
- Rust 1.89.0 or newer.
rust-versioninCargo.tomlis the floor and Cargo enforces it; CI builds on stable across Linux, macOS and Windows. See the minimum-toolchain policy for when and why it may move. - A
stdplatform, orwasm32with thewasmfeature. The crate usesstdunconditionally; there is nono_stdbuild. - No async runtime is required. Every entry point is synchronous
unless you enable the
asyncfeature, which adds a Tokio-based wrapper for callers who already run one.
Quick Start
use ;
Overview
html-generator converts Markdown into production-ready HTML with a configurable pipeline that applies accessibility, SEO, table of contents, math, diagrams, and minification in a single pass. No raw HTML passthrough by default — safe for untrusted input. Runs natively or as WebAssembly in browsers, Cloudflare Workers, and edge runtimes.
- Full CommonMark with extensions (tables, strikethrough, task lists, superscript)
- Front matter extraction from YAML (
---), TOML (+++), and JSON ({...}) - WCAG-compliant output with automatic ARIA attribute injection
- JSON-LD structured data appended for rich search results
- Table of contents injected at
[[TOC]]placeholder - Server-side LaTeX → MathML for
$..$and$$..$$(no client-side JS needed) - Mermaid diagram passthrough for
\u{60}\u{60}\u{60}mermaidfenced blocks - In-memory minification without disk I/O
- WebAssembly bindings via
wasm-bindgen(browsers, Workers, Edge) - Optional async via tokio
spawn_blocking(behindasyncfeature) - Zero unsafe code via
#![forbid(unsafe_code)]at crate root
| Metric | Value |
|---|---|
| Source | ~12,900 lines across 11 modules (src/yaml/ is a vendored snapshot, see FAQ) |
| Test suite | 485 unit/integration tests + 163 doctests + 4 WASM smoke tests = 652 total |
| Coverage | 98.08% line coverage (cargo llvm-cov); Codecov project ≥95%, patch ≥90% gates |
| Examples | 14 runnable examples, all executed in CI |
| Dependencies | 13 native runtime + 1 optional async (tokio) + 2 optional WASM (wasm-bindgen, js-sys) |
| MSRV | Rust 1.89.0 |
| WASM bundle | 5.8 MB raw / 2.0 MB gzipped (after wasm-opt -Os) |
| CI gates | 10 distinct checks including end-to-end wasm-pack test --node against Node 20 |
Features
| Markdown to HTML | Full CommonMark via mdx-gen with extensions: tables, strikethrough, task lists, autolinks, superscript. Custom class blocks via :::class syntax. Image class attributes via .class="cls". |
| Accessibility | Automatic ARIA attribute injection for buttons, navs, forms, inputs, tabs, modals, accordions, tooltips. WCAG 2.1 validation (Levels A, AA, AAA). Heading structure checks. Language attribute validation. |
| Front matter | YAML (---), TOML (+++), JSON ({...}) delimiters. extract_front_matter strips metadata and returns body. extract_front_matter_data parses metadata into serde_json::Value. |
| Table of contents | generate_table_of_contents builds <ul> from headings. Pipeline injects at [[TOC]] placeholder when generate_toc is enabled. |
| SEO | MetaTagsBuilder for meta tag generation. generate_structured_data for JSON-LD <script> output with configurable @type and additional properties. HTML entity escaping via escape_html. |
| Math (MathML) | enable_math flag converts $..$ and $$..$$ LaTeX spans to native <math> MathML via pulldown-latex. Server-side, no JS bundle. Conservative regex matchers leave $5 currency literals alone. Behind the math feature (default-on). |
| Diagrams (Mermaid) | enable_diagrams flag rewrites \u{60}\u{60}\u{60}mermaid fenced blocks to <pre class="mermaid"> for the standard client-side mermaid.js bundle. Diagram source flows through verbatim. |
| Minification | File-based minify_html(path) and in-memory minify_html_string(html). Preserves HTML semantics, strips comments, minifies CSS/JS. Configurable via MinifyConfig. |
| WebAssembly | wasm feature exposes generateHtml, generateHtmlFullDocument, generateHtmlWithOptions to JavaScript via wasm-bindgen. Build with wasm-pack build --target web --features wasm --no-default-features. |
| Performance | Regexes and CSS selectors compiled once into static Lazy. SIMD-backed str::contains short-circuits before any html5ever parse. DOM-aware element replacement handles attribute reordering. 2.09 ms full pipeline on an 8 KB blog payload (comrak parse alone is 172 µs). |
| Async | Optional async feature enables async_generate_html via tokio spawn_blocking. Synchronous users pay zero cost — tokio not compiled without the feature. |
| Security | #![forbid(unsafe_code)]. Raw HTML stripped by default (allow_unsafe_html: false). All user-controlled attributes escaped. NUL-byte rejection on file paths. Directory traversal blocked. Input size limits enforced. |
Library Usage
use ;
let config = HtmlConfig ;
let markdown = "[[TOC]]\n\n# Introduction\n\nWelcome to the guide.\n\n## Getting Started\n\nFollow these steps.";
let html = generate_html?;
// Output includes: ARIA attributes, TOC at [[TOC]], JSON-LD, minified
# Ok::
The pipeline applies steps in order:
- Markdown → HTML (with extensions)
- Accessibility (ARIA attributes)
- Table of contents (inject at
[[TOC]]) - Structured data (append JSON-LD)
- Minification (compress)
use extract_front_matter_data;
// YAML front matter
let content = "---\ntitle: My Page\nauthor: Jane Doe\n---\n# Hello";
let = extract_front_matter_data?;
assert_eq!;
assert_eq!;
// TOML front matter
let content = "+++\ntitle = \"My Page\"\nauthor = \"Jane Doe\"\n+++\n# Hello";
let = extract_front_matter_data?;
assert_eq!;
// JSON front matter
let content = "{\"title\": \"My Page\"}\n# Hello";
let = extract_front_matter_data?;
assert_eq!;
# Ok::
use ;
let markdown = "[[TOC]]\n\n# Chapter 1\n\n## Section 1.1\n\n# Chapter 2";
let config = HtmlConfig ;
let html = generate_html?;
assert!;
assert!;
# Ok::
use ;
use HashMap;
// Meta tags
let meta = new
.with_title
.with_description
.add_meta_tag
.build?;
// JSON-LD structured data
let html = r#"<html><head><title>My Page</title></head><body><p>Content</p></body></html>"#;
let config = StructuredDataConfig ;
let json_ld = generate_structured_data?;
assert!;
# Ok::
use ;
let html = r#"<button>Submit</button><nav><ul><li>Home</li></ul></nav>"#;
// Enhance with ARIA attributes
let enhanced = add_aria_attributes?;
assert!;
// Validate WCAG compliance
let config = default;
let report = validate_wcag?;
println!;
# Ok::
use minify_html_string;
let html = "<html> <body> <p>Hello</p> </body> </html>";
let minified = minify_html_string?;
assert_eq!;
# Ok::
The default generate_html silently degrades when optional steps fail.
Use generate_html_with_diagnostics to inspect which steps succeeded:
use ;
let config = HtmlConfig ;
let output = generate_html_with_diagnostics?;
println!;
for d in &output.diagnostics
# Ok::
Enable with features = ["async"]:
use async_generate_html;
async
Configuration
use HtmlConfig;
let config = HtmlConfig ;
Use the builder for validated configuration:
use HtmlConfig;
let config = builder
.with_syntax_highlighting
.with_language
.build?;
# Ok::
Examples
| Example | Description |
|---|---|
hello |
Heading, lists, code blocks, links — basic Markdown to HTML |
pipeline |
Full pipeline: ARIA + TOC + JSON-LD + minification in one pass |
frontmatter |
YAML, TOML, JSON front matter extraction and parsing |
accessibility |
ARIA injection for buttons, navs, forms; WCAG validation |
seo |
Meta tags, JSON-LD structured data, HTML entity escaping |
toc |
Table of contents from headings, [[TOC]] placeholder |
minify |
In-memory HTML minification with size savings |
errors |
Error variants, type matching, graceful recovery patterns |
config |
HtmlConfig builder, validation, field inspection |
headers |
Custom ID and class generators for heading elements |
custom_syntax |
Triple-colon blocks (:::warning) and image classes |
emojis |
Bundled emoji data, emoji-to-ARIA-label mapping |
math_and_diagrams |
LaTeX → MathML and \u{60}\u{60}\u{60}mermaid passthrough |
async |
Asynchronous generation via tokio (requires --features async) |
Run any of them with cargo run --example <name>, or all fourteen with
make examples. CI does the same on every push, so an example that
stops working fails the build.
Benchmarks
Comparative throughput on the same realistic 8 KB blog payload,
measured with Criterion (--quick) on an Apple M-series CPU with
[profile.bench] at opt-level = 3 and fat LTO. Numbers are worth
nothing without the host they came from; reproduce them on yours with
cargo bench --bench competitors.
| Engine | Time / iter | What it does |
|---|---|---|
pulldown_cmark (parse only) |
45 µs | Pull-parser, no post-processing. Fastest plain CommonMark in Rust. |
comrak (parse only) |
172 µs | The CommonMark/GFM parser this crate wraps. |
html_generator (full pipeline) |
2.09 ms | Parse + ARIA injection + TOC + JSON-LD + minification. |
Pure parsers will always be faster — they don't do ARIA, JSON-LD, TOC, or
minification. html-generator does all four in one pass; the ~2 ms
overhead is what buys WCAG-compliant output without a downstream
post-processing layer. Reproduce with:
Math and diagrams
Two opt-in post-processors turn ordinary Markdown into rich technical documentation without client-side JavaScript for math:
use ;
let md = r"
# Pythagoras
In a right triangle, $a^2 + b^2 = c^2$.
```mermaid
graph LR
A --> B
```";
let cfg = HtmlConfig ;
let html = generate_html?;
# Ok::
- Math — server-side LaTeX → MathML via
pulldown-latex(gated behind themathfeature, on by default). Browsers render MathML natively, so no client-side bundle is required. Parse errors are encoded inline as<merror>markers rather than crashing the build. - Diagrams —
\u{60}\u{60}\u{60}mermaidfenced blocks become<pre class="mermaid">…</pre>so the standard mermaid.js loader picks them up. Drop a single<script type="module">import mermaid from "https://…/mermaid.esm.mjs"; mermaid.initialize({startOnLoad:true});</script>in your page and you're done.
WebAssembly
The same pipeline runs in Cloudflare Workers, Vercel Edge, browsers, and Node — without changing API:
# or, to publish an npm bundle:
Three JS-friendly entry points are exposed via wasm-bindgen:
| JS name | Description |
|---|---|
generateHtml(markdown) |
Render Markdown to an accessible HTML fragment with default config. |
generateHtmlFullDocument(markdown) |
Same but wrapped in <!DOCTYPE html><html>…</html>. |
generateHtmlWithOptions(markdown, optionsJson) |
Pass a JSON object configuring add_aria_attributes, generate_toc, enable_math, enable_diagrams, etc. |
WASM builds drop mdx-gen's :::class, image-class, and syntect
syntax highlighting (the underlying tokio/onig C dependencies do not
compile to wasm32-unknown-unknown); CommonMark + GFM (tables,
strikethrough, autolinks, tasklists, superscript) plus the full ARIA /
TOC / JSON-LD / math / mermaid post-processing layer renders identically.
Use it from JavaScript:
// pkg/ generated by `wasm-pack build --target web ...`
import init from "./pkg/html_generator.js";
await ;
// Simple render with defaults (ARIA on):
const fragment = ;
// Render with custom options:
const article = ;
From Cloudflare Workers / Vercel Edge: use wasm-pack build --target bundler and import the generated module from your worker entry point.
The JS-side API is identical to the browser case.
Bundle size
Measured wasm-pack build --release --target web output, post
wasm-opt -Os:
| Feature set | .wasm raw |
.wasm gzipped |
|---|---|---|
--features wasm,math |
5.8 MB | 2.0 MB |
--features wasm (no math) |
5.7 MB | 1.96 MB |
The math feature adds ~40 KB gzipped. Both bundles fit comfortably
in Cloudflare Workers' paid plan (10 MB compressed); the free plan
(1 MB compressed) requires further trimming — the ammonia,
minify-html, and scraper-on-html5ever deps account for the bulk
of the binary.
Smoke tests live in tests/wasm_smoke.rs and run
under wasm-pack test --node --no-default-features --features wasm,math.
The CI's wasm-build job exercises this exact command on every push.
When not to use html-generator
Cases where something else fits better, listed because the honest answer is "not yet" or "by design" rather than a disagreement about priorities.
- You only need Markdown to HTML. Use
comrakorpulldown-cmarkdirectly. They are the parsers underneath, they are an order of magnitude faster, and everything this crate adds on top is overhead you would not be using. - You need
no_std. The crate usesstdunconditionally. Thewasm32build is the only non-native target it supports, and it still needsstd. - You need a full HTML parser's error recovery. The accessibility
and SEO passes read the generated document with
scraper; they are built for markup this crate produced, not for arbitrary broken HTML from the wild. - You need WCAG conformance as a legal guarantee.
validate_wcagchecks the rules it implements: heading order, image alt text, form labels, landmark structure, link text. Conformance is a property of a whole site and its content, and no library can certify it for you. - You want raw HTML in Markdown to pass through untouched by default. It does not, and that is deliberate. See Security.
Development
DEVELOPMENT.md maps each CI job to its local
equivalent and explains the gotchas.
Fuzzing
Three cargo-fuzz targets live under fuzz/fuzz_targets/:
fuzz_accessibility is the one that matters most: the enrichment pass
rewrites markup by byte offset, which is where this crate's
slice-boundary bugs have lived. fuzz/corpus/<target> holds the
committed seeds and fuzz/regressions/<target> every fixed-bug input;
both replay on each push, so a fixed crash cannot silently return.
cargo-fuzz must be installed from source (cargo install --locked cargo-fuzz): the prebuilt binary is a musl build and infers its own
build triple as the fuzz target.
Miri
The crate is #![forbid(unsafe_code)], so Miri does not police its own
code. The job exists to check the interaction with dependencies that do
use unsafe internally.
CI
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
push, PR | clippy, fmt, tests across three OSes, coverage, audit |
quality.yml |
push, PR | coverage gate, Miri, fuzz replay, docs lint, cargo-vet ratchet, release hygiene |
docs.yml |
push to main | build and deploy API docs to GitHub Pages |
release.yml |
tag v* |
validate, build, GitHub Release |
See CONTRIBUTING.md for signed commits and PR guidelines.
Security
Reporting: never open a public issue for a vulnerability. See
SECURITY.md for the private channel and disclosure
policy.
This crate turns untrusted Markdown into HTML a site will serve, so injection is the first-order risk and the reason for most of what follows.
Injection
Raw HTML in Markdown is off by default: allow_unsafe_html is
false, so embedded markup is escaped and nothing a document author
writes reaches the page as live HTML. sanitize_html is a separate
switch, also off by default, and has no effect on its own: it runs the
output through ammonia, an allow-list sanitiser, only when
allow_unsafe_html is true. If you enable one, enable both. Every
user-controlled attribute value is escaped on the way out.
Architectural posture
#![forbid(unsafe_code)]— the compiler proves the absence of unsafe blocks.- No C dependencies, no FFI, no network I/O. File access happens only
where the caller names a path, and
..traversal is rejected. - Input size limits at every boundary: a per-call
max_input_size(5 MiB by default), a 1 MB cap on the HTML the accessibility and SEO passes will rewrite, and a 16 MiB reader buffer.
Supply chain
cargo-denyandcargo-auditin CI; documented advisory exemptions live in.cargo/audit.tomlwith the upstream reason for each.cargo-vetprovenance insupply-chain/, with an exemption baseline the CI ratchet cannot exceed.noyalibpinned exactly (=0.0.X); a bump is a deliberate release.Cargo.lockcommitted; CI builds--locked. Actions pinned by SHA.- REUSE 3.3 compliant, linted in CI.
- Commits on
mainare signed; releases are signed tags (KEYS.asc).
Documentation
The four entry points, identical across every repo in the family:
- API reference — rustdoc on docs.rs
- Developer docs — toolchain, task map, reproducing every CI gate locally
- Architecture — module map, pipeline, design decisions
- Decision records — the choices that would be expensive to reverse
| Document | Covers |
|---|---|
CHANGELOG.md |
per-release notes, Keep a Changelog format |
SECURITY.md |
disclosure policy, injection posture, resource limits, supply chain |
CONTRIBUTING.md |
branch and commit conventions, PR expectations, code standards |
GOVERNANCE.md |
who decides what, how changes land |
SUPPORT.md |
where to ask, what to expect |
AGENTS.md |
invariants for AI-assisted contributions |
Stability guarantees
- Versioning. SemVer, with the pre-1.0 posture
that the patch number is the breaking axis during
0.0.x. Releases increment by+0.0.1and every breaking change is called out inCHANGELOG.md. - Output stability. For a generator, output is API: a change to the HTML produced for a given Markdown input — the ARIA attributes added, the heading ids emitted, the JSON-LD shape, what the minifier collapses — is treated as breaking even when no Rust signature moves.
- Determinism. The same Markdown and the same
HtmlConfigproduce byte-identical HTML on every run and every platform. Anything else is a bug, not a tolerance. - Deprecations live for at least two releases with a
#[deprecated]note naming the replacement before removal. - Version-bearing files are checked against the manifest by
scripts/verify-release-versions.shbefore a tag exists, so an install snippet cannot go stale.
Minimum-toolchain policy
The floor is Rust 1.89.0, declared as rust-version in
Cargo.toml so Cargo refuses older toolchains with a clear message.
- When it may rise: only on a release, never silently, and always with the reason in the changelog entry.
- Why it is where it is: 1.89.0 is the floor shared by the maintainer's Rust crates. The dependency graph alone needs 1.88.0; the floor moves again when a dependency the crate needs moves past it.
- What is verified: CI builds and tests on stable, and a
msrvjob builds the library on therust-versiontoolchain itself, so the declared floor cannot drift below what the dependency graph needs.
No claim is made about distro-LTS toolchains. Making one would require a table mapping current distro versions to this floor, and an aspirational claim there is worse than none.
License
Dual-licensed under Apache 2.0 or MIT, at your option.