domq/lib.rs
1//! # domq
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 = domq::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 = domq::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 = domq::parse(src);
81//! let b = dom.find_by_tag("b").next().unwrap();
82//! assert_eq!(b.source_range(), Some(9..21));
83//! assert_eq!(domq::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 = domq::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//! ## Declarative extraction
121//!
122//! With the `derive` feature, `#[derive(FromHtml)]` reads a struct out of a
123//! page: each field names a CSS selector and what to read from the element it
124//! matches. [`Dom::extract`] reads one value, [`Dom::extract_all`] one per
125//! match of the type's own selector, [`NodeRef::extract`] one from an element.
126//!
127//! ```
128//! # #[cfg(feature = "derive")] {
129//! use domq::FromHtml;
130//!
131//! #[derive(FromHtml, Debug, PartialEq)]
132//! struct Product {
133//! #[html(select = "h1")]
134//! name: String,
135//! #[html(select = ".price", attr = "data-value")]
136//! price: f64,
137//! #[html(select = ".tags li")]
138//! tags: Vec<String>,
139//! #[html(select = ".discount")]
140//! discount: Option<String>,
141//! #[html(select = ".seller")]
142//! seller: Seller,
143//! #[html(select = ".review")]
144//! reviews: Vec<Review>,
145//! }
146//!
147//! #[derive(FromHtml, Debug, PartialEq)]
148//! struct Seller {
149//! #[html(select = ".name")]
150//! name: String,
151//! #[html(select = "a", attr = "href")]
152//! url: String,
153//! }
154//!
155//! #[derive(FromHtml, Debug, PartialEq)]
156//! struct Review {
157//! #[html(select = ".stars", attr = "data-value")]
158//! stars: u8,
159//! #[html(select = "p")]
160//! text: String,
161//! }
162//!
163//! let dom = domq::parse(r#"
164//! <h1>Acme Anvil</h1>
165//! <span class="price" data-value="149.99">$149.99</span>
166//! <ul class="tags"><li>tools</li><li>heavy</li></ul>
167//! <div class="seller"><b class="name">Wile E.</b> <a href="/s/77">profile</a></div>
168//! <div class="review"><i class="stars" data-value="5"></i><p>Beep beep!</p></div>
169//! <div class="review"><i class="stars" data-value="1"></i><p>It fell on me.</p></div>
170//! "#);
171//! let product: Product = dom.extract().unwrap();
172//! assert_eq!(product.name, "Acme Anvil");
173//! assert_eq!(product.price, 149.99);
174//! assert_eq!(product.tags, ["tools", "heavy"]);
175//! assert_eq!(product.discount, None);
176//! assert_eq!(product.seller.url, "/s/77");
177//! assert_eq!(product.reviews[1], Review { stars: 1, text: "It fell on me.".into() });
178//!
179//! // Errors say which field, selector and value failed:
180//! let err = domq::parse("<h1>Anvil</h1><span class=price data-value='$5'>")
181//! .extract::<Product>()
182//! .unwrap_err();
183//! assert_eq!(
184//! err.to_string(),
185//! r#"Product.price (".price" @data-value): cannot parse "$5" as f64: invalid float literal"#,
186//! );
187//! # }
188//! ```
189//!
190//! **Field keys** — every field has `#[html(...)]` (a field without one is a
191//! compile error):
192//!
193//! | key | meaning |
194//! |---|---|
195//! | `select = "css"` | the elements to read; without it, the scope itself (the document, or the element of a nested struct) |
196//! | *(no source key)* | the [readable text](Dom::to_text) — or, for a type that implements [`FromHtml`] itself (a nested struct), that type read from the match |
197//! | `attr = "name"` | an attribute's value |
198//! | `html` / `outer_html` | the inner / outer HTML |
199//! | `markdown` | the Markdown (needs the `markdown` feature) |
200//! | `with = "path::to::fn"` | convert the value (text by default) with `fn(&str) -> Result<T, E>` where `E: Display`, instead of [`FromHtmlValue`] |
201//! | `default` | no match or no attribute gives `Default::default()` instead of an error |
202//! | `skip` | read nothing: `Default::default()` |
203//!
204//! At most one source per field. `attr`, `html`, `outer_html` and `markdown`
205//! convert the string with [`FromHtmlValue`] (implemented for `String`, the
206//! number types, `bool` and `char`; numbers, `bool` and `char` ignore
207//! surrounding whitespace, and `bool` is `true` or `false` in any ASCII case).
208//! On the struct, `#[html(select = "css")]` sets [`FromHtml::SELECTOR`]:
209//! where values of the type live, for [`Dom::extract_all`] (one per match)
210//! and [`Dom::extract`] (the first match).
211//!
212//! **The field type decides how many matches are read:** `Option<T>` is the
213//! first match or `None` (also `None` when the attribute is missing; a value
214//! that fails to convert is still an error), `Vec<T>` is every match in
215//! document order (it needs a `select`; an element without the attribute is
216//! an error, so select `a[href]` to skip them), and any other type is the
217//! first match, a [`NoMatch`](ExtractErrorKind::NoMatch) error without one.
218//! `Option` and `Vec` are recognized by name, so a type alias for one is read
219//! as a single value.
220//!
221//! **Errors** ([`ExtractError`]) carry the field path (`Product.seller.name`,
222//! `Product.reviews[1].stars`, `Product[1].title` for [`Dom::extract_all`]
223//! items), the selector, the source and the reason; fields are read in
224//! declaration order and the first failure is returned.
225//!
226//! **Notes.** Selectors are parsed at run time, once per field each time a
227//! value is read (so a `Vec<Review>` field parses `Review`'s selectors once per
228//! review); an invalid one is an [`InvalidSelector`](ExtractErrorKind::InvalidSelector)
229//! error naming the field. That is cheap next to matching, but in a hot loop
230//! parse each page once and read everything you need from that one [`Dom`].
231//! Generic structs work: the macro adds `T: FromHtml` (or [`FromHtmlValue`],
232//! or `Default`) bounds for the fields that need them. The generated code
233//! names only `::domq::__private` re-exports, never `std`, so it works in
234//! `no_std` crates — but it does need this crate to be reachable as
235//! `domq` (the default; a renamed dependency is not supported). Implement
236//! [`FromHtml`] by hand for anything the attributes can't express; a
237//! hand-written type nests in derived ones like any other.
238//!
239//! ## Features
240//!
241//! - `json` *(default)* — the zero-dependency [`Dom::to_json`] serializer.
242//! - `markdown` *(default)* — the zero-dependency [`Dom::to_markdown`] and
243//! [`Dom::to_markdown_chunks`].
244//! - `serde` — optional `serde::Serialize` for the tree, [`Metadata`] and
245//! [`Chunk`] (adds the `serde` dep).
246//! - `query` *(default)* — a zero-dependency CSS selector engine
247//! ([`Dom::select`], [`Selector`]), and the extraction traits built on it
248//! ([`FromHtml`], [`Dom::extract`]).
249//! - `derive` — `#[derive(FromHtml)]` (implies `query`): declarative
250//! extraction into structs. Re-exports the proc-macro from the companion
251//! `domq-derive` crate, which uses `syn`, `quote` and `proc-macro2` at
252//! compile time only; nothing is added to the compiled program.
253//! - `entities-full` — decode all 2,125 WHATWG named character references
254//! instead of the curated ~180 (a sorted static table, about 70 KB).
255//! - `simd` — scan text and attribute values with
256//! [`memchr`](https://crates.io/crates/memchr) (SIMD, `no_std`) instead of
257//! the built-in word-at-a-time search. Adds that one dependency; tokens and
258//! trees are identical either way. Worth it only for very text-heavy input.
259//! - `std` — adds `std`-only conveniences; the crate is `no_std + alloc` by default.
260
261#![no_std]
262#![forbid(unsafe_code)]
263#![cfg_attr(docsrs, feature(doc_cfg))]
264#![cfg_attr(
265 not(test),
266 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
267)]
268#![warn(missing_docs)]
269
270extern crate alloc;
271
272#[cfg(any(feature = "std", test))]
273extern crate std;
274
275mod builder;
276mod edit;
277mod entities;
278#[cfg(feature = "entities-full")]
279mod entities_full;
280mod error;
281mod events;
282mod extract;
283mod node;
284mod scan;
285mod source;
286mod tags;
287mod token;
288mod tokenizer;
289mod tree;
290mod url;
291
292mod serialize;
293
294#[cfg(feature = "query")]
295mod query;
296
297#[cfg(feature = "query")]
298mod from_html;
299
300pub use error::{ErrorKind, ParseError};
301pub use events::{Event, Events};
302pub use extract::{Link, Metadata, Resource, to_csv};
303pub use node::{NodeId, NodeKind};
304pub use source::line_col;
305pub use token::{AttrValue, Token};
306pub use tokenizer::Tokenizer;
307pub use tree::{Descendants, Dom, NodeRef};
308pub use url::{resolve_srcset, resolve_url};
309
310#[cfg(feature = "markdown")]
311pub use serialize::Chunk;
312
313#[cfg(feature = "query")]
314pub use query::{AsSelector, Selector, SelectorError};
315
316#[cfg(feature = "query")]
317pub use from_html::{ExtractError, ExtractErrorKind, FromHtml, FromHtmlValue, Scope, ValueSource};
318
319/// `#[derive(FromHtml)]`: declarative extraction into structs (the `derive`
320/// feature); see [Declarative extraction](crate#declarative-extraction).
321#[cfg(feature = "derive")]
322pub use domq_derive::FromHtml;
323
324/// What `#[derive(FromHtml)]` expands to. Not public API: no semver
325/// guarantees.
326#[cfg(feature = "query")]
327#[doc(hidden)]
328pub mod __private {
329 pub use alloc::string::String;
330 pub use alloc::vec::Vec;
331 pub use core::default::Default;
332 pub use core::option::Option::{self, None, Some};
333 pub use core::result::Result::{self, Err, Ok};
334
335 pub use crate::from_html::{Ctx, Src, all, nested, one, opt, value, with};
336 pub use crate::{ExtractError, FromHtml, FromHtmlValue, Scope};
337
338 pub use crate::__domq_require_markdown as require_markdown;
339}
340
341/// Stops the build of a `#[html(markdown)]` field when the `markdown`
342/// feature is off.
343#[cfg(not(feature = "markdown"))]
344#[doc(hidden)]
345#[macro_export]
346macro_rules! __domq_require_markdown {
347 () => {
348 ::core::compile_error!(
349 "`#[html(markdown)]` needs the `markdown` feature of domq (on by default)"
350 );
351 };
352}
353
354/// Stops the build of a `#[html(markdown)]` field when the `markdown`
355/// feature is off.
356#[cfg(feature = "markdown")]
357#[doc(hidden)]
358#[macro_export]
359macro_rules! __domq_require_markdown {
360 () => {};
361}
362
363/// Parse a full HTML document into a best-effort tree.
364///
365/// Never panics. Any recovery the parser performed is recorded in
366/// [`Dom::errors`].
367///
368/// ```
369/// let dom = domq::parse("<p>hello<p>world");
370/// assert_eq!(dom.find_by_tag("p").count(), 2); // implicit </p>
371/// ```
372pub fn parse(html: &str) -> Dom {
373 builder::parse_document(html)
374}
375
376/// Parse an HTML fragment.
377///
378/// Uses the same engine as [`parse`]; the name simply documents intent (a
379/// fragment commonly has several top-level nodes).
380pub fn parse_fragment(html: &str) -> Dom {
381 builder::parse_document(html)
382}
383
384/// Create a streaming [`Tokenizer`] over `html` without building a tree.
385///
386/// ```
387/// use domq::Token;
388/// let kinds: usize = domq::tokenize("<b>hi</b>").count();
389/// assert_eq!(kinds, 3); // <b>, "hi", </b>
390/// ```
391pub fn tokenize(html: &str) -> Tokenizer<'_> {
392 Tokenizer::new(html)
393}
394
395/// Stream a document as [`Event`]s — start and end of elements, text,
396/// comments, doctypes — without building a tree.
397///
398/// Unlike [`tokenize`], the events come **after recovery**, from the same
399/// tree builder [`parse`] uses: omitted end tags are implied (`<li>a<li>b`
400/// ends the first `<li>`), void and self-closing elements end right away,
401/// stray end tags are dropped, mismatched ones close what they skip, and
402/// everything still open ends at the end of the input. So every `Start` has
403/// exactly one matching `End`, and replaying the events builds exactly the
404/// tree `parse` returns. Text and attribute values are entity-decoded, and
405/// each event comes with its byte range in `html` (see [`Events`]).
406///
407/// Memory use is proportional to the nesting depth, not the document size —
408/// except for the recovery diagnostics, which are kept for
409/// [`Events::errors`] (16 bytes each; messy markup has about one per
410/// implicitly closed element). It is not faster than [`parse`]: each start
411/// tag with attributes gets its own `Vec`, where the tree shares one arena.
412///
413/// ```
414/// use domq::Event;
415///
416/// let html = "<ul><li>Tom & Jerry<li>Spike<br></ul>";
417/// let mut depth = 0;
418/// let mut items = Vec::new();
419/// for (event, range) in domq::events(html) {
420/// match event {
421/// Event::Start { name, .. } => {
422/// depth += 1;
423/// if name == "li" {
424/// items.push(String::new());
425/// }
426/// }
427/// Event::End { name, implied } => {
428/// depth -= 1;
429/// if name == "li" {
430/// // Both `</li>` were omitted: the first is closed by the
431/// // second `<li>`, the second by `</ul>`.
432/// assert!(implied);
433/// assert!(range.is_empty());
434/// }
435/// }
436/// Event::Text(text) => {
437/// if let Some(item) = items.last_mut() {
438/// item.push_str(&text);
439/// }
440/// }
441/// _ => {}
442/// }
443/// }
444/// assert_eq!(depth, 0);
445/// assert_eq!(items, ["Tom & Jerry", "Spike"]);
446/// ```
447pub fn events(html: &str) -> Events<'_> {
448 Events::new(html)
449}