domtree/lib.rs
1//! # dom-tree-rs
2//!
3//! A tiny, **zero-dependency**, forgiving HTML parser. It turns messy
4//! real-world HTML into a clean DOM tree (and JSON) and **never panics** on bad
5//! input — ideal for the browser, the edge, or anywhere you don't want to pull
6//! in a 100 KB+ parser.
7//!
8//! It is *not* a spec-compliant WHATWG parser (see
9//! [`html5ever`](https://crates.io/crates/html5ever) for that) and *not* a
10//! speed record (see [`tl`](https://crates.io/crates/tl)). It is the smallest
11//! way to get a usable tree out of arbitrary HTML.
12//!
13//! ## Quick start
14//!
15//! ```
16//! let dom = domtree::parse(r#"<ul id="list"><li class="item">Item 1</li></ul>"#);
17//!
18//! let li = dom.find_by_tag("li").next().unwrap();
19//! assert_eq!(li.attr("class"), Some("item"));
20//! assert_eq!(li.text_content(), "Item 1");
21//!
22//! // The first list element:
23//! let ul = dom.root().unwrap();
24//! assert_eq!(ul.tag_name(), Some("ul"));
25//! assert_eq!(ul.children().count(), 1);
26//! # #[cfg(feature = "json")]
27//! # assert!(dom.to_json().contains("\"tag\":\"li\""));
28//! ```
29//!
30//! ## What it handles
31//!
32//! Void elements (`<br>`, `<img>`, …), self-closing tags, comments, doctypes,
33//! CDATA, raw text (`<script>`/`<style>`), boolean and unquoted attributes,
34//! hyphenated/custom tags, common HTML entities, and the everyday "forgot a
35//! closing tag" cases (`<li>`, `<p>`, table cells, …) via implicit closing.
36//!
37//! ## What it deliberately does *not* do
38//!
39//! Full WHATWG tree construction: no adoption-agency algorithm, no automatic
40//! `<html>`/`<head>`/`<body>` insertion, no foster parenting. Anything it has
41//! to recover from is recorded in [`Dom::errors`].
42//!
43//! No unbounded search for the element an end tag closes: it must be among
44//! the 256 innermost open elements or the 8 outermost (where `</body>` and
45//! `</html>` find theirs). An end tag for an element open only in between is
46//! ignored as stray ([`ErrorKind::StrayEndTag`]) and the element stays open.
47//! Real documents almost never nest that deep, and the bound keeps every end
48//! tag O(1) on hostile input.
49//!
50//! ## Getting content out
51//!
52//! - [`Dom::to_text`] — readable plain text: no `<script>`/`<style>`/`<head>`,
53//! whitespace collapsed, a line per block, a blank line between paragraphs.
54//! - [`Dom::to_markdown`] — CommonMark + GFM (headings, lists, links, images,
55//! code blocks, tables, quotes): for LLM prompts, RAG indexes and notes.
56//! - [`Dom::to_html`], [`NodeRef::inner_html`], [`NodeRef::outer_html`] — HTML.
57//! - [`Dom::to_markdown_chunks`] — that Markdown split at headings into
58//! chunks under a size budget, each with its heading path: for RAG indexes.
59//!
60//! ```
61//! let dom = domtree::parse(
62//! "<article><h2>News</h2><p>It <em>works</em>!</p><script>track()</script></article>",
63//! );
64//! assert_eq!(dom.to_text(), "News\n\nIt works!");
65//! # #[cfg(feature = "markdown")]
66//! assert_eq!(dom.to_markdown(), "## News\n\nIt *works*!");
67//! ```
68//!
69//! ## Editing and source positions
70//!
71//! A [`Dom`] doubles as a small HTML rewriter: [`Dom::remove`],
72//! [`Dom::set_text`], [`Dom::set_attr`], [`Dom::rename`], [`Dom::wrap`],
73//! [`Dom::unwrap`], parsed-fragment insertion ([`Dom::append_html`] and
74//! friends) and node moves ([`Dom::append_child`], [`Dom::insert_before`]).
75//! Parsed nodes also remember where they came from:
76//! [`NodeRef::source_range`] plus [`line_col`].
77//!
78//! ```
79//! let src = "<p>Hello <b>world</b></p>";
80//! let mut dom = domtree::parse(src);
81//! let b = dom.find_by_tag("b").next().unwrap();
82//! assert_eq!(b.source_range(), Some(9..21));
83//! assert_eq!(domtree::line_col(src, 9), (1, 10));
84//!
85//! let b = b.id();
86//! dom.rename(b, "strong");
87//! dom.insert_html_after(b, "!");
88//! assert_eq!(dom.to_html(), "<p>Hello <strong>world</strong>!</p>");
89//! ```
90//!
91//! ## Structure and data
92//!
93//! - [`Dom::main_content`] — the article / post / docs body without the
94//! navigation, sidebars, comments and footer ("reader mode").
95//! - [`Dom::links`] / [`Dom::resources`] — every link (with its text) and
96//! every embedded URL, resolved against the page URL and `<base href>`
97//! ([`resolve_url`], [`resolve_srcset`]: RFC 3986, zero-dependency).
98//! - [`Dom::metadata`] — title, description, canonical URL, language,
99//! OpenGraph / Twitter-card fields, JSON-LD blocks and feeds.
100//! - [`NodeRef::table_rows`] — a `<table>` as rows of cell text with
101//! `colspan`/`rowspan` expanded; [`to_csv`] formats them.
102//!
103//! ```
104//! let dom = domtree::parse(r#"<html lang="en"><head><title>Release notes</title></head>
105//! <body><nav><a href="/">Home</a></nav>
106//! <article><h1>Version 2</h1>
107//! <p>This release adds link extraction, page metadata, tables as data and a
108//! reader mode, all without a single new dependency.</p>
109//! <p>See the <a href="changelog.html">full changelog</a> for details.</p>
110//! </article></body></html>"#);
111//!
112//! assert_eq!(dom.metadata().title.as_deref(), Some("Release notes"));
113//! let article = dom.main_content().unwrap();
114//! assert_eq!(article.tag_name(), Some("article"));
115//! let links = dom.links(Some("https://example.com/news/"));
116//! assert_eq!(links[1].url, "https://example.com/news/changelog.html");
117//! assert_eq!(links[1].text, "full changelog");
118//! ```
119//!
120//! ## Features
121//!
122//! - `json` *(default)* — the zero-dependency [`Dom::to_json`] serializer.
123//! - `markdown` *(default)* — the zero-dependency [`Dom::to_markdown`] and
124//! [`Dom::to_markdown_chunks`].
125//! - `serde` — optional `serde::Serialize` for the tree, [`Metadata`] and
126//! [`Chunk`] (adds the `serde` dep).
127//! - `query` — a zero-dependency CSS selector engine ([`Dom::select`], [`Selector`]).
128//! - `entities-full` — decode all 2,125 WHATWG named character references
129//! instead of the curated ~180 (a sorted static table, about 70 KB).
130//! - `simd` — scan text and attribute values with
131//! [`memchr`](https://crates.io/crates/memchr) (SIMD, `no_std`) instead of
132//! the built-in word-at-a-time search. Adds that one dependency; tokens and
133//! trees are identical either way. Worth it only for very text-heavy input.
134//! - `std` — adds `std`-only conveniences; the crate is `no_std + alloc` by default.
135
136#![no_std]
137#![forbid(unsafe_code)]
138#![cfg_attr(docsrs, feature(doc_cfg))]
139#![cfg_attr(
140 not(test),
141 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
142)]
143#![warn(missing_docs)]
144
145extern crate alloc;
146
147#[cfg(any(feature = "std", test))]
148extern crate std;
149
150mod builder;
151mod edit;
152mod entities;
153#[cfg(feature = "entities-full")]
154mod entities_full;
155mod error;
156mod events;
157mod extract;
158mod node;
159mod scan;
160mod source;
161mod tags;
162mod token;
163mod tokenizer;
164mod tree;
165mod url;
166
167mod serialize;
168
169#[cfg(feature = "query")]
170mod query;
171
172pub use error::{ErrorKind, ParseError};
173pub use events::{Event, Events};
174pub use extract::{Link, Metadata, Resource, to_csv};
175pub use node::{NodeId, NodeKind};
176pub use source::line_col;
177pub use token::{AttrValue, Token};
178pub use tokenizer::Tokenizer;
179pub use tree::{Descendants, Dom, NodeRef};
180pub use url::{resolve_srcset, resolve_url};
181
182#[cfg(feature = "markdown")]
183pub use serialize::Chunk;
184
185#[cfg(feature = "query")]
186pub use query::{AsSelector, Selector, SelectorError};
187
188/// Parse a full HTML document into a best-effort tree.
189///
190/// Never panics. Any recovery the parser performed is recorded in
191/// [`Dom::errors`].
192///
193/// ```
194/// let dom = domtree::parse("<p>hello<p>world");
195/// assert_eq!(dom.find_by_tag("p").count(), 2); // implicit </p>
196/// ```
197pub fn parse(html: &str) -> Dom {
198 builder::parse_document(html)
199}
200
201/// Parse an HTML fragment.
202///
203/// Uses the same engine as [`parse`]; the name simply documents intent (a
204/// fragment commonly has several top-level nodes).
205pub fn parse_fragment(html: &str) -> Dom {
206 builder::parse_document(html)
207}
208
209/// Create a streaming [`Tokenizer`] over `html` without building a tree.
210///
211/// ```
212/// use domtree::Token;
213/// let kinds: usize = domtree::tokenize("<b>hi</b>").count();
214/// assert_eq!(kinds, 3); // <b>, "hi", </b>
215/// ```
216pub fn tokenize(html: &str) -> Tokenizer<'_> {
217 Tokenizer::new(html)
218}
219
220/// Stream a document as [`Event`]s — start and end of elements, text,
221/// comments, doctypes — without building a tree.
222///
223/// Unlike [`tokenize`], the events come **after recovery**, from the same
224/// tree builder [`parse`] uses: omitted end tags are implied (`<li>a<li>b`
225/// ends the first `<li>`), void and self-closing elements end right away,
226/// stray end tags are dropped, mismatched ones close what they skip, and
227/// everything still open ends at the end of the input. So every `Start` has
228/// exactly one matching `End`, and replaying the events builds exactly the
229/// tree `parse` returns. Text and attribute values are entity-decoded, and
230/// each event comes with its byte range in `html` (see [`Events`]).
231///
232/// Memory use is proportional to the nesting depth, not the document size —
233/// except for the recovery diagnostics, which are kept for
234/// [`Events::errors`] (16 bytes each; messy markup has about one per
235/// implicitly closed element). It is not faster than [`parse`]: each start
236/// tag with attributes gets its own `Vec`, where the tree shares one arena.
237///
238/// ```
239/// use domtree::Event;
240///
241/// let html = "<ul><li>Tom & Jerry<li>Spike<br></ul>";
242/// let mut depth = 0;
243/// let mut items = Vec::new();
244/// for (event, range) in domtree::events(html) {
245/// match event {
246/// Event::Start { name, .. } => {
247/// depth += 1;
248/// if name == "li" {
249/// items.push(String::new());
250/// }
251/// }
252/// Event::End { name, implied } => {
253/// depth -= 1;
254/// if name == "li" {
255/// // Both `</li>` were omitted: the first is closed by the
256/// // second `<li>`, the second by `</ul>`.
257/// assert!(implied);
258/// assert!(range.is_empty());
259/// }
260/// }
261/// Event::Text(text) => {
262/// if let Some(item) = items.last_mut() {
263/// item.push_str(&text);
264/// }
265/// }
266/// _ => {}
267/// }
268/// }
269/// assert_eq!(depth, 0);
270/// assert_eq!(items, ["Tom & Jerry", "Spike"]);
271/// ```
272pub fn events(html: &str) -> Events<'_> {
273 Events::new(html)
274}