diff --git a/docs/content/docs/config-options.md b/docs/content/docs/config-options.md index b3eedce..7001f84 100644 --- a/docs/content/docs/config-options.md +++ b/docs/content/docs/config-options.md @@ -71,6 +71,30 @@ Note that currently Pagefind only supports lists of options via configuration fi |---------------------------|------------------------------|---------------------| | `--exclude-selectors ` | `PAGEFIND_EXCLUDE_SELECTORS` | `exclude_selectors` | +### Rules +Applies Pagefind attributes to the elements a selector matches, so a site can be configured with the same vocabulary its HTML would use. For example, in `pagefind.yml`: + +```yml +rules: + - selector: "#my_navigation" + attributes: + data-pagefind-ignore: all + - selector: ".article-body" + attributes: + data-pagefind-body: "" + data-pagefind-meta: "title[data-title]" +``` + +Each rule behaves exactly as if the attributes had been written on the matching elements, so the documentation for [indexing attributes](/docs/indexing/) applies unchanged. + +An attribute written in the page always wins over a rule setting the same attribute, so rules never override markup you control. A selector Pagefind cannot parse is reported and skipped, and indexing continues without it. + +Rules are only available via configuration files, as a list of selectors with their attributes does not fit a CLI flag or an environment variable. + +| CLI Flag | ENV Variable | Config Key | +|----------|--------------|------------| +| n/a | n/a | `rules` | + ### Include characters Prevents Pagefind from stripping the provided characters when indexing content. Allows users to search for words including these characters. diff --git a/pagefind/src/fossick/parser.rs b/pagefind/src/fossick/parser.rs index ac0136a..aaaeba5 100644 --- a/pagefind/src/fossick/parser.rs +++ b/pagefind/src/fossick/parser.rs @@ -154,7 +154,41 @@ impl<'a> DomParser<'a> { .join(", "); let mut anchor_counter = 0; + // Config rules run before every other handler, so the attributes they set are + // read by the handlers below exactly as if the page had carried them itself. + let rule_handlers = options + .rules + .iter() + .filter_map(|rule| match rule.selector.parse::() { + Ok(_) => { + let attributes = rule.attributes.clone(); + Some(element!(rule.selector.clone(), move |el| { + for (name, value) in attributes.iter() { + // An attribute written in the HTML is more specific than a + // rule matching a whole selector, so the page wins. + if el.has_attribute(name) { + continue; + } + if el.set_attribute(name, value).is_err() { + // Only an invalid attribute name can fail here, and that + // is reported once when the rules are read. + } + } + Ok(()) + })) + } + Err(error) => { + options.logger.warn(format!( + "Ignoring the rule selector {:?}, which Pagefind can't parse: {error}. Indexing will continue without it.", + rule.selector + )); + None + } + }) + .collect::>(); + let rewriter = HtmlRewriter::new( + rule_handlers.into_iter().chain( vec![ enclose! { (data) element!("html", move |el| { let mut data = data.borrow_mut(); @@ -563,8 +597,7 @@ impl<'a> DomParser<'a> { } Ok(()) })}, - ] - .into_iter() + ]) .fold(Settings::new().with_strict(false), |settings, handler| { settings.append_element_content_handler(handler) }), @@ -771,6 +804,93 @@ mod tests { rewriter.wrap() } + fn test_parse_with_rules( + mut input: Vec<&'static str>, + rules: &[(&'static str, &[(&'static str, &'static str)])], + ) -> DomParserResult { + use clap::CommandFactory; + let config_args = vec![twelf::Layer::Clap( + crate::PagefindInboundConfig::command().get_matches_from(vec![ + "pagefind", + "--source", + "not_important", + ]), + )]; + let mut config = + SearchOptions::load(crate::PagefindInboundConfig::with_layers(&config_args).unwrap()) + .unwrap(); + config.rules = rules + .iter() + .map(|(selector, attributes)| crate::options::PagefindRule { + selector: selector.to_string(), + attributes: attributes + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(), + }) + .collect(); + + let mut rewriter = DomParser::new(&config); + input.insert(0, ""); + input.push(""); + for line in input { + let _ = rewriter.write(line.as_bytes()); + } + rewriter.wrap() + } + + #[test] + fn rules_apply_pagefind_attributes_by_selector() { + let data = test_parse_with_rules( + vec!["", "

Kept

"], + &[("#nav", &[("data-pagefind-ignore", "all")])], + ); + + assert_eq!(data.digest, "Kept."); + } + + #[test] + fn rules_can_pick_the_body() { + let data = test_parse_with_rules( + vec![ + "
Chrome
", + "
Real content
", + ], + &[(".main", &[("data-pagefind-body", "")])], + ); + + assert_eq!(data.digest, "Real content."); + } + + #[test] + fn an_attribute_in_the_page_wins_over_a_rule() { + let data = test_parse_with_rules( + vec![ + "

Own weight

", + "

Rule weight

", + ], + &[(".weighted", &[("data-pagefind-weight", "5")])], + ); + + // The first paragraph keeps the weight written in the page, the second one + // takes the weight from the rule, so the rule applies without overriding. + assert!(data.digest.contains("___PAGEFIND_WEIGHT___2 Own weight.")); + assert!(data.digest.contains("___PAGEFIND_WEIGHT___5 Rule weight.")); + } + + #[test] + fn unsupported_rule_selector_does_not_halt_indexing() { + let data = test_parse_with_rules( + vec!["

Kept

", "
Dropped
"], + &[ + (":not(.a b)", &[("data-pagefind-ignore", "all")]), + (".ad", &[("data-pagefind-ignore", "all")]), + ], + ); + + assert_eq!(data.digest, "Kept."); + } + #[test] fn unsupported_exclude_selector_does_not_halt_indexing() { let data = test_parse_with_exclusions( diff --git a/pagefind/src/options.rs b/pagefind/src/options.rs index 2de8d3a..d9fc68b 100644 --- a/pagefind/src/options.rs +++ b/pagefind/src/options.rs @@ -4,7 +4,7 @@ use anyhow::{bail, Result}; use clap::Parser; use rust_patch::Patch; use serde::{Deserialize, Serialize}; -use std::{env, path::PathBuf}; +use std::{collections::BTreeMap, env, path::PathBuf}; use twelf::config; use typed_builder::TypedBuilder; @@ -22,6 +22,15 @@ use crate::{ // No options should be added that are required. // +/// A selector paired with the Pagefind attributes to set on the elements it matches, +/// so a site can be configured with the same vocabulary its HTML would use. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct PagefindRule { + pub(crate) selector: String, + #[serde(default)] + pub(crate) attributes: BTreeMap, +} + #[config] #[derive(Parser, Debug, Clone)] #[clap(author, version, about, long_about = None)] @@ -74,6 +83,11 @@ pub(crate) struct PagefindInboundConfig { #[serde(default)] pub(crate) exclude_selectors: Vec, + /// Not a CLI flag: a list of selectors with attribute maps does not fit an argument. + #[clap(skip)] + #[serde(default)] + pub(crate) rules: Vec, + #[clap( long, help = "The file glob Pagefind uses to find HTML files. Defaults to \"**/*.{html}\"" @@ -227,6 +241,7 @@ pub(crate) struct SearchOptions { pub(crate) bundle_output: PathBuf, pub(crate) root_selector: String, pub(crate) exclude_selectors: Vec, + pub(crate) rules: Vec, pub(crate) glob: String, pub(crate) force_language: Option, pub(crate) include_characters: Vec, @@ -308,6 +323,7 @@ impl SearchOptions { bundle_output, root_selector: config.root_selector, exclude_selectors: config.exclude_selectors, + rules: config.rules, glob: config.glob, force_language: config.force_language, include_characters,