use std::cmp::Reverse;
use url::Url;
#[derive(Debug, Default, Clone)]
struct SpecifierMap {
entries: Vec<(String, Url)>,
}
impl SpecifierMap {
fn parse(value: Option<&serde_json::Value>, base_url: Option<&Url>) -> Self {
let mut entries: Vec<(String, Url)> = value
.and_then(serde_json::Value::as_object)
.into_iter()
.flatten()
.filter_map(|(key, value)| {
if key.is_empty() {
return None;
}
let target = value.as_str()?;
if key.ends_with('/') != target.ends_with('/') {
return None;
}
let url = resolve_map_target(target, base_url)?;
Some((key.clone(), url))
})
.collect();
entries.sort_by_key(|(key, _)| Reverse(key.len()));
Self { entries }
}
fn resolve(&self, specifier: &str) -> Option<Url> {
for (key, target) in &self.entries {
if key == specifier {
return Some(target.clone());
}
if key.ends_with('/') && specifier.starts_with(key.as_str()) {
return target.join(&specifier[key.len()..]).ok();
}
}
None
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct ImportMap {
imports: SpecifierMap,
scopes: Vec<(String, SpecifierMap)>,
}
impl ImportMap {
pub(crate) fn parse(json: &str, base_url: Option<&Url>) -> Self {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Self::default();
};
let imports = SpecifierMap::parse(value.get("imports"), base_url);
let mut scopes: Vec<(String, SpecifierMap)> = value
.get("scopes")
.and_then(serde_json::Value::as_object)
.into_iter()
.flatten()
.filter_map(|(prefix, map)| {
let prefix = resolve_map_target(prefix, base_url)?;
Some((prefix.to_string(), SpecifierMap::parse(Some(map), base_url)))
})
.collect();
scopes.sort_by_key(|(prefix, _)| Reverse(prefix.len()));
Self { imports, scopes }
}
pub(crate) fn is_empty(&self) -> bool {
self.imports.entries.is_empty() && self.scopes.is_empty()
}
pub(crate) fn resolve(&self, specifier: &str, referrer_url: Option<&Url>) -> Option<Url> {
if let Some(referrer) = referrer_url {
let referrer = referrer.as_str();
for (prefix, map) in &self.scopes {
if referrer.starts_with(prefix.as_str()) {
if let Some(url) = map.resolve(specifier) {
return Some(url);
}
}
}
}
self.imports.resolve(specifier)
}
}
fn resolve_map_target(target: &str, base_url: Option<&Url>) -> Option<Url> {
if let Ok(url) = Url::parse(target) {
return Some(url);
}
if target.starts_with('/') || target.starts_with("./") || target.starts_with("../") {
return base_url?.join(target).ok();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Url {
Url::parse("https://example.invalid/app/index.html").expect("a valid base")
}
#[test]
fn a_bare_specifier_resolves_through_imports() {
let map = ImportMap::parse(
r#"{"imports": {"preact": "/vendor/preact.js"}}"#,
Some(&base()),
);
assert_eq!(
map.resolve("preact", None).map(|url| url.to_string()),
Some("https://example.invalid/vendor/preact.js".to_owned())
);
}
#[test]
fn a_trailing_slash_key_maps_a_subtree_by_longest_prefix() {
let map = ImportMap::parse(
r#"{"imports": {"lib/": "/js/", "lib/deep/": "/other/"}}"#,
Some(&base()),
);
assert_eq!(
map.resolve("lib/a.js", None).map(|url| url.to_string()),
Some("https://example.invalid/js/a.js".to_owned())
);
assert_eq!(
map.resolve("lib/deep/a.js", None)
.map(|url| url.to_string()),
Some("https://example.invalid/other/a.js".to_owned())
);
}
#[test]
fn a_scope_overrides_the_top_level_map_for_modules_under_it() {
let map = ImportMap::parse(
r#"{
"imports": {"dep": "/v2/dep.js"},
"scopes": {"/legacy/": {"dep": "/v1/dep.js"}}
}"#,
Some(&base()),
);
let legacy = Url::parse("https://example.invalid/legacy/old.js").expect("a valid URL");
let modern = Url::parse("https://example.invalid/modern/new.js").expect("a valid URL");
assert_eq!(
map.resolve("dep", Some(&legacy)).map(|url| url.to_string()),
Some("https://example.invalid/v1/dep.js".to_owned())
);
assert_eq!(
map.resolve("dep", Some(&modern)).map(|url| url.to_string()),
Some("https://example.invalid/v2/dep.js".to_owned())
);
}
#[test]
fn a_mismatched_trailing_slash_is_dropped() {
let map = ImportMap::parse(r#"{"imports": {"lib/": "/js/bundle.js"}}"#, Some(&base()));
assert!(map.is_empty());
}
#[test]
fn malformed_json_yields_an_empty_map() {
assert!(ImportMap::parse("{not json", Some(&base())).is_empty());
}
}