#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! The bottom layer of reading and writing HTML, shared by everything that reads a page, a
//! mail body or a clipboard fragment.
//!
//! - [`Lexer`] is a tolerant tokenizer: text, start tags and end tags, borrowed from the input.
//! It reads anything: a `<` that starts no tag is text, a tag or comment that never ends runs
//! to the end of the input. [`Options`] names the elements to drop whole (`script`), the
//! elements whose content is text (`textarea`), and the caps ([`Limits`]).
//! - [`unescape`], [`unescape_attr`] and [`decode_entity`] decode character references;
//! [`escape`] and [`escape_dq`] write them.
//! - [`declared_charset`] finds the character set a page names in its first bytes.
//!
//! There is no tree builder, no CSS and no rendering here: what the tags mean is the caller's.
//!
//! Input is hostile. Nothing here panics on any string, and the work is linear in the input.
//!
//! ```
//! use craft_html::{Lexer, Options, Token, unescape};
//!
//! let opts = Options { drop: &["script"], ..Options::default() };
//! let mut text = String::new();
//! let mut links = Vec::new();
//! for token in Lexer::with(r#"Tom & Jerry"#, opts) {
//! match token {
//! Token::Text(t) => text.push_str(&unescape(t)),
//! Token::Start(tag) if tag.name == "a" => links.extend(tag.attr("href")),
//! _ => {}
//! }
//! }
//! assert_eq!((text.as_str(), links.as_slice()), ("Tom & Jerry", &["?a=1&b=2".to_string()][..]));
//! ```
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
#[cfg(feature = "flow")]
pub mod doc;
mod entity;
mod lex;
pub use entity::{decode_entity, escape, escape_dq, reference, unescape, unescape_attr};
pub use lex::{Lexer, Limits, Options, Tag, Token};
/// How many bytes of a page [`declared_charset`] looks at.
pub const CHARSET_SNIFF_BYTES: usize = 2048;
/// The character set a page names in its first [`CHARSET_SNIFF_BYTES`] bytes: a
/// ``, a `content="text/html; charset=…"`, or an XML declaration's `encoding=…`.
/// Lower case, as written otherwise: the caller maps the label to a decoder. `None` when the
/// page names none. A byte order mark is the caller's to check first.
pub fn declared_charset(bytes: &[u8]) -> Option {
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(CHARSET_SNIFF_BYTES)).unwrap_or(bytes)).to_ascii_lowercase();
let (i, n) = head.find("charset").map(|i| (i, 7)).or_else(|| head.find("encoding").map(|i| (i, 8)))?;
let after = head.get(i + n..)?.trim_start().strip_prefix('=')?;
let trimmed = after.trim_start().trim_start_matches(['"', '\'']);
let end = trimmed.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ';' | '>' | '/')).unwrap_or(trimmed.len());
let label = trimmed.get(..end)?.trim();
(!label.is_empty()).then(|| label.to_string())
}
#[cfg(test)]
mod tests {
use super::declared_charset;
#[test]
fn the_declared_charset_is_found_in_each_form() {
assert_eq!(declared_charset(b"").as_deref(), Some("utf-8"));
assert_eq!(declared_charset(b"").as_deref(), Some("windows-1251"));
assert_eq!(declared_charset(b"").as_deref(), Some("shift_jis"));
assert_eq!(declared_charset(b"").as_deref(), Some("koi8-r"));
for none in [&b"no charset here
"[..], b"charset", b"charset=", b"charset=''", b"", b"\xff\xfe\x00"] {
assert_eq!(declared_charset(none), None);
}
// Only the head is looked at.
let mut late = vec![b' '; super::CHARSET_SNIFF_BYTES];
late.extend_from_slice(b"");
assert_eq!(declared_charset(&late), None);
}
}