domq 0.4.0

Tiny, zero-dependency, forgiving HTML parser: turn messy real-world HTML into a clean DOM tree (and JSON). WASM-first, no_std-friendly.
Documentation

A tiny, zero-dependency, never-panicking HTML parser with CSS selectors — for Rust, your shell, JavaScript (a ~41 KB gzipped WebAssembly module) and Python.

crates.io npm docs.rs CI dependencies no_std unsafe MSRV license

▶ Try it in your browser · Rust docs · CLI · JavaScript · Roadmap

New name: up to 0.3 this project was published as dom-tree-rs (crates.io, PyPI), domtree-cli and dom-tree-wasm (npm). From 0.4 it is domq everywhere; the old packages point here.


let dom = domq::parse(r#"<ul id=links><li><a href="/a">One</a><li><a href="/b">Two</a></ul>"#);

for a in dom.select("#links a[href]") {
    println!("{} -> {}", a.text_content(), a.attr("href").unwrap_or_default());
}
// One -> /a
// Two -> /b        (the missing </li>s were recovered)

let md = domq::parse("<h2>Notes</h2><p>Some <b>bold</b> text</p>").to_markdown();
assert_eq!(md, "## Notes\n\nSome **bold** text");

Pick your entry point

[dependencies]
domq = "0.3"
let dom = domq::parse(html);
dom.select_first("h1")
   .map(|h| h.to_text());
cargo install domq-cli
curl -sL rust-lang.org \
  | domq -t h1
npm install domq
import init, { select }
  from "domq";
await init();
select(html, "a[href]");

Also: 🐍 Python — pip install domq (docs) · 🍺 Homebrew — brew install iamsaquib8/tap/domq · ⚡ WASI — domq.wasm on every release.

Why domq?

  • Zero dependencies. One crate in your cargo tree. A clean build takes 0.6 s — scraper pulls in 42 crates and takes 5.3 s.¹
  • Never panics. Any byte sequence gives you a best-effort tree. Proptested and fuzzed: parse, select, to_text, to_markdown. Deep nesting can't blow the stack.
  • Real CSS selectors. Combinators ( , >, +, ~), every attribute operator, :nth-child(), :not(), :is(), :has(), and the scraper's favourite, :contains("text").
  • Content out, not just a tree. Readable text (to_text), CommonMark/GFM Markdown (to_markdown) for LLM prompts and RAG pipelines, JSON, and HTML.
  • Runs everywhere. no_std + alloc, #![forbid(unsafe_code)], a ~41 KB gzipped wasm module for browsers, Cloudflare Workers, Deno, Bun and Node, and a single static binary for your shell.
  • Fast. ~500 MB/s on markup — about 8× scraper, level with or ahead of tl — with ~20 allocations per parse (see Performance).
  • Streams, too. domq::events yields start/end/text events with the same recovery as the tree builder, for documents you don't want to keep.

¹ Clean debug cargo build of an empty crate depending only on the parser (default features), Apple M4 Pro, 14 cores: domq 0.6 s · tl 0.4 s · html5ever 2.9 s · scraper 5.3 s · lol_html 6.9 s. Fewer cores widens the gap.

Cookbook

let dom = domq::parse(html);

let title = dom.select_first("title").map(|t| t.to_text());
let links: Vec<&str> = dom.select("a[href^='https://']").filter_map(|a| a.attr("href")).collect();
let price = dom.select_first(".product:has(.in-stock) .price").map(|p| p.to_text());
use domq::FromHtml;

#[derive(FromHtml)]
#[html(select = ".product")]
struct Product {
    #[html(select = "h2")] name: String,
    #[html(select = ".price", attr = "data-value")] price: f64,
    #[html(select = ".tag")] tags: Vec<String>,                // every match
    #[html(select = "a.more", attr = "href")] link: Option<String>, // None if missing
}

let products: Vec<Product> = domq::parse(html).extract_all()?;

Nested structs, default, with = parse_fn, and html / outer_html / markdown sources are supported, and misused attributes are compile errors. Runtime errors name the field and selector: Product.price (".price" @data-value): cannot parse "cheap" as f64: invalid float literal.

let dom = domq::parse(html);
let article = dom.select_first("article, main").map(|n| n.to_markdown());

Or from the shell, stripping the chrome first:

curl -sL https://blog.rust-lang.org | domq -r 'nav, footer, script' --markdown body
let dom = domq::parse(html);
let article = dom.main_content();                   // reader mode: drops nav, ads, comments…
let chunks = match article {
    Some(node) => node.to_markdown_chunks(1500),
    None => dom.to_markdown_chunks(1500),
};
for chunk in &chunks {
    println!("[{}]\n{}", chunk.context(), chunk.markdown); // e.g. "[Guide > Install]"
}
let dom = domq::parse(html);

for link in dom.links(Some("https://example.com/blog/")) {
    println!("{} -> {}", link.text, link.url);       // resolved, honours <base href>
}

let meta = dom.metadata();                          // title, description, OpenGraph, JSON-LD…
println!("{:?} {:?}", meta.title, meta.canonical);

if let Some(table) = dom.select_first("table#prices") {
    let rows = table.table_rows();                  // colspan/rowspan expanded
    print!("{}", domq::to_csv(&rows));
}
use domq::Selector;

let sel = Selector::parse("li:nth-child(odd) > a").expect("valid selector");
for page in pages {
    let dom = domq::parse(page);
    let count = dom.select(&sel).count();
}

let err = Selector::parse("a:hover").unwrap_err();
println!("{err}"); // unsupported pseudo-class ':hover' at byte 1
let link = dom.select_first("a").unwrap();
let section = link.closest("section");          // nearest matching ancestor
let parent = link.parent();                     // O(1) — arena-backed
let next = link.next_element_sibling();
for node in link.ancestors() { /* … */ }
for node in dom.root().unwrap().descendants() { /* … */ }
let mut dom = domq::parse("<p>Hi <script>track()</script><a href=/x>x</a></p>");
let scripts: Vec<_> = dom.select("script").map(|n| n.id()).collect();
for id in scripts {
    dom.remove(id);
}
assert_eq!(dom.to_html(), r#"<p>Hi <a href="/x">x</a></p>"#);

// Insert parsed HTML, wrap, rename, set attributes and text:
let a = dom.select_first("a").unwrap().id();
dom.set_attr(a, "rel", "nofollow");
dom.insert_html_after(a, " <b>new</b>");
dom.wrap(a, "em");
let p = dom.root().unwrap().id();
dom.rename(p, "div");
assert_eq!(
    dom.to_html(),
    r#"<div>Hi <em><a href="/x" rel="nofollow">x</a></em> <b>new</b></div>"#
);
let src = "<ul>\n  <li>one\n  <li>two</ul>";
let dom = domq::parse(src);
let li = dom.select("li").nth(1).unwrap();
let range = li.source_range().unwrap();              // byte range in `src`
assert_eq!(&src[range.clone()], "<li>two");
assert_eq!(domq::line_col(src, range.start), (3, 3)); // 1-based line, column

Command line

domq is a maintained, zero-dependency, drop-in replacement for htmlq — same flags, plus Markdown, JSON/JSON Lines and CSV output, structured extraction, reader mode, encoding detection, a linter, and modern selectors. Full CLI docs →

curl -s https://news.ycombinator.com | domq -t '.titleline > a'           # headlines
curl -sL https://www.rust-lang.org | domq -b https://www.rust-lang.org -a href 'nav a'
curl -sL https://blog.rust-lang.org | domq -r 'nav, footer' --markdown body # page → Markdown
domq -c 'img:not([alt])' < page.html                                       # a11y check
echo '<ul><li>a<li>b</ul>' | domq -p                                       # pretty-print

# Structured extraction: one JSON object per post
curl -sL https://blog.rust-lang.org | domq --jsonl -b https://blog.rust-lang.org/ \
  -e title=a -e url=a@href 'tr:has(a)'
# {"title":"Announcing Rust 1.99.1","url":"https://blog.rust-lang.org/2026/10/15/Rust-1.99.1/"}

# Reader mode → Markdown, tables → CSV, page metadata → JSON
curl -sL https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/ | domq --main --markdown
domq --csv 'table#prices' < report.html
domq --meta < page.html

# Lint HTML in CI (file:line:col, exit 1 on findings)
domq lint public/**/*.html

JavaScript & WebAssembly

The same parser — selectors, text, Markdown and all — in a ~41 KB (gzipped) wasm module with zero JS dependencies and full TypeScript types. Works in the browser, Cloudflare Workers, Deno, Bun, and Node (no setup needed in Node). Package docs → · Playground →

import init, { parse, select, toMarkdown, Document } from "domq";
await init(); // browsers and Workers; harmless in Node

const tree = parse("<ul><li>a<li>b</ul>");   // typed tree: { type: "document", children: [...] }
const links = select(html, "a[href]");       // [{ tag, attrs, text, html, nodeIndex }, ...]
const md = toMarkdown(html, "article");      // just the article, as Markdown

const page = new Document(html);             // parse once, query many times
const headings = page.select("h2").map((h) => h.text);
page.free();

Python

Same engine, Pythonic API, abi3 wheels for Linux, macOS and Windows (Python ≥ 3.11). The GIL is released while parsing large documents. Package docs →

import domq

doc = domq.parse(html)
links = [a.attr("href") for a in doc.select("a[href]")]
price = doc.select_first(".product:has(.in-stock) .price").text
markdown = doc.select_first("article").to_markdown()   # ready for an LLM prompt

# Declarative extraction into dataclasses (mirrors Rust's #[derive(FromHtml)])
from dataclasses import dataclass
from typing import Annotated, Optional
from domq import extract_all, Select, Attr

@dataclass
class Product:
    __select__ = ".product"
    name: Annotated[str, Select("h2")]
    price: Annotated[float, Attr(".price", "data-value")]
    link: Annotated[Optional[str], Attr("a.more", "href")]

products = extract_all(Product, html)

How it compares

domq tl scraper html5ever lol_html
Transitive dependencies 0 0 42 19 41
CSS selectors ✅ limited ✅ ❌ ✅
Text / Markdown output ✅ ❌ ❌ ❌ ❌
JSON output ✅ ❌ ❌ ❌ ❌
WASM + npm package ✅ ❌ ❌ ❌ ❌
CLI ✅ ❌ ❌ ❌ ❌
no_std ✅ ❌ ❌ ❌ ❌
Edit + re-serialize ✅ ❌ ❌ ✅ ✅ (streaming)
Streaming events with recovery ✅ ❌ ❌ ✅ (TreeSink) ✅
Never panics on input ✅ ✅ ✅ ✅ ✅
WHATWG spec-compliant tree ❌ ❌ ✅ ✅ ❌

When to pick something else: if you need byte-exact browser parsing, use html5ever/scraper. If raw throughput is all that matters, use tl. If you're sanitizing untrusted HTML, use ammonia — never a forgiving, non-spec parser.

Performance

Fast for an owned tree — full recovery, entity decoding, source spans and all. Apple M4 Pro, cargo bench (generated inputs; see benchmarks/ for method and caveats):

input domq tl scraper html5ever¹
small (0.5 KiB) 400 MB/s 395 MB/s 53 MB/s 48 MB/s
medium (21 KiB) 491 MB/s 462 MB/s 60 MB/s 53 MB/s
large (420 KiB) 512 MB/s 358 MB/s 61 MB/s 54 MB/s
table-heavy (712 KiB) 422 MB/s 326 MB/s 58 MB/s 51 MB/s
text-heavy (547 KiB) 7.7 GB/s 8.5 GB/s 1.9 GB/s 1.6 GB/s

About 8× faster than scraper/html5ever on markup, ahead of tl on large documents, and ~10% behind it on prose (tl borrows from the input and skips entity decoding and recovery). A parse makes ~20 allocations in total, and the optional simd feature cuts text-heavy parse time by another ~24%. Run them yourself: cd benchmarks && cargo bench.

¹ into its reference RcDom.

What it handles

Void elements, self-closing tags, comments, doctypes, CDATA, raw text (<script>/<style>), escapable raw text (<title>/<textarea>), boolean and unquoted attributes, hyphenated/namespaced/custom tags, named + numeric entities (with browser-style Windows-1252 remapping), and implicit closing for the everyday cases (<li>, <p>, table cells/rows, <dt>/<dd>, <option>). Everything it recovers from is recorded in Dom::errors.

Deliberately not done: full WHATWG tree construction (adoption agency, automatic <html>/<head>/<body>, foster parenting). See the non-goals.

Cargo features

feature default what it adds
json ✅ to_json() — zero-dependency JSON
query ✅ CSS selectors: select, select_first, Selector, closest, matches
markdown ✅ to_markdown() and to_markdown_chunks() — zero-dependency HTML → Markdown
derive #[derive(FromHtml)] — declarative extraction into structs (adds the domq-derive proc-macro crate)
entities-full all 2,125 WHATWG named character references (default: the 178 most common)
simd memchr-backed scanning (~24% faster on text-heavy pages; adds memchr)
serde serde::Serialize for the tree (same JSON shape; adds serde)
std std::error::Error impls; the crate is no_std + alloc otherwise

MSRV

Rust 1.88 (edition 2024), enforced in CI. Raised only in minor releases.

Quality

Property-tested ("never panics" + tree invariants), snapshot-tested against a corpus of messy HTML, fuzzed with cargo-fuzz, and CI-checked for fmt, clippy, no_std, MSRV, zero dependencies and a wasm size budget.

Contributing

Issues and PRs are welcome! Start with CONTRIBUTING.md, the architecture guide, and the roadmap — issues labelled good first issue are a friendly way in. Please follow the code of conduct, and report security problems privately as described in SECURITY.md.

If domq saved you some time, a ⭐ helps other people find it.

License

MIT