metadata_gen/metatags/html.rs
1// SPDX-FileCopyrightText: 2024 - 2026 metadata-gen contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `<meta>` extraction from HTML (feature `html`).
5
6use super::MetaTag;
7use crate::error::MetadataError;
8use alloc::format;
9use alloc::string::String;
10use alloc::vec::Vec;
11
12/// Extracts `<meta>` tags from HTML content in one streaming pass
13/// (feature `html`).
14///
15/// Both `<meta …>` and `<meta … />` are handled; attribute names are
16/// matched case-insensitively; `name`, `property` and `http-equiv`
17/// identify a tag, in that order of preference; entity references in
18/// values are decoded. Malformed markup ends the scan and what was found
19/// so far is returned, because a whole HTML page rarely satisfies a
20/// strict reader end to end. Use [`extract_meta_tags_lenient`] to learn
21/// where the scan stopped.
22///
23/// # Errors
24///
25/// None today; the signature keeps `Result` so a strict mode can report
26/// without a breaking change.
27///
28/// # Implementation note
29///
30/// Scans `<…>` tags in one forward pass and parses each `<meta>` tag
31/// with `oxml` (attribute parsing and XML/HTML character and entity
32/// reference decoding without full-document DOM allocation). This
33/// replaces the previous `scraper` / `html5ever` and `quick-xml`
34/// dependencies. See issue #22.
35///
36/// # Example
37///
38/// ```
39/// use metadata_gen::metatags::extract_meta_tags;
40///
41/// let html = r#"<head><meta name="description" content="A & B"><meta property="og:title" content="T"></head>"#;
42/// let tags = extract_meta_tags(html).unwrap();
43/// assert_eq!(tags[0].content, "A & B");
44/// assert_eq!(tags[1].name, "og:title");
45/// ```
46pub fn extract_meta_tags(
47 html_content: &str,
48) -> Result<Vec<MetaTag>, MetadataError> {
49 Ok(extract_meta_tags_lenient(html_content).0)
50}
51
52/// Like [`extract_meta_tags`], but also reports where a malformed
53/// document stopped the scan (feature `html`).
54///
55/// The second value is `None` when the whole document was read, and
56/// otherwise the scanner's error with the byte offset it reached, so a
57/// caller can tell "no tags" from "gave up at byte 300".
58///
59/// # Example
60///
61/// ```
62/// use metadata_gen::metatags::extract_meta_tags_lenient;
63///
64/// let (tags, stopped) = extract_meta_tags_lenient(r#"<meta name="a" content="1"><p <broken"#);
65/// assert_eq!(tags.len(), 1);
66/// assert!(stopped.is_some());
67/// ```
68pub fn extract_meta_tags_lenient(
69 html_content: &str,
70) -> (Vec<MetaTag>, Option<MetadataError>) {
71 let bytes = html_content.as_bytes();
72 let mut pos = 0;
73 let mut meta_tags = Vec::new();
74 let stopped = loop {
75 let Some(rel) = memchr::memchr(b'<', &bytes[pos..]) else {
76 break None;
77 };
78 let start = pos + rel;
79 match scan_tag_end(bytes, start) {
80 Ok(end) => {
81 if let Some(attrs) =
82 meta_body(&html_content[start..end])
83 {
84 if let Some(tag) = collect_meta_tag(attrs) {
85 meta_tags.push(tag);
86 }
87 }
88 pos = end;
89 }
90 Err((offset, reason)) => {
91 break Some(MetadataError::ExtractionError {
92 message: format!(
93 "HTML scan stopped at byte {offset}: {reason}"
94 ),
95 });
96 }
97 }
98 };
99 (meta_tags, stopped)
100}
101
102/// Finds the byte index one past the closing `>` of the tag starting at
103/// `start`, respecting single- and double-quoted attribute values.
104fn scan_tag_end(
105 bytes: &[u8],
106 start: usize,
107) -> Result<usize, (usize, &'static str)> {
108 let mut quote: Option<u8> = None;
109 for (idx, &b) in bytes[start + 1..].iter().enumerate() {
110 if let Some(q) = quote {
111 if b == q {
112 quote = None;
113 }
114 } else if b == b'"' || b == b'\'' {
115 quote = Some(b);
116 } else if b == b'<' {
117 return Err((start + 1 + idx, "unexpected '<' inside tag"));
118 } else if b == b'>' {
119 return Ok(start + 1 + idx + 1);
120 }
121 }
122 Err((start, "unclosed tag or attribute"))
123}
124
125/// Returns the trimmed attribute slice of a `<meta …>` or `<meta … />`
126/// tag, or `None` if `tag_slice` is any other tag.
127fn meta_body(tag_slice: &str) -> Option<&str> {
128 let inner = tag_slice[1..tag_slice.len() - 1].trim();
129 let head = inner.get(..4)?;
130 let rest = inner.get(4..)?;
131 if !head.eq_ignore_ascii_case("meta")
132 || !rest.starts_with(|c: char| c.is_ascii_whitespace())
133 {
134 return None;
135 }
136 Some(rest.trim_end_matches('/').trim())
137}
138
139/// Pulls a `MetaTag` out of a `<meta>` attribute slice if it carries
140/// both an identifying attribute (`name`, then `property`, then
141/// `http-equiv`) and a `content` value.
142fn collect_meta_tag(attrs_src: &str) -> Option<MetaTag> {
143 let xml = format!("<meta {attrs_src}/>");
144 let doc = oxml::parse(&xml).ok()?;
145 let root = doc.root_element()?;
146 let mut slots: [Option<String>; 4] = [None, None, None, None];
147
148 for attr in doc.attributes(root) {
149 if let Some(name) = doc.name(attr.name) {
150 record_meta_attr(&name.local, attr.value, &mut slots);
151 }
152 }
153
154 let [name, property, http_equiv, content] = slots;
155 let id = name.or(property).or(http_equiv)?;
156 Some(MetaTag::new(id, content?))
157}
158
159/// Stores a recognized `<meta>` attribute (`name`, `property`,
160/// `http-equiv`, or `content`) into the corresponding slot.
161fn record_meta_attr(
162 key: &str,
163 value: &str,
164 slots: &mut [Option<String>; 4],
165) {
166 let idx = if key.eq_ignore_ascii_case("name") {
167 0
168 } else if key.eq_ignore_ascii_case("property") {
169 1
170 } else if key.eq_ignore_ascii_case("http-equiv") {
171 2
172 } else if key.eq_ignore_ascii_case("content") {
173 3
174 } else {
175 return;
176 };
177 slots[idx] = Some(String::from(value));
178}