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