use std::collections::HashSet;
use std::sync::LazyLock;
use regex::Regex;
use reqwest::Client;
use url::Url;
use crate::error::BrogzError;
use crate::orchestration::join_under_base;
static ASSET_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(?:src|href)=["']([^"']+\.(?:js|mjs|css))["']"#)
.expect("static asset regex compiles")
});
pub fn parse_assets(html: &str, base: &Url) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
let index = "/index.html".to_owned();
seen.insert(index.clone());
out.push(index);
let base_origin = base.origin();
for caps in ASSET_REGEX.captures_iter(html) {
let href = &caps[1];
let Ok(resolved) = base.join(href) else {
continue;
};
if resolved.origin() != base_origin {
continue;
}
let path = match resolved.query() {
Some(q) => format!("{}?{}", resolved.path(), q),
None => resolved.path().to_owned(),
};
if seen.insert(path.clone()) {
out.push(path);
}
}
out
}
pub async fn discover_urls(base: &Url, client: &Client) -> Result<Vec<String>, BrogzError> {
let index_url = join_under_base(base, "/index.html")?;
let response = client
.get(index_url.clone())
.header("Accept-Encoding", "identity")
.send()
.await?;
if !response.status().is_success() {
return Err(BrogzError::MissingHtml {
url: index_url.to_string(),
});
}
let html = response.text().await?;
Ok(parse_assets(&html, base))
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Url {
Url::parse("https://example.test").unwrap()
}
#[test]
fn extracts_assets_dedupes_and_keeps_order() {
let html = r#"<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/assets/app-abc123.css">
<link rel="modulepreload" href='/assets/preload.mjs'>
<link rel="icon" href="/favicon.ico">
<script src="/assets/app-def456.js"></script>
<script src='assets/relative.js'></script>
<script src="https://cdn.example.com/external.js"></script>
<script src="http://cdn.example.com/insecure.js"></script>
<script src="/assets/app-def456.js"></script>
</head>
</html>"#;
let paths = parse_assets(html, &base());
assert_eq!(
paths,
vec![
"/index.html",
"/assets/app-abc123.css",
"/assets/preload.mjs",
"/assets/app-def456.js",
"/assets/relative.js",
]
);
}
#[test]
fn empty_html_still_includes_index() {
assert_eq!(parse_assets("<html></html>", &base()), vec!["/index.html"]);
}
#[test]
fn match_is_case_insensitive() {
let html = r#"<SCRIPT SRC="/app.JS"></SCRIPT><LINK HREF="/style.CSS">"#;
let paths = parse_assets(html, &base());
assert_eq!(paths, vec!["/index.html", "/app.JS", "/style.CSS"]);
}
#[test]
fn skips_protocol_relative_cross_host_assets() {
let html = r#"<link href="//cdn.some.site/frontend/dist/Main.css">
<script src="//cdn.example.com/sdk.js"></script>
<script src="/assets/local.js"></script>"#;
assert_eq!(
parse_assets(html, &Url::parse("https://some.site").unwrap()),
vec!["/index.html", "/assets/local.js"]
);
}
#[test]
fn fully_qualified_same_origin_is_kept_as_path() {
let html = r#"<script src="https://app.example/assets/main.js"></script>
<link href="https://app.example/style.css">
<script src="https://app.example:8443/portbump.js"></script>"#;
assert_eq!(
parse_assets(html, &Url::parse("https://app.example").unwrap()),
vec!["/index.html", "/assets/main.js", "/style.css"]
);
}
#[test]
fn cross_scheme_is_treated_as_cross_origin() {
let html = r#"<script src="http://app.example/insecure.js"></script>"#;
assert_eq!(
parse_assets(html, &Url::parse("https://app.example").unwrap()),
vec!["/index.html"]
);
}
#[test]
fn ignores_unrelated_extensions() {
let html = r#"<link rel="icon" href="/favicon.ico">
<script src="/app.wasm"></script>
<script src="/app.js"></script>"#;
assert_eq!(parse_assets(html, &base()), vec!["/index.html", "/app.js"]);
}
}