use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use futures::future::try_join_all;
use regex::Regex;
use crate::AssetProvider;
use crate::pages::Page;
pub async fn resolve_images(
pages: &mut [Page],
provider: &dyn AssetProvider,
dest: &Path,
) -> Result<Vec<String>> {
let re = image_regex();
let mut keys: BTreeMap<String, ()> = BTreeMap::new();
for page in pages.iter() {
for cap in re.captures_iter(&page.body) {
let url = cap.name("url").unwrap().as_str();
if looks_remote(url) {
continue;
}
keys.insert(url.to_string(), ());
}
}
let key_vec: Vec<String> = keys.into_keys().collect();
let fetches = try_join_all(key_vec.iter().map(|k| async move {
let res: Result<(String, Option<PathBuf>)> = async {
let Some(bytes) = provider.get(k).await? else {
return Ok((k.clone(), None));
};
let local = dest.join(sanitize_key(k));
if let Some(parent) = local.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create parent for {}", local.display()))?;
}
tokio::fs::write(&local, &bytes)
.await
.with_context(|| format!("write {}", local.display()))?;
Ok((k.clone(), Some(local)))
}
.await;
res
}))
.await?;
let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
let mut resolved: Vec<String> = Vec::new();
for (k, p) in fetches {
if let Some(p) = p {
map.insert(k.clone(), p);
resolved.push(k);
}
}
for page in pages.iter_mut() {
let new_body = re
.replace_all(&page.body, |caps: ®ex::Captures<'_>| {
let url = caps.name("url").unwrap().as_str();
match map.get(url) {
Some(local) => {
format!("", &caps["alt"], local.display())
}
None => caps[0].to_string(),
}
})
.into_owned();
page.body = new_body;
}
Ok(resolved)
}
fn image_regex() -> Regex {
Regex::new(r"!\[(?P<alt>[^\]]*)\]\((?P<url>[^)\s]+)\)").unwrap()
}
fn looks_remote(url: &str) -> bool {
url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("data:")
|| url.starts_with("file://")
}
fn sanitize_key(key: &str) -> String {
key.replace(['/', '\\'], "__")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::sync_provider;
use crate::pages::PageOrigin;
use bytes::Bytes;
fn page(body: &str) -> Page {
Page { class: "content".into(), body: body.into(), origin: PageOrigin::Explicit }
}
#[tokio::test]
async fn rewrites_resolved_keys_only() {
let provider = sync_provider(|key| match key {
"img/diagram.svg" => Ok(Some(Bytes::from_static(b"<svg/>"))),
_ => Ok(None),
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec and  and "),
];
let resolved = resolve_images(&mut pages, &provider, tmp.path()).await.unwrap();
assert_eq!(resolved, vec!["img/diagram.svg".to_string()]);
assert!(pages[0].body.contains("img__diagram.svg"));
assert!(pages[0].body.contains("img/missing.png"), "missing keys preserved");
assert!(pages[0].body.contains("https://e.com/p.png"), "remote URLs preserved");
}
#[tokio::test]
async fn dedup_fetches_same_key_only_once() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let calls = Arc::new(AtomicUsize::new(0));
let calls_c = calls.clone();
let provider = sync_provider(move |k| {
calls_c.fetch_add(1, Ordering::SeqCst);
if k == "shared.png" { Ok(Some(Bytes::from_static(b"x"))) } else { Ok(None) }
});
let tmp = tempfile::tempdir().unwrap();
let mut pages = vec"), page("")];
resolve_images(&mut pages, &provider, tmp.path()).await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}