domq 0.5.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 · Guide · 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.5"
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-wasm
import init, { select }
  from "domq-wasm";
await init();
select(html, "a[href]");

Also: 🤖 AI agents — domq mcp (setup) · 🐍 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.
  • Agent-ready. domq mcp is an MCP server, so Claude, Cursor or any MCP client can turn the HTML it has into Markdown, fields or links.

¹ 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

AI agents (MCP)

domq mcp runs the CLI as a Model Context Protocol server over stdio. An agent that has fetched a page hands its HTML to domq and gets back what it needs, instead of reading raw markup:

Tool Returns
markdown the page (or each match of a selector) as Markdown; main keeps just the article
text readable text
select elements matching a CSS selector: tag, attributes, text (and HTML)
extract named fields as JSON, one object per item: {"name": "h2", "url": "a@href"}
chunks Markdown chunks with their heading paths, for RAG
links, metadata, tables links with resolved URLs; title, OpenGraph, JSON-LD…; tables as rows
lint accessibility and markup problems, with line and column

Every tool is read-only and works only on the HTML in its arguments: domq never fetches URLs or reads files. It speaks MCP 2026-07-28 and the earlier handshake-based revisions (2025-11-25 back to 2024-11-05).

claude mcp add domq -- domq mcp        # Claude Code
codex mcp add domq -- domq mcp         # Codex CLI

For Claude Desktop, Cursor, VS Code, Windsurf and other clients, add it to the MCP config:

{ "mcpServers": { "domq": { "command": "domq", "args": ["mcp"] } } }

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-wasm";
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 (including browsers' recovery of <!--> and --!>), doctypes, CDATA, raw text (<script>, <style>, <xmp>, <iframe>, <noembed>, <noframes>, <plaintext>), escapable raw text (<title>/<textarea>), boolean and unquoted attributes, hyphenated/namespaced/custom tags, named + numeric entities (with browser-style Windows-1252 remapping, and a missing ; recovered where browsers do: &copy 2026, &#169), and implicit closing as browsers scope it: a <li>, <dd>/<dt>, table cell or row closes its predecessor even past unclosed inline elements (<li><a>One<li><a>Two), a block closes an open <p> past them (<p><b>x<div>), a stray </p> makes an empty paragraph, </br> is a <br>. Everything it recovers from is recorded in Dom::errors. How close the trees are to a browser's is measured in docs/conformance.md.

Deliberately not done: full WHATWG tree construction (adoption agency and reopening formatting elements, automatic <html>/<head>/<body>, foster parenting, namespaces). 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.

Measured against browsers: CI runs the 1,959 HTML tree-construction tests (html5lib, in web-platform-tests) and publishes the results in docs/conformance.md: 0 panics, and 61.8% of trees match a browser's structure in domq's model (no implied <html>/<body>), with the gaps explained area by area. Reader mode is scored against Mozilla Readability's 130 labelled pages in docs/reader-mode.md: 95.2% F1 against Firefox's reader view, up from 82.1% for the whole page.

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