use lazy_static::lazy_static; use lol_html::html_content::Element; use lol_html::{element, text, HtmlRewriter, Settings}; use regex::Regex; use std::cell::RefCell; use std::collections::BTreeMap; use std::default::Default; use std::rc::Rc; use crate::SearchOptions; use super::normalize_content; lazy_static! { static ref ALL_SPACES: Regex = Regex::new("\\s").unwrap(); static ref SENTENCE_CHARS: Regex = Regex::new("[\\w'\"\\)\\$\\*]").unwrap(); } lazy_static! { static ref ATTRIBUTE_MATCH: Regex = Regex::new("^\\s*(?P[^:\\[\\]]+)\\[(?P.+)\\]\\s*$").unwrap(); } const SENTENCE_SELECTORS: &[&'static str] = &[ "h1", "h2", "h3", "h4", "h5", "h6", "p", "td", "div", "ul", "li", "article", "section", ]; const INLINE_SELECTORS: &[&'static str] = &[ "a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code", "dfn", "em", "i", "img", "input", "kbd", "label", "map", "object", "output", "q", "samp", "script", "select", "small", "span", "strong", "sub", "sup", "textarea", "time", "tt", "var", ]; const REMOVE_SELECTORS: &[&'static str] = &[ "head", "style", "script", "noscript", "label", "form", "svg", "footer", "nav", "iframe", "template", ]; const SPACE_SELECTORS: &[&'static str] = &["br"]; // We aren't transforming HTML, just parsing, so we dump the output. #[derive(Default)] struct EmptySink; impl lol_html::OutputSink for EmptySink { fn handle_chunk(&mut self, _: &[u8]) {} } /// Houses the HTML parsing instance and the internal data while parsing pub struct DomParser<'a> { rewriter: HtmlRewriter<'a, EmptySink>, data: Rc>, } // The internal state while parsing, // with a reference to the deepest HTML element // that we're currently reading #[derive(Default, Debug)] struct DomParserData { current_node: Rc>, filters: BTreeMap>, sort: BTreeMap, meta: BTreeMap, default_meta: BTreeMap, anchor_content: BTreeMap, anchor_titles: BTreeMap, language: Option, has_html_element: bool, has_old_bundle_reference: bool, has_default_ui_reference: bool, // Set when an --exclude-selectors match fires, and taken by the handler that // builds the node for that same element. exclude_element: bool, } #[derive(Clone, Copy, Debug, PartialEq)] enum NodeStatus { Indexing, // Our content & children should not be indexed Ignored, // Our content & children should be excluded entirely // (including meta / filters) Excluded, Body, // There was a body element below us, // so our content should be ignored. ParentOfBody, } impl Default for NodeStatus { fn default() -> Self { Self::Indexing } } // A single HTML element that we're reading into. // Contains a reference to the parent element, // and since we collapse this tree upwards while we parse, // we don't need to store tree structure. #[derive(Default, Debug)] struct DomParsingNode { current_value: String, parent: Option>>, filter: Option>, sort: Option>, meta: Option>, default_meta: Option>, weight: Option, anchor_ids: Option>, status: NodeStatus, } /// The search-relevant data that was retrieved from the given input #[derive(Debug)] pub struct DomParserResult { pub digest: String, pub filters: BTreeMap>, pub sort: BTreeMap, pub meta: BTreeMap, pub anchor_content: BTreeMap, pub anchor_titles: BTreeMap, pub has_custom_body: bool, pub force_inclusion: bool, // Include this page even if there is no body pub has_html_element: bool, pub has_old_bundle_reference: bool, pub has_default_ui_reference: bool, pub language: String, } // Some shorthand to clean up our use of Rc> in the lol_html macros // From https://github.com/rust-lang/rfcs/issues/2407#issuecomment-385291238 macro_rules! enclose { ( ($( $x:ident ),*) $y:expr ) => { { $(let $x = $x.clone();)* $y } }; } impl<'a> DomParser<'a> { pub fn new(options: &'a SearchOptions) -> Self { let data = Rc::new(RefCell::new(DomParserData::default())); let root = format!("{}, {} *", options.root_selector, options.root_selector); let scope_to_root = |selectors: &mut dyn Iterator| { selectors .map(|e| format!("{} {}", options.root_selector, e)) .collect::>() .join(", ") }; let user_exclusions = scope_to_root(&mut options.exclude_selectors.iter().map(String::as_str)); let auto_exclusions = scope_to_root(&mut REMOVE_SELECTORS.iter().copied()); let mut anchor_counter = 0; // Registered ahead of the handler below so that the element's own status // is known before any of its metadata is read. let user_exclusion_handler = (!user_exclusions.is_empty()).then(|| { enclose! { (data) element!(user_exclusions, move |_el| { data.borrow_mut().exclude_element = true; Ok(()) })} }); let rewriter = HtmlRewriter::new( user_exclusion_handler.into_iter().chain(vec![ enclose! { (data) element!("html", move |el| { let mut data = data.borrow_mut(); data.has_html_element = true; if let Some(lang) = el.get_attribute("lang") { data.language = Some(lang.to_lowercase()); } Ok(()) })}, enclose! { (data) element!(root, move |el| { let excluded_by_selector = std::mem::take(&mut data.borrow_mut().exclude_element); let explicit_ignore_flag = el.get_attribute("data-pagefind-ignore").map(|attr| { match attr.to_ascii_lowercase().as_str() { "" | "index" | "true" => NodeStatus::Ignored, "all" => NodeStatus::Excluded, _ => { options.logger.warn(format!("data-pagefind-ignore value of \"{}\" is not valid. Expected no value, or one of: [index, all]. Assuming 'all' and excluding this element entirely.", attr)); NodeStatus::Excluded } } }); let treat_as_body = el.has_attribute("data-pagefind-body"); let weight = el.get_attribute("data-pagefind-weight").map(|attr| attr.to_string()); let filter = el.get_attribute("data-pagefind-filter").map(|attr| parse_attr_string(attr, el)); let element_id = el.get_attribute("id").map(|e| ALL_SPACES.replace_all(&e, "").to_string()); let pagefind_title = el.get_attribute("data-pagefind-title").map(|title| normalize_content(&title)).filter(|title| !title.is_empty()); let meta = el.get_attribute("data-pagefind-meta").map(|attr| parse_attr_string(attr, el)); let default_meta = el.get_attribute("data-pagefind-default-meta").map(|attr| parse_attr_string(attr, el)); let sort = el.get_attribute("data-pagefind-sort").map(|attr| parse_attr_string(attr, el)); let index_attrs: Option> = el.get_attribute("data-pagefind-index-attrs").map(|attr| attr.split(',').map(|a| a.trim().to_string()).collect()); let tag_name = el.tag_name(); let status = if excluded_by_selector { NodeStatus::Excluded } else if treat_as_body { NodeStatus::Body } else if let Some(explicit_ignore_flag) = explicit_ignore_flag { explicit_ignore_flag } else { NodeStatus::Indexing }; let mut anchor_id = None; if status != NodeStatus::Excluded && status != NodeStatus::Ignored { if let Some(element_id) = element_id { let parent = &data.borrow().current_node; let mut parent = parent.borrow_mut(); // Don't insert anchors if this node is outside of a body-tree if !(parent.status == NodeStatus::ParentOfBody && status != NodeStatus::Body && status != NodeStatus::ParentOfBody) { parent.current_value.push_str(&format!(" ___PAGEFIND_ANCHOR___{tag_name}:{anchor_counter}:{element_id} ")); anchor_id = Some(format!("{anchor_counter}:{element_id}")); anchor_counter += 1; } } } if let (Some(anchor_id), Some(title)) = (&anchor_id, pagefind_title) { data.borrow_mut().anchor_titles.insert(anchor_id.clone(), title); } if status != NodeStatus::Excluded { if let Some(attrs) = index_attrs { let parent = &data.borrow().current_node; for attr in attrs { let mut value = el.get_attribute(attr.trim()).unwrap_or_default(); if value.chars() .last() .filter(|c| SENTENCE_CHARS.is_match(&c.to_string())) .is_some() { value.push('.'); } let mut parent = parent.borrow_mut(); parent.current_value.push(' '); parent.current_value.push_str(&value); parent.current_value.push(' '); } } // Handle adding spaces between words separated by
tags and the like if SPACE_SELECTORS.contains(&el.tag_name().as_str()) { let parent = &data.borrow().current_node; let mut parent = parent.borrow_mut(); parent.current_value.push(' '); } } let node = { let mut data = data.borrow_mut(); let parent_node = data.current_node.borrow(); let parent_status = parent_node.status; let mut node_anchors = if parent_node.anchor_ids.is_some() && INLINE_SELECTORS.contains(&tag_name.as_str()) { parent_node.anchor_ids.clone() } else { None }; if let Some(this_node_anchor_id) = anchor_id { if let Some(existing) = node_anchors.as_mut() { existing.push(this_node_anchor_id); } else { node_anchors = Some(vec![this_node_anchor_id]); } } let node = Rc::new(RefCell::new(DomParsingNode{ parent: Some(Rc::clone(&data.current_node)), status: match parent_status { NodeStatus::Excluded => parent_status, _ => status, }, filter, meta, default_meta, sort, anchor_ids: node_anchors, current_value: String::default(), weight, })); drop(parent_node); data.current_node = Rc::clone(&node); node }; let can_have_content = if let Some(handlers) = el.end_tag_handlers() { handlers.push(Box::new(enclose! { (data, node, tag_name) move |end| { let mut data = data.borrow_mut(); let mut node = node.borrow_mut(); // When we reach an end tag, we need to // make sure to move focus back to the parent node. if let Some(parent) = &node.parent { data.current_node = Rc::clone(parent); } // For fully-excluded elements, we want to bail before we // even get to filters or metadata. if node.status == NodeStatus::Excluded { return Ok(()); } // Process filters & meta before we continue // (Filters & meta are valid on ignored elements) if let Some(filters) = &node.filter { for filter in filters { if let Some((filter, value)) = node.get_attribute_pair(filter) { match data.filters.get_mut(&filter) { Some(filter_arr) => filter_arr.push(normalize_content(&value)), None => { data.filters.insert(filter, vec![ normalize_content(&value) ]); } } } } } if let Some(sorts) = &node.sort { for sort in sorts { if let Some((sort, value)) = node.get_attribute_pair(sort) { data.sort.insert(sort, value); } } } if let Some(metas) = &node.meta { for meta in metas { if let Some((meta, value)) = node.get_attribute_pair(meta) { data.meta.insert(meta, value); } } } if let Some(metas) = &node.default_meta { for meta in metas { if let Some((meta, value)) = node.get_attribute_pair(meta) { data.default_meta.insert(meta, value); } } } // Try to capture the first title on the page (if unset) if tag_name == "h1" && !data.meta.contains_key("auto_title") && !node.current_value.trim().is_empty() { data.meta.insert("auto_title".into(), normalize_content(&node.current_value)); } // Try to capture the actual title of the page as a fallback for later if tag_name == "title" && !data.meta.contains_key("auto_page_title") { data.meta.insert("auto_page_title".into(), normalize_content(&node.current_value)); } // If we bail out now, the content won't be persisted anywhere // and the node + children will be dropped. if node.status == NodeStatus::Ignored { return Ok(()); } let tag_name = end.name(); if SENTENCE_SELECTORS.contains(&tag_name.as_str()) { // For block elements, we want to make sure sentences // don't hug each other without whitespace. // We normalize repeated whitespace later, so we // can add this indiscriminately. node.current_value.insert(0, ' '); // Similarly, we want to separate block elements // with punctuation, so that the excerpts read nicely. // (As long as it doesn't already end with, say, a . or ?) if node.current_value.chars() .last() .filter(|c| SENTENCE_CHARS.is_match(&c.to_string())) .is_some() { node.current_value.push('.'); } node.current_value.push(' '); } if let Some(weight) = &node.weight { node.current_value = [ " ___PAGEFIND_WEIGHT___", &weight, " ", &node.current_value, " ___END_PAGEFIND_WEIGHT___ " ].concat(); } else { if let Some(auto_weight) = match &tag_name[..] { "h1" => Some("7".to_string()), "h2" => Some("6".to_string()), "h3" => Some("5".to_string()), "h4" => Some("4".to_string()), "h5" => Some("3".to_string()), "h6" => Some("2".to_string()), _ => None, } { node.current_value = [ " ___PAGEFIND_AUTO_WEIGHT___", &auto_weight, " ", &node.current_value, " ___END_PAGEFIND_WEIGHT___ " ].concat(); } } // Huck all of the content we have onto the end of the // content that the parent node has (so far) // This will include all of our children's content, // and the order of tree traversal will mean that it // is inserted in the correct position in the parent's content. let mut parent = data.current_node.borrow_mut(); // If the parent is a parent of a body, we don't want to append // any more content to it. (Unless, of course, we are representing another body) if parent.status == NodeStatus::ParentOfBody && node.status != NodeStatus::Body && node.status != NodeStatus::ParentOfBody { return Ok(()); } match node.status { NodeStatus::Ignored | NodeStatus::Excluded => {}, NodeStatus::Indexing => { parent.current_value.push_str(&node.current_value); }, NodeStatus::Body | NodeStatus::ParentOfBody => { // If our parent is already a parent of a body, then // we're probably a subsequent body. Avoid clearing it out. if parent.status != NodeStatus::ParentOfBody { parent.current_value.clear(); } parent.current_value.push_str(&node.current_value); parent.status = NodeStatus::ParentOfBody; } }; Ok(()) }})); true } else { false }; // Try to handle tags like which have no end tag, // and thus will never hit the logic to reset the current node. // TODO: This could still be missed for tags with implied ends? if !can_have_content { let mut data = data.borrow_mut(); let node = node.borrow(); if let Some(parent) = &node.parent { data.current_node = Rc::clone(parent); } // For fully-excluded elements, we want to bail before we // even get to filters or metadata. if node.status == NodeStatus::Excluded { return Ok(()); } // Process filters & meta before we continue // TODO: Abstract repetition into function if let Some(filters) = &node.filter { for filter in filters { if let Some((filter, value)) = node.get_attribute_pair(filter) { match data.filters.get_mut(&filter) { Some(filter_arr) => filter_arr.push(normalize_content(&value)), None => { data.filters.insert(filter, vec![ normalize_content(&value) ]); } } } } } if let Some(sorts) = &node.sort { for sort in sorts { if let Some((sort, value)) = node.get_attribute_pair(sort) { data.sort.insert(sort, value); } } } if let Some(metas) = &node.meta { for meta in metas { if let Some((meta, value)) = node.get_attribute_pair(meta) { data.meta.insert(meta, value); } } } if let Some(metas) = &node.default_meta { for meta in metas { if let Some((meta, value)) = node.get_attribute_pair(meta) { data.default_meta.insert(meta, value); } } } // Try to capture the first image _after_ a title (if unset) if tag_name == "img" && !data.meta.contains_key("auto_image") && (data.meta.contains_key("auto_title") || data.meta.contains_key("title")) { if let Some(src) = el.get_attribute("src") { data.meta.insert("auto_image".into(), src); if let Some(alt) = el.get_attribute("alt") { data.meta.insert("auto_image_alt".into(), alt); } } } } Ok(()) })}, // Elements we always skip stay "ignored" rather than "excluded", // so that metadata tagged inside a or a