#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Eagerness {
Conservative,
Moderate,
Eager,
Immediate,
}
impl Eagerness {
fn as_str(self) -> &'static str {
match self {
Eagerness::Conservative => "conservative",
Eagerness::Moderate => "moderate",
Eagerness::Eager => "eager",
Eagerness::Immediate => "immediate",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpeculationMode {
Off,
Prefetch,
Prerender,
}
impl SpeculationMode {
fn rule_key(self) -> Option<&'static str> {
match self {
SpeculationMode::Off => None,
SpeculationMode::Prefetch => Some("prefetch"),
SpeculationMode::Prerender => Some("prerender"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PrefetchConfig {
pub mode: SpeculationMode,
pub eagerness: Eagerness,
}
impl Default for PrefetchConfig {
fn default() -> Self {
Self {
mode: SpeculationMode::Prefetch,
eagerness: Eagerness::Moderate,
}
}
}
impl PrefetchConfig {
pub const OFF: Self = Self {
mode: SpeculationMode::Off,
eagerness: Eagerness::Moderate,
};
pub fn script(&self) -> Option<String> {
let key = self.mode.rule_key()?;
Some(format!(
concat!(
r#"<script type="speculationrules">"#,
r#"{{"{key}":[{{"where":{{"and":[{{"href_matches":"/*"}},"#,
r#"{{"not":{{"selector_matches":"[data-no-prefetch]"}}}}]}},"#,
r#""eagerness":"{eagerness}"}}]}}"#,
r#"</script>"#,
),
key = key,
eagerness = self.eagerness.as_str(),
))
}
pub fn inject_into_head(&self, before: String) -> String {
let Some(script) = self.script() else {
return before;
};
if !before.contains("</head>") {
return before;
}
before.replacen("</head>", &format!("{script}</head>"), 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_prefetch_moderate() {
let cfg = PrefetchConfig::default();
assert_eq!(cfg.mode, SpeculationMode::Prefetch);
assert_eq!(cfg.eagerness, Eagerness::Moderate);
let script = cfg.script().expect("default emits a script");
assert!(script.contains(r#"type="speculationrules""#));
assert!(script.contains(r#""prefetch""#));
assert!(script.contains(r#""eagerness":"moderate""#));
assert!(script.contains(r#""href_matches":"/*""#));
assert!(script.contains(r#""selector_matches":"[data-no-prefetch]""#));
assert!(!script.contains(r#""prerender""#));
}
#[test]
fn off_emits_no_script_and_no_injection() {
assert!(PrefetchConfig::OFF.script().is_none());
let html = "<html><head></head><body>x</body></html>".to_string();
assert_eq!(PrefetchConfig::OFF.inject_into_head(html.clone()), html);
}
#[test]
fn prerender_mode_uses_prerender_key() {
let cfg = PrefetchConfig {
mode: SpeculationMode::Prerender,
eagerness: Eagerness::Eager,
};
let script = cfg.script().unwrap();
assert!(script.contains(r#""prerender""#));
assert!(script.contains(r#""eagerness":"eager""#));
assert!(!script.contains(r#""prefetch""#));
}
#[test]
fn injects_immediately_before_head_close() {
let before = "<html><head><title>t</title></head><body><main>".to_string();
let out = PrefetchConfig::default().inject_into_head(before);
let script_at = out.find("speculationrules").unwrap();
let head_close = out.find("</head>").unwrap();
let body_at = out.find("<body>").unwrap();
assert!(script_at < head_close, "script lands inside <head>");
assert!(head_close < body_at);
}
#[test]
fn headless_fragment_is_untouched() {
let fragment = r#"<div class="root"><h1>Home</h1></div>"#.to_string();
assert_eq!(
PrefetchConfig::default().inject_into_head(fragment.clone()),
fragment,
);
}
#[test]
fn only_first_head_close_is_targeted() {
let before = "<head>a</head><head>b</head>".to_string();
let out = PrefetchConfig::default().inject_into_head(before);
assert_eq!(out.matches("speculationrules").count(), 1);
let first = out.find("</head>").unwrap();
assert!(out.find("speculationrules").unwrap() < first);
}
}