molt-framework 1.0.1

Fast, type-safe HTML DSL and static site generation engine in Rust and WebAssembly
Documentation

Molt

A safe, dependency-free HTML DSL for Rust and WebAssembly.

crates.io docs.rs CI License: MIT Rust 1.85+ WebAssembly Sponsor

๐Ÿ“ฆ 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 molt::prelude::*;
use molt::Document;

let page = Document::new(Some("pt-BR"))
    .head_children([
        meta().attr("charset", "UTF-8"),
        title().text("RideKeeper โ€” track every service"),
        stylesheet("/css/style.css"),
    ])
    .body_children([
        header().child(nav().child(link_to("/").add_class("logo").text("RideKeeper"))),
        main_tag().child(
            article()
                .add_class("card p-4")
                .child(h1().text("Track every service"))
                .child(p().text("Fuel, tyres & chain โ€” all in one place.")),
        ),
    ]);

println!("{}", page.render());

Or with the html! macro, which expands to the same builder calls, never a second code path:

use molt::{html, prelude::*};

let items = ["Fuel", "Tyres", "Chain"];
let markup = html! {
    section(class = "log") {
        h2 { "Service log" }
        ul { @for item in items { li { (item) } } }
    }
};

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 to javascript:, vbscript: or data:text/html are written as #, including through character references. srcdoc is 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::raw and trusted_url are the only ways around any of this. They are named so an audit is one grep.
  • unsafe_code = "forbid", cargo deny on the dependency graph, four fuzz targets, and SECURITY.md maps every guarantee onto the OWASP Top 10.
# use molt::prelude::*;
assert_eq!(p().text("<script>alert(1)</script>").render(),
           "<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
assert_eq!(a().attr("href", "javascript:alert(1)").render(), r##"<a href="#"></a>"##);

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.

[dependencies]
molt-framework = "1.0"
cargo add molt-framework
use molt::prelude::*;

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 axum::response::Html;
use molt::prelude::*;

async fn home() -> Html<String> {
    let page = Document::new(Some("en"))
        .body_children([h1().text("Track every service")]);
    Html(page.render())
}

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 axum::response::{Html, IntoResponse, Response};

struct Page(molt::Document);

impl IntoResponse for Page {
    fn into_response(self) -> Response {
        Html(self.0.render()).into_response()
    }
}

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, @for
  • Document โ€” owns the doctype, renders pretty by default
  • Layout trait โ€” reusable page shells
  • SEO โ€” Open Graph, Twitter Cards, the common <meta> block, and a SeoBuilder
  • 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

wasm-pack build --target web --features wasm
import init, { element, WDocument } from "./pkg/molt.js";
await init();

const page = new WDocument("pt-BR")
  .addBody(element("h1").text("Track every service"))
  .addBody(element("p").addClass("lead").text("Fuel, tyres & chain."));

document.body.innerHTML = page.render();

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

Development

./scripts/verify.sh        # build, test, clippy, fmt, catalog, fixtures, smoke test
cargo test                 # unit, integration and doc tests
cargo bench                # render performance

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.md says 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.sh before you open the pull request. The rules and the reasons behind them are in CONTRIBUTING.md.
  • Using an AI agent? Point it at AGENTS.md and the knowledge base in .claude/skills/molt: security rules, Rust practices and the lessons learned getting to 1.0.

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.