fix: exclude-selectors matches are excluded entirely, not just ignored

--exclude-selectors marked a match as "ignored", the same as a bare
data-pagefind-ignore, so metadata and filters inside the match were still
collected. Excluding img.emoji, for example, still let the emoji become the
page's auto-detected image.

The status was also applied after the element had been processed. For a void
element such as <img> that is too late twice over: its metadata has already
been read, and the parser has already popped back to the parent, so the
status landed on the parent instead and wiped out the rest of the page.

Match the selectors in a handler registered ahead of the main element
handler, which now reads that flag and gives the node Excluded status up
front. The built-in REMOVE_SELECTORS (head, nav, footer, ...) keep the old
Ignored status, so metadata tagged inside them still counts.

Fixes #692
This commit is contained in:
dualfroz committed 2026-09-13 15:31:49 +02:00
1 parent 5205bba40d
commit a36c86c90c
2 files changed
+78 -17

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ exclude_selectors:
- "[id^='prefix-']"
```
All children will also be ignored, so using a `#my_navigation *` selector is not required — in other words, the semantics are the same as the [data-pagefind-ignore](/docs/indexing/#removing-individual-elements-from-the-index) attribute.
All children will also be excluded, so using a `#my_navigation *` selector is not required. In other words, the semantics are the same as the [data-pagefind-ignore="all"](/docs/indexing/#removing-individual-elements-from-the-index) attribute: matched elements are skipped for metadata and filters as well as for content.
Note that currently Pagefind only supports lists of options via configuration files. If using the `--exclude-selectors` CLI flag or the `PAGEFIND_EXCLUDE_SELECTORS` environment variable, only one selector may be supplied. The selector may be a comma-separated CSS selector though, so the above example can be passed as `--exclude-selectors "#my_navigation, blockquote > span, [id^='prefix-']"`.
+77 -16
View File
@@ -63,6 +63,9 @@ struct DomParserData {
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)]
@@ -134,17 +137,28 @@ 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 mut custom_exclusions = options.exclude_selectors.clone();
custom_exclusions.extend(REMOVE_SELECTORS.iter().map(|s| s.to_string()));
let custom_exclusions = custom_exclusions
.iter()
let scope_to_root = |selectors: &mut dyn Iterator<Item = &str>| {
selectors
.map(|e| format!("{} {}", options.root_selector, e))
.collect::<Vec<_>>()
.join(", ");
.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(
vec![
user_exclusion_handler.into_iter().chain(vec![
enclose! { (data) element!("html", move |el| {
let mut data = data.borrow_mut();
data.has_html_element = true;
@@ -154,6 +168,7 @@ impl<'a> DomParser<'a> {
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,
@@ -175,7 +190,9 @@ impl<'a> DomParser<'a> {
let index_attrs: Option<Vec<String>> = 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 treat_as_body {
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
@@ -495,8 +512,9 @@ impl<'a> DomParser<'a> {
}
Ok(())
})},
// If we hit a selector that should be excluded, mark whatever the current node is as such
enclose! { (data) element!(custom_exclusions, move |_el| {
// Elements we always skip stay "ignored" rather than "excluded",
// so that metadata tagged inside a <head> or a <nav> still counts.
enclose! { (data) element!(auto_exclusions, move |_el| {
let data = data.borrow_mut();
let mut node = data.current_node.borrow_mut();
node.status = NodeStatus::Ignored;
@@ -552,8 +570,7 @@ impl<'a> DomParser<'a> {
}
Ok(())
})},
]
.into_iter()
])
.fold(Settings::new().with_strict(false), |settings, handler| {
settings.append_element_content_handler(handler)
}),
@@ -711,13 +728,18 @@ mod tests {
}
fn test_raw_parse(input: Vec<&'static str>) -> DomParserResult {
test_raw_parse_with_args(vec![], input)
}
fn test_raw_parse_with_args(
extra_args: Vec<&'static str>,
input: Vec<&'static str>,
) -> DomParserResult {
use clap::CommandFactory;
let mut args = vec!["pagefind", "--source", "not_important"];
args.extend(extra_args);
let config_args = vec![twelf::Layer::Clap(
crate::PagefindInboundConfig::command().get_matches_from(vec![
"pagefind",
"--source",
"not_important",
]),
crate::PagefindInboundConfig::command().get_matches_from(args),
)];
let config =
SearchOptions::load(crate::PagefindInboundConfig::with_layers(&config_args).unwrap())
@@ -820,6 +842,45 @@ mod tests {
assert_eq!(data.digest, "Elements like: forms. As well as *crickets*.");
}
#[test]
fn excluded_selectors_are_excluded_entirely() {
let data = test_raw_parse_with_args(
vec!["--exclude-selectors", "img.emoji, .promo"],
vec![
"<html><body>",
"<h1>Kitty blog</h1>",
"<img class='emoji' alt='thumbs up' src='/emoji/+1.png'>",
"<div class='promo' data-pagefind-meta='deal:half price'></div>",
"<div class='promo' data-pagefind-filter='color:red'>Buy now</div>",
"<img alt='a kitty' src='/kitty.jpg'>",
"</body></html>",
],
);
assert_eq!(
data.digest,
"___PAGEFIND_AUTO_WEIGHT___7 Kitty blog. ___END_PAGEFIND_WEIGHT___"
);
assert_eq!(data.meta.get("deal"), None);
assert_eq!(data.filters.get("color"), None);
assert_eq!(data.meta.get("image"), Some(&"/kitty.jpg".to_owned()));
assert_eq!(data.meta.get("image_alt"), Some(&"a kitty".to_owned()));
}
#[test]
fn automatically_removed_elements_still_supply_metadata() {
let data = test_raw_parse(vec![
"<html><head><title>Kitty blog</title></head><body>",
"<nav data-pagefind-meta='section:navigation'>Home</nav>",
"<p>Meow</p>",
"</body></html>",
]);
assert_eq!(data.digest, "Meow.");
assert_eq!(data.meta.get("section"), Some(&"navigation".to_owned()));
assert_eq!(data.meta.get("title"), Some(&"Kitty blog".to_owned()));
}
#[test]
fn return_metadata() {
let data = test_raw_parse(vec![