# dom-tree-rs
[](https://crates.io/crates/dom-tree-rs)
[](https://docs.rs/dom-tree-rs)
[](https://crates.io/crates/dom-tree-rs)
[](https://github.com/iamsaquib8/dom-tree-rs/actions/workflows/ci.yml)
[](./LICENSE)
[](#msrv)


**A tiny, zero-dependency, forgiving HTML parser.** Turn messy real-world HTML
into a clean tree (and JSON) — in the browser, on the edge, or anywhere you
don't want to pull in a 100 KB+ parser. It **never panics** on bad input.
```rust
let dom = domtree::parse(r#"<ul id="list"><li class="item">Item 1<li>Item 2"#);
let items: Vec<_> = dom.find_by_tag("li").map(|li| li.text_content()).collect();
assert_eq!(items, ["Item 1", "Item 2"]); // note the missing </li> — recovered
println!("{}", dom.to_json()); // self-describing JSON tree, zero deps
```
> **What it is not:** a spec-compliant WHATWG parser (use
> [`html5ever`](https://crates.io/crates/html5ever)) or a raw-throughput record
> (use [`tl`](https://crates.io/crates/tl)). It's the *smallest* way to turn
> arbitrary HTML into a usable tree.
## Why
Most Rust HTML parsers are either spec-grade and heavy (`html5ever`, `scraper`,
`lol_html` — 5–11 dependencies, hundreds of KB of WASM) or fast and low-level
(`tl`). `dom-tree-rs` fills a specific gap:
- **Zero dependencies** in the default build. Your `cargo tree` stays clean.
- **WASM-first**: a ~19 KB-gzipped module that parses HTML to JSON in any JS
runtime (browser / Deno / Cloudflare Workers / Node).
- **Built-in JSON** output — no `serde` required (it's an optional feature).
- **Forgiving**: missing end tags, void elements, mis-nesting, stray tags,
unquoted/boolean attributes, comments, doctypes, `<script>`/`<style>`,
entities — all handled, and it never panics.
- **`no_std` + `alloc`** compatible.
- **Ergonomic tree**: `parent`/`children`/`descendants`/`ancestors`,
`find_by_tag/id/class`, and optional CSS-selector querying.
## How it compares
| Default dependencies | **0** | 0 | 5+ | 10+ | 11+ |
| WASM + npm package | **✅** | ✅ | ❌ | ❌ | ❌ |
| Built-in JSON output | **✅** | ❌ | ❌ | ❌ | ❌ |
| CSS-selector querying | subset¹ | limited | ✅ | ❌ | ✅ |
| Streaming tokenizer | ✅ | ❌ | ❌ | partial | ✅ |
| `no_std` | **✅** | ❌ | ❌ | ❌ | ❌ |
| Never panics on input | ✅ | ✅ | ✅ | ✅ | ✅ |
| WHATWG spec-compliant | ❌ | ❌ | ✅ | ✅ | ❌ |
¹ behind the `query` feature.
[tl]: https://crates.io/crates/tl
[scraper]: https://crates.io/crates/scraper
[html5ever]: https://crates.io/crates/html5ever
[lol_html]: https://crates.io/crates/lol_html
## Performance
Parsing is fast *for an owned tree*. On a synthetic article workload (Apple
M-series, single core, `--release`):
| ~1 KB | ~256 MiB/s | ~308 MiB/s | — |
| ~50 KB | ~250 MiB/s | ~297 MiB/s | — |
| ~1 MB | ~248 MiB/s | ~257 MiB/s | — |
We land within ~5–15 % of `tl` (a SIMD speed specialist) and roughly on par on
large documents, while staying zero-dependency and producing an *owned* tree.
`tl` will usually win on raw throughput — **if speed is your only goal, use
`tl`.** † html5ever benches around ~50 MB/s in the community
[rust-html-parser-benchmark]; we don't include it in our harness to keep the
benchmark crate dependency-light. Run the numbers yourself:
```sh
cd benchmarks && cargo bench
```
[rust-html-parser-benchmark]: https://github.com/y21/rust-html-parser-benchmark
## Install
```toml
[dependencies]
dom-tree-rs = "0.2"
```
The library is imported as `domtree`:
```rust
use domtree::parse;
```
## Usage
### Traverse
```rust
let dom = domtree::parse("<article><h1>Title</h1><p>Body <a href=\"/x\">link</a></p></article>");
let h1 = dom.find_by_tag("h1").next().unwrap();
assert_eq!(h1.text_content(), "Title");
for a in dom.find_by_tag("a") {
println!("{} -> {}", a.text_content(), a.attr("href").unwrap_or(""));
}
// arena-backed: parent / siblings / ancestors are O(1) per step
let link = dom.find_by_tag("a").next().unwrap();
assert_eq!(link.parent().unwrap().tag_name(), Some("p"));
```
### Query with CSS selectors (`query` feature)
```toml
dom-tree-rs = { version = "0.2", features = ["query"] }
```
```rust
# #[cfg(feature = "query")] {
let dom = domtree::parse("<ul class=links><li><a href=/a>a</a><li><a href=/b rel=next>b</a></ul>");
assert_eq!(dom.select("ul.links a[href]").count(), 2);
assert_eq!(dom.select("a[rel=next]").next().unwrap().text_content(), "b");
# }
```
Supported: type / `*` / `#id` / `.class` / `[attr]` / `[attr=value]`,
descendant (` `) and child (`>`) combinators, and `,` selector lists.
### JSON
```rust
let dom = domtree::parse("<p>hi</p>");
let json = dom.to_json();
// {"type":"document","errors":0,"children":[
// {"type":"element","tag":"p","attrs":{},"children":[
// {"type":"text","value":"hi"}]}]}
```
With the `serde` feature, the tree also implements `serde::Serialize` (same JSON
shape).
### HTML
Re-serialize a tree (or subtree) back to HTML — a `parse → to_html → parse`
round-trip is stable:
```rust
let dom = domtree::parse("<ul><li>a<li>b</ul>"); // missing </li>s
assert_eq!(dom.to_html(), "<ul><li>a</li><li>b</li></ul>");
```
### WebAssembly
See [`dom-tree-wasm/`](./dom-tree-wasm) — parse HTML to JSON from JavaScript with
a ~19 KB-gzipped module and zero JS dependencies.
```js
import init, { parse } from "dom-tree-wasm";
await init();
const doc = JSON.parse(parse("<ul><li>a<li>b</ul>"));
```
## What it handles
Void elements (`<br>`, `<img>`, …), self-closing tags, comments, doctypes,
CDATA, raw text (`<script>`/`<style>`), escapable raw text
(`<title>`/`<textarea>`), boolean and unquoted attributes,
hyphenated/namespaced/custom tags, the common HTML entities (named + numeric),
and implicit closing for the everyday cases (`<li>`, `<p>`, table cells/rows,
`<dt>`/`<dd>`, `<option>`).
## What it deliberately does not do
It is **not** a full WHATWG tree builder: no adoption-agency algorithm, no
automatic `<html>`/`<head>`/`<body>` insertion, no foster parenting, and only a
curated subset (~130) of named entities. Everything it recovers from is recorded
in [`Dom::errors`](https://docs.rs/dom-tree-rs/latest/domtree/struct.Dom.html#method.errors).
If you need byte-exact browser parsing, use `html5ever`.
## Features
| `json` | ✅ | the zero-dependency `Dom::to_json` serializer |
| `serde` | | `serde::Serialize` for the tree (adds the `serde` dep) |
| `query` | | the CSS-subset selector engine, `Dom::select` |
| `std` | | `std`-only conveniences; the crate is `no_std + alloc` otherwise |
## MSRV
Rust **1.85** (edition 2024). Built and tested on the latest stable.
## Quality
Property-tested for "never panics" + tree invariants (proptest, thousands of
cases per run), snapshot-tested against a corpus of messy HTML, and CI runs
fmt / clippy / a `no_std` build / a wasm size gate / `cargo-fuzz`.
## Roadmap
See [ROADMAP.md](./ROADMAP.md) for what's planned (`to_html()` re-serialization,
wider selectors and entities, streaming events, performance) — and, just as
importantly, the **non-goals** that keep this crate tiny and honest. Ideas and
PRs that fit the philosophy are welcome.
## License
[MIT](./LICENSE).