use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use crate::{Error, Result};
#[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Importmap {
imports: BTreeMap<String, String>,
}
impl Importmap {
pub fn new() -> Self {
Self::default()
}
pub fn from_mounts(mounts: &[crate::mount::Mount]) -> Self {
let mut map = Self::new();
for mount in mounts {
let specifier = mount.specifier_prefix();
if !specifier.is_empty() {
map.insert(specifier, mount.url_prefix());
}
}
map
}
pub fn insert(&mut self, specifier: impl Into<String>, url: impl Into<String>) -> &mut Self {
self.imports.insert(specifier.into(), url.into());
self
}
pub fn extend(&mut self, other: Importmap) -> &mut Self {
self.imports.extend(other.imports);
self
}
pub fn is_empty(&self) -> bool {
self.imports.is_empty()
}
pub fn len(&self) -> usize {
self.imports.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.imports.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn resolves(&self, specifier: &str) -> bool {
self.imports.contains_key(specifier)
|| self
.imports
.keys()
.any(|k| k.ends_with('/') && specifier.starts_with(k.as_str()))
}
pub fn from_json_file(path: &Path) -> Result<Self> {
let bytes = fs::read(path)?;
let text = String::from_utf8_lossy(&bytes);
Self::from_json_str(&text, &path.display().to_string())
}
pub fn from_json_str(json: &str, context: &str) -> Result<Self> {
serde_json::from_str(json).map_err(|e| Error::ImportMap(format!("{context}: {e}")))
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("string map serializes")
}
pub fn to_script_tag(&self) -> String {
format!(
"<script type=\"importmap\">{}</script>",
escape_for_script(&serde_json::to_string(self).expect("string map serializes"))
)
}
pub fn write_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, self.to_json())?;
Ok(())
}
}
fn escape_for_script(json: &str) -> String {
json.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('&', "\\u0026")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_other_wins() {
let mut base = Importmap::new();
base.insert("lit", "/old.js").insert("only-base", "/b.js");
let mut other = Importmap::new();
other.insert("lit", "/new.js").insert("only-other", "/o.js");
base.extend(other);
let json = base.to_json();
assert!(json.contains("/new.js"));
assert!(!json.contains("/old.js"));
assert!(json.contains("only-base") && json.contains("only-other"));
}
#[test]
fn round_trips_through_json_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("importmap.json");
let mut original = Importmap::new();
original
.insert("lit", "/web_modules/lit/index.js")
.insert("lit/", "/web_modules/lit/");
original.write_to(&path).unwrap();
let parsed = Importmap::from_json_file(&path).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn script_tag_is_compact_and_wrapped() {
let mut map = Importmap::new();
map.insert("lit", "/web_modules/lit/index.js");
let tag = map.to_script_tag();
assert!(tag.starts_with("<script type=\"importmap\">{"));
assert!(tag.ends_with("}</script>"));
assert!(!tag.contains('\n'));
}
#[test]
fn script_tag_escapes_html_breakout() {
let mut map = Importmap::new();
map.insert(
"evil/</script><script>alert(1)</script>",
"/web_modules/evil/index.js",
);
let tag = map.to_script_tag();
assert!(tag.contains("\\u003c/script\\u003e"));
assert_eq!(tag.matches("</script>").count(), 1);
assert!(map.to_json().contains("</script>"));
}
#[test]
fn resolves_exact_and_prefix() {
let mut map = Importmap::new();
map.insert("lit", "/web_modules/lit/index.js")
.insert("lit/", "/web_modules/lit/");
assert!(map.resolves("lit"));
assert!(map.resolves("lit/decorators.js")); assert!(!map.resolves("react"));
assert!(!map.resolves("@oxc-project/runtime/helpers/decorate"));
}
#[test]
fn rejects_non_string_and_missing_imports() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("bad.json");
fs::write(&bad, r#"{"imports":{"lit":42}}"#).unwrap();
assert!(matches!(
Importmap::from_json_file(&bad).unwrap_err(),
Error::ImportMap(_)
));
fs::write(&bad, r#"{"nope":{}}"#).unwrap();
assert!(matches!(
Importmap::from_json_file(&bad).unwrap_err(),
Error::ImportMap(_)
));
}
}