use std::collections::HashSet;
use indexmap::IndexMap;
use crate::aliases::{self, AliasOptions};
use crate::error::{Error, Result};
use crate::parse::{Document, Element, Node};
const GOOGLE_FONTS_ORIGIN: &str = "https://fonts.googleapis.com";
const GOOGLE_FONTS_STATIC: &str = "https://fonts.gstatic.com";
pub fn expand_font_links(
doc: &mut Document,
_aliases: &AliasOptions,
site: Option<(&str, &str)>,
) -> Result<()> {
let mut state = ExpandState::default();
expand_font_links_in_nodes(&mut doc.children, &mut state, site)
}
#[derive(Default)]
struct ExpandState {
preconnect_done: HashSet<&'static str>,
}
fn expand_font_links_in_nodes(
nodes: &mut Vec<Node>,
state: &mut ExpandState,
site: Option<(&str, &str)>,
) -> Result<()> {
let mut i = 0;
while i < nodes.len() {
let is_font = matches!(&nodes[i], Node::Element(el) if is_font_link(el));
if is_font {
if let Node::Element(el) = &nodes[i] {
let expanded = expand_font_link(el, state, site)?;
let len = expanded.len();
nodes.splice(i..=i, expanded);
i += len.max(1);
continue;
}
}
if let Node::Element(el) = &mut nodes[i] {
expand_font_links_in_nodes(&mut el.children, state, site)?;
}
i += 1;
}
Ok(())
}
fn expand_font_link(
el: &Element,
state: &mut ExpandState,
site: Option<(&str, &str)>,
) -> Result<Vec<Node>> {
let href = el.attr("href").unwrap_or("").trim();
if href.is_empty() {
return Err(font_err(site, &["href"], "statica/font link missing href"));
}
let mut out = Vec::new();
if aliases::is_google_fonts_css(href) {
push_preconnect(&mut out, GOOGLE_FONTS_ORIGIN, CrossOrigin::None, state);
push_preconnect(&mut out, GOOGLE_FONTS_STATIC, CrossOrigin::Anonymous, state);
}
out.push(stylesheet_link(href, el));
Ok(out)
}
fn push_preconnect(
out: &mut Vec<Node>,
href: &'static str,
crossorigin: CrossOrigin,
state: &mut ExpandState,
) {
if !state.preconnect_done.insert(href) {
return;
}
match crossorigin {
CrossOrigin::None => out.push(link_node(&[("rel", "preconnect"), ("href", href)])),
CrossOrigin::Anonymous => out.push(link_node(&[
("rel", "preconnect"),
("href", href),
("crossorigin", ""),
])),
}
}
#[derive(Clone, Copy)]
enum CrossOrigin {
None,
Anonymous,
}
fn stylesheet_link(href: &str, src: &Element) -> Node {
let mut attrs = IndexMap::new();
attrs.insert("rel".into(), "stylesheet".into());
attrs.insert("href".into(), href.to_string());
for (k, v) in &src.attrs {
if k == "rel" || k == "href" {
continue;
}
attrs.insert(k.clone(), v.clone());
}
Node::Element(Element {
name: "link".into(),
attrs,
children: Vec::new(),
void: true,
})
}
fn link_node(attrs: &[(&str, &str)]) -> Node {
let mut map = IndexMap::new();
for (k, v) in attrs {
map.insert((*k).into(), (*v).into());
}
Node::Element(Element {
name: "link".into(),
attrs: map,
children: Vec::new(),
void: true,
})
}
pub fn is_font_link(el: &Element) -> bool {
el.is_link()
&& el
.attr("rel")
.is_some_and(|r| r.split_whitespace().any(|p| p == "statica/font"))
}
fn font_err(site: Option<(&str, &str)>, extra: &[&str], message: impl Into<String>) -> Error {
let mut needles = vec!["rel=\"statica/font\"", "rel='statica/font'"];
needles.extend_from_slice(extra);
match site {
Some((file, source)) => Error::at(file, source, &needles, message),
None => Error::at_file("<page>", message),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aliases::{resolve_paths_in_document, AliasOptions};
use crate::parse::{parse_document, serialize_document};
fn expand_doc(html: &str, aliases: &AliasOptions) -> String {
let mut doc = parse_document(html).unwrap();
resolve_paths_in_document(&mut doc, aliases, None).unwrap();
expand_font_links(&mut doc, aliases, None).unwrap();
serialize_document(&doc)
}
#[test]
fn google_alias_expands_to_preconnect_and_stylesheet() {
let html = expand_doc(
r#"<!doctype html><html><head>
<link rel="statica/font" href="@Google/?family=Outfit:wght@100..900&display=swap" id="outfit-font" />
</head><body></body></html>"#,
&AliasOptions::default(),
);
assert!(html.contains(r#"<link rel="preconnect" href="https://fonts.googleapis.com""#));
assert!(html
.contains(r#"<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="""#));
assert!(html.contains("fonts.googleapis.com/css2?family=Outfit:wght@100..900"));
assert!(html.contains("display=swap"));
assert!(html.contains(r#"rel="stylesheet""#));
assert!(html.contains(r#"id="outfit-font""#));
assert!(!html.contains("statica/font"));
}
#[test]
fn preconnect_deduped_for_multiple_google_fonts() {
let html = expand_doc(
r#"<!doctype html><html><head>
<link rel="statica/font" href="@Google/?family=Outfit:wght@400&display=swap" />
<link rel="statica/font" href="@Google/?family=Open+Sans:wght@400;700&display=swap" />
</head><body></body></html>"#,
&AliasOptions::default(),
);
assert_eq!(html.matches("rel=\"preconnect\"").count(), 2);
}
#[test]
fn plain_local_path_rewrites_to_stylesheet() {
let html = expand_doc(
r#"<!doctype html><html><head>
<link rel="statica/font" href="./fonts/outfit.css" />
</head><body></body></html>"#,
&AliasOptions::default(),
);
assert!(html.contains(r#"<link rel="stylesheet" href="./fonts/outfit.css""#));
assert!(!html.contains("preconnect"));
assert!(!html.contains("statica/font"));
}
#[test]
fn local_alias_rewrites_to_stylesheet() {
let mut aliases = AliasOptions::default();
aliases
.paths
.insert("fonts".into(), "./assets/fonts".into());
let html = expand_doc(
r#"<!doctype html><html><head>
<link rel="statica/font" href="@fonts/outfit.css" />
</head><body></body></html>"#,
&aliases,
);
assert!(html.contains(r#"href="./assets/fonts/outfit.css""#));
assert!(html.contains(r#"rel="stylesheet""#));
}
}