Molt
A safe, dependency-free HTML DSL for Rust and WebAssembly.
๐ฆ molt-framework on crates.io ยท ๐ docs.rs ยท ๐ Getting started
Molt is an embedded DSL: you describe the page in Rust, with a fluent builder or the html!
macro, and the compiler checks it. The result is a tree of plain values rendered once as a
string, compact or pretty. There is no DOM, no runtime, no template language and no
reflection, so there is nothing to parse at run time and nothing to keep in sync.
It is the HTML layer and only that: the element tree, the renderer, metadata, an
accessibility audit, sitemaps, feeds, static site output and a WebAssembly build. Routing,
servers and Markdown stay in the crates that already do them well, and plug in through a
String.
use *;
use Document;
let page = new
.head_children
.body_children;
println!;
Or with the html! macro, which expands to the same builder calls, never a second code path:
use ;
let items = ;
let markup = html! ;
Why Molt
Secure by construction. The one guarantee is that text and attribute values you pass in cannot become markup, and it is enforced where the data enters, not where you remember to.
- Text and attribute values are escaped once, on entry. There is no way to forget and no way to double-escape.
- Tag and attribute names are validated at write time. An invalid one is dropped, never repaired.
- URL attributes (
href,src,action,srcset, โฆ) that resolve tojavascript:,vbscript:ordata:text/htmlare written as#, including through character references.srcdocis never written with a value you did not vouch for. - JSON inside
<script>goes through its own escaper, so a</script>in a value cannot end the element. raw_text,Node::raw,Attribute::rawandtrusted_urlare the only ways around any of this. They are named so an audit is onegrep.unsafe_code = "forbid",cargo denyon the dependency graph, four fuzz targets, andSECURITY.mdmaps every guarantee onto the OWASP Top 10.
# use *;
assert_eq!;
assert_eq!;
Robust under hostile input. Rendering, cloning, comparing, auditing and dropping a tree
all walk an explicit stack, never the call stack, so nesting depth cannot be turned into a
process abort. A 100,000-level tree renders in both modes in the test suite, and
RenderOptions::with_max_depth bounds depth that comes from outside. Output is pinned
byte for byte by golden fixtures: a change in markup is a failing test, never a surprise in
production. The crate is 1.x, so what compiles today keeps compiling, and
MIGRATION.md shows every change that ever altered output.
Light. Zero mandatory dependencies: cargo tree on the default features shows one crate,
this one. wasm-bindgen and rayon are opt-in features. The WebAssembly build is about
25 KB gzipped. The renderer writes the whole tree into one buffer, allocation counts are
asserted in CI, and Node is Send + Sync, so bulk generation parallelises without
ceremony.
Fast enough that it is not the bottleneck. Measured on the development machine with
cargo bench --bench render (Criterion, opt-level = 3):
| bench | median |
|---|---|
| compact render, 10,000-item list | โ 400 ยตs |
| full 50-card document, pretty / compact | โ 8 ยตs / 6.5 ยตs |
Building the tree costs more than rendering it; both are far below the cost of the request that asked for the page.
Install
The crate is on crates.io and its API
documentation on docs.rs. It is published as
molt-framework โ molt on crates.io is an unrelated project โ and imported as molt.
[]
= "1.0"
use *;
Features:
| feature | default | what it does |
|---|---|---|
ssg |
โ | StaticSiteGenerator โ writes pages and assets to disk |
wasm |
wasm-bindgen bindings for JavaScript |
|
parallel |
rayon-backed bulk page generation |
Serving the output
Molt renders a String, and that is the whole integration contract with a web server: there
is no axum, actix or tokio feature, and none is planned, so the core keeps zero
mandatory dependencies. With Axum, Html already accepts a
String:
use Html;
use *;
async
If you want your own type to be a response, wrap the document in a newtype in your crate and implement the framework's trait there; the orphan rule allows it because the newtype is yours:
use ;
;
The same pattern works for any framework. A framework-specific crate (molt-axum, say) is
welcome as a separate project that depends on this one; see ROADMAP.md.
What is in it
- 93 HTML elements, generated from one table โ see
docs/tag-catalog.md - Fluent builder API โ
set_id,add_class,add_classes,set_style,attr,data_attr(s),aria_attr(s),set_role,child,text,raw_text html!macro โ nesting, attributes,@if/@else,@forDocumentโ owns the doctype, renders pretty by defaultLayouttrait โ reusable page shells- SEO โ Open Graph, Twitter Cards, the common
<meta>block, and aSeoBuilder - Accessibility โ ARIA helpers plus an audit that finds images without
alt, buttons with no accessible name, iframes without a title - Sitemaps and RSS 2.0
- Static site generation โ atomic writes, asset copying, optional parallelism
- WebAssembly โ the same API from JavaScript
WebAssembly
import init from "./pkg/molt.js";
await ;
const page =
.
.;
document.. = page.;
The bindings wrap the real tree rather than accumulating pre-rendered strings, so
renderPretty() works and JavaScript drives exactly the renderer Rust does.
Handles are consumed. Builder methods and element arguments move their value into Rust,
so a handle you have already passed on (or chained from) is gone: using it throws
null pointer passed to rust. Chain calls, or keep the handle each call returns.
Known limitations
Pretty output is quadratic in nesting depth. Every line carries one indent string per
level above it, so a very deep tree renders a very large string. Rendering itself has no
depth limit โ the writer walks an explicit stack rather than recursing โ but the size is
worth knowing about when depth comes from untrusted input. See
SECURITY.md.
Documentation
GETTING_STARTED.md: a first page, escaping,html!, SEO, a site on disk.MIGRATION.md: from 0.1.0 to 1.0.0, change by change.SECURITY.md: what is guaranteed, the limits, how to report a problem.ROADMAP.mdandCHANGELOG.md.- API reference on docs.rs.
Development
verify.sh is a superset of CI. Run it before reporting that a change is done.
Contributing
Molt is open source and contributions are welcome โ a bug report with the input that triggered it is as useful as a patch.
- Found a bug or want a feature? Open an issue.
ROADMAP.mdsays what is planned, what is a lead, and what is deliberately not. - Found a security problem? Please do not open a public issue; use
a private security advisory.
See
SECURITY.md. - Sending code? Branch from
develop, add a test that asserts on the rendered markup, and run./scripts/verify.shbefore you open the pull request. The rules and the reasons behind them are inCONTRIBUTING.md. - Using an AI agent? Point it at
AGENTS.md, the map over the rules in.claude/rules, the slash commands, the review agents and the knowledge base in.claude/skills/molt: security rules, Rust practices and the lessons learned getting to 1.0. The code style every contributor follows is.claude/rules/code-style.md.
Support the project
If Molt saves you time, you can help keep it maintained by sponsoring on GitHub. Starring the repository, sharing it, and telling me what you built with it help just as much.
The name
Molt is the shedding of an exoskeleton, the way a crustacean grows: a nod to Ferris, and to where this project came from. It started as a port of a Swift HTML library, built to run commercial SaaS products on small cloud footprints, and moved to Rust for the efficiency and the guarantees. The shell it kept is the API; what it shed is the overhead.
License
MIT โ see LICENSE.
Created by Michel Anderson Lutz Teixeira.