1use quarb::{AstAdapter, NodeId, Value};
23use scraper::{ElementRef, Html};
24use std::collections::HashMap;
25
26struct Node {
27 tag: Option<String>,
29 attrs: Vec<(String, String)>,
30 pieces: Vec<Piece>,
36 text: std::cell::OnceCell<String>,
37 parent: Option<NodeId>,
38 children: Vec<NodeId>,
39}
40
41enum Piece {
43 Text(String),
44 Child(usize),
45}
46
47impl Node {
48 fn attr(&self, name: &str) -> Option<&str> {
49 self.attrs
50 .iter()
51 .find(|(k, _)| k == name)
52 .map(|(_, v)| v.as_str())
53 }
54}
55
56pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
66 if let Some(label) = xml_prologue_encoding(bytes) {
67 return Some(label);
68 }
69 let head = &bytes[..bytes.len().min(1024)];
70 let lower: Vec<u8> = head.iter().map(|b| b.to_ascii_lowercase()).collect();
71 let mut at = 0;
72 while let Some(i) = find(&lower[at..], b"<meta") {
73 let start = at + i + 5;
74 let end = start + find(&lower[start..], b">").unwrap_or(lower.len() - start);
75 let attrs = meta_attributes(&lower[start..end]);
76 let get = |name: &str| {
77 attrs
78 .iter()
79 .find(|(k, _)| k == name)
80 .map(|(_, v)| v.as_str())
81 };
82 if let Some(label) = get("charset").filter(|l| !l.is_empty()) {
83 return Some(label.to_string());
84 }
85 if get("http-equiv") == Some("content-type")
86 && let Some(content) = get("content")
87 && let Some(p) = content.find("charset=")
88 {
89 let label = content[p + 8..]
90 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_ascii_whitespace())
91 .split([';', ' ', '"', '\''])
92 .next()
93 .unwrap_or("");
94 if !label.is_empty() {
95 return Some(label.to_string());
96 }
97 }
98 at = end;
99 }
100 None
101}
102
103fn xml_prologue_encoding(bytes: &[u8]) -> Option<String> {
108 let head = bytes.strip_prefix(b"<?xml")?;
109 if !head.first().is_some_and(|b| b.is_ascii_whitespace()) {
110 return None;
111 }
112 let end = find(head, b"?>")?;
113 let prologue = std::str::from_utf8(&head[..end]).ok()?;
114 let rest = prologue
115 .split_once("encoding")?
116 .1
117 .trim_start()
118 .strip_prefix('=')?
119 .trim_start();
120 let quote = rest.chars().next()?;
121 (quote == '"' || quote == '\'')
122 .then(|| {
123 rest[1..]
124 .split(quote)
125 .next()
126 .unwrap_or("")
127 .trim()
128 .to_string()
129 })
130 .filter(|l| !l.is_empty())
131}
132
133fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
134 hay.windows(needle.len()).position(|w| w == needle)
135}
136
137fn meta_attributes(tag: &[u8]) -> Vec<(String, String)> {
142 let mut out = Vec::new();
143 let mut i = 0;
144 let n = tag.len();
145 while i < n {
146 while i < n && (tag[i].is_ascii_whitespace() || tag[i] == b'/') {
147 i += 1;
148 }
149 let name_start = i;
150 while i < n && !tag[i].is_ascii_whitespace() && tag[i] != b'=' && tag[i] != b'/' {
151 i += 1;
152 }
153 if i == name_start {
154 break;
155 }
156 let name = String::from_utf8_lossy(&tag[name_start..i]).into_owned();
157 while i < n && tag[i].is_ascii_whitespace() {
158 i += 1;
159 }
160 let mut value = String::new();
161 if i < n && tag[i] == b'=' {
162 i += 1;
163 while i < n && tag[i].is_ascii_whitespace() {
164 i += 1;
165 }
166 if i < n && (tag[i] == b'"' || tag[i] == b'\'') {
167 let q = tag[i];
168 i += 1;
169 let v = i;
170 while i < n && tag[i] != q {
171 i += 1;
172 }
173 value = String::from_utf8_lossy(&tag[v..i]).trim().to_string();
174 i = (i + 1).min(n);
175 } else {
176 let v = i;
177 while i < n && !tag[i].is_ascii_whitespace() {
178 i += 1;
179 }
180 value = String::from_utf8_lossy(&tag[v..i]).to_string();
181 }
182 }
183 out.push((name, value));
184 }
185 out
186}
187
188pub struct HtmlAdapter {
190 nodes: Vec<Node>,
191 ids: HashMap<String, NodeId>,
193 root: NodeId,
194 document_url: Option<url::Url>,
199}
200
201impl HtmlAdapter {
202 fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
209 match hint {
210 None | Some("*") => true,
211 Some(h) => self.nodes[node.0 as usize]
212 .attr("rel")
213 .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
214 }
215 }
216
217 pub fn set_document_url(&mut self, url: &str) {
220 self.document_url = url::Url::parse(url).ok();
221 }
222
223 fn text_of(&self, node: NodeId) -> &str {
228 self.nodes[node.0 as usize].text.get_or_init(|| {
229 let mut out = String::new();
230 let mut stack: Vec<(usize, usize, usize)> = vec![(node.0 as usize, 0, 0)];
235 while let Some(top) = stack.last_mut() {
236 let (cur, at, start) = *top;
237 let n = &self.nodes[cur];
238 if at >= n.pieces.len() {
239 if cur != node.0 as usize {
240 let _ = n.text.set(out[start..].to_string());
241 }
242 stack.pop();
243 continue;
244 }
245 top.1 += 1;
246 match &n.pieces[at] {
247 Piece::Text(t) => out.push_str(t),
248 Piece::Child(k) => {
249 let c = n.children[*k].0 as usize;
250 match self.nodes[c].text.get() {
251 Some(t) => out.push_str(t),
252 None => stack.push((c, 0, out.len())),
253 }
254 }
255 }
256 }
257 out
258 })
259 }
260
261 pub fn parse(html: &str) -> Self {
263 let document = Html::parse_document(html);
264 let mut nodes = vec![Node {
265 tag: None,
266 attrs: Vec::new(),
267 pieces: vec![Piece::Child(0)],
268 text: std::cell::OnceCell::new(),
269 parent: None,
270 children: Vec::new(),
271 }];
272 let mut ids = HashMap::new();
273 let root = NodeId(0);
274
275 let html_node = build(document.root_element(), Some(root), &mut nodes, &mut ids);
276 nodes[0].children = vec![html_node];
277
278 HtmlAdapter {
279 nodes,
280 ids,
281 root,
282 document_url: None,
283 }
284 }
285
286 pub fn locator(&self, node: NodeId) -> String {
290 let mut segments = Vec::new();
291 let mut cur = Some(node);
292 while let Some(id) = cur {
293 let n = &self.nodes[id.0 as usize];
294 if let Some(tag) = &n.tag {
295 segments.push(self.segment(id, tag));
296 }
297 cur = n.parent;
298 }
299 segments.reverse();
300 format!("/{}", segments.join("/"))
301 }
302
303 fn segment(&self, node: NodeId, tag: &str) -> String {
304 let Some(parent) = self.nodes[node.0 as usize].parent else {
305 return tag.to_string();
306 };
307 let siblings = &self.nodes[parent.0 as usize].children;
308 let same_tag: Vec<NodeId> = siblings
309 .iter()
310 .copied()
311 .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
312 .collect();
313 if same_tag.len() > 1 {
314 let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
315 format!("{tag}[{n}]")
316 } else {
317 tag.to_string()
318 }
319 }
320}
321
322fn build(
333 el: ElementRef,
334 parent: Option<NodeId>,
335 nodes: &mut Vec<Node>,
336 ids: &mut HashMap<String, NodeId>,
337) -> NodeId {
338 let root_this = NodeId(nodes.len() as u64);
339 let mut stack = vec![(el, parent)];
341
342 while let Some((el, parent)) = stack.pop() {
343 let this = NodeId(nodes.len() as u64);
344
345 let tag = el.value().name().to_string();
346 let attrs: Vec<(String, String)> = el
347 .value()
348 .attrs()
349 .map(|(k, v)| (k.to_string(), v.to_string()))
350 .collect();
351 let mut pieces = Vec::new();
354 let mut ordinal = 0;
355 for child in el.children() {
356 if let Some(t) = child.value().as_text() {
357 pieces.push(Piece::Text(t.to_string()));
358 } else if ElementRef::wrap(child).is_some() {
359 pieces.push(Piece::Child(ordinal));
360 ordinal += 1;
361 }
362 }
363
364 if let Some(id) = el.value().id() {
365 ids.entry(id.to_string()).or_insert(this);
366 }
367
368 nodes.push(Node {
369 tag: Some(tag),
370 attrs,
371 pieces,
372 text: std::cell::OnceCell::new(),
373 parent,
374 children: Vec::new(),
375 });
376
377 if let Some(p) = parent {
381 nodes[p.0 as usize].children.push(this);
382 }
383
384 let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
387 for child in children.into_iter().rev() {
388 stack.push((child, Some(this)));
389 }
390 }
391
392 root_this
393}
394
395const BLOCK: &[&str] = &[
397 "address",
398 "article",
399 "aside",
400 "blockquote",
401 "body",
402 "details",
403 "dialog",
404 "dd",
405 "div",
406 "dl",
407 "dt",
408 "fieldset",
409 "figcaption",
410 "figure",
411 "footer",
412 "form",
413 "h1",
414 "h2",
415 "h3",
416 "h4",
417 "h5",
418 "h6",
419 "header",
420 "hgroup",
421 "hr",
422 "html",
423 "li",
424 "main",
425 "nav",
426 "ol",
427 "p",
428 "pre",
429 "section",
430 "table",
431 "ul",
432];
433
434fn is_heading(tag: &str) -> bool {
435 matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
436}
437
438impl AstAdapter for HtmlAdapter {
439 fn root(&self) -> NodeId {
440 self.root
441 }
442
443 fn walks_for_references(&self) -> bool {
446 true
447 }
448
449 fn children(&self, node: NodeId) -> Vec<NodeId> {
450 self.nodes[node.0 as usize].children.clone()
451 }
452
453 fn name(&self, node: NodeId) -> Option<String> {
454 self.nodes[node.0 as usize].tag.clone()
455 }
456
457 fn parent(&self, node: NodeId) -> Option<NodeId> {
458 self.nodes[node.0 as usize].parent
459 }
460
461 fn traits(&self, node: NodeId) -> Vec<String> {
464 let n = &self.nodes[node.0 as usize];
465 let Some(tag) = &n.tag else {
466 return Vec::new();
467 };
468 let mut out = Vec::new();
469 if BLOCK.contains(&tag.as_str()) {
470 out.push("block".to_string());
471 } else {
472 out.push("inline".to_string());
473 }
474 if is_heading(tag) {
475 out.push("heading".to_string());
476 }
477 if tag == "a" && n.attr("href").is_some() {
478 out.push("link".to_string());
479 }
480 out
481 }
482
483 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
486 self.nodes[node.0 as usize]
487 .attr(name)
488 .map(|v| Value::Str(v.to_string()))
489 }
490
491 fn default_value(&self, node: NodeId) -> Option<Value> {
493 Some(Value::Str(self.text_of(node).to_string()))
494 }
495
496 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
504 let n = &self.nodes[node.0 as usize];
505 match key {
506 "tag" => n.tag.clone().map(Value::Str),
507 "classes" => n.attr("class").map(|c| {
508 Value::list(
509 c.split_whitespace()
510 .map(|s| Value::Str(s.to_string()))
511 .collect(),
512 )
513 }),
514 "attrs" => {
515 let mut names: Vec<&str> = n.attrs.iter().map(|(k, _)| k.as_str()).collect();
516 names.sort_unstable();
517 Some(Value::list(
518 names
519 .into_iter()
520 .map(|k| Value::Str(k.to_string()))
521 .collect(),
522 ))
523 }
524 "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
525 _ => None,
526 }
527 }
528
529 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
532 if property == "href" && !self.rel_ok(node, hint) {
533 return None;
534 }
535 let value = self.nodes[node.0 as usize].attr(property)?;
536 let fragment = value.strip_prefix('#')?;
537 self.ids.get(fragment).copied()
538 }
539
540 fn external_ref(
550 &self,
551 node: NodeId,
552 property: &str,
553 hint: Option<&str>,
554 ) -> Option<quarb::ExternalRef> {
555 if property == "href" && !self.rel_ok(node, hint) {
556 return None;
557 }
558 let value = self.nodes[node.0 as usize].attr(property)?;
559 let t = value.trim();
560 if t.is_empty() || t.starts_with('#') {
561 return None;
562 }
563 let u = match url::Url::parse(t) {
564 Ok(u) => u,
565 Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
570 Some(base) => base.join(t).ok()?,
571 None => return Some(quarb::ExternalRef::uri(t)),
572 },
573 Err(_) => return None,
574 };
575 if u.scheme() != "http" && u.scheme() != "https" {
576 return None;
577 }
578 Some(quarb::ExternalRef::uri(u.as_str()))
579 }
580
581 fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
584 self.ids.get(fragment).copied()
585 }
586
587 fn property_names(&self, node: NodeId) -> Vec<String> {
591 self.nodes[node.0 as usize]
592 .attrs
593 .iter()
594 .map(|(k, _)| k.clone())
595 .collect()
596 }
597
598 fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
601 Some(true)
602 }
603
604 fn ref_property(&self, node: NodeId) -> Option<String> {
605 let n = &self.nodes[node.0 as usize];
606 let (prop, tags): (&str, &[&str]) = match n.tag.as_deref() {
607 Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
608 Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
609 _ => return None,
610 };
611 let _ = tags;
612 n.attr(prop).map(|_| prop.to_string())
613 }
614
615 fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
620 if property != "href" {
621 return None;
622 }
623 let rel = self.nodes[node.0 as usize].attr("rel")?;
624 let rel = rel.trim();
625 (!rel.is_empty()).then(|| rel.to_string())
626 }
627}
628
629#[cfg(test)]
630mod declared_tests {
631 use super::declared_encoding;
632
633 #[test]
634 fn meta_charset_forms() {
635 assert_eq!(
636 declared_encoding(
637 b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
638 ),
639 Some("windows-1251".into())
640 );
641 assert_eq!(
642 declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
643 Some("koi8-r".into())
644 );
645 assert_eq!(
646 declared_encoding(b"<html><head><meta charset=utf-8></head>"),
647 Some("utf-8".into())
648 );
649 assert_eq!(
650 declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
651 None
652 );
653 assert_eq!(
654 declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
655 Some("ISO-8859-5".into())
656 );
657 assert_eq!(
659 declared_encoding(
660 b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
661 ),
662 None
663 );
664 assert_eq!(
666 declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
667 None
668 );
669 let mut late = vec![b' '; 1100];
670 late.extend_from_slice(b"<meta charset=\"koi8-r\">");
671 assert_eq!(declared_encoding(&late), None);
672 }
673}