use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use crate::{Error, Result};
#[derive(Default, Debug, Clone, PartialEq, Eq)]
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 parsed: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| Error::ImportMap(format!("{}: {e}", path.display())))?;
let imports = parsed
.get("imports")
.and_then(|v| v.as_object())
.ok_or_else(|| {
Error::ImportMap(format!(
"{}: expected a top-level 'imports' object",
path.display()
))
})?;
let mut map = Self::new();
for (key, value) in imports {
let url = value.as_str().ok_or_else(|| {
Error::ImportMap(format!(
"{}: imports.{key:?} is not a string",
path.display()
))
})?;
map.insert(key.clone(), url.to_string());
}
Ok(map)
}
pub fn to_json(&self) -> String {
self.to_value().to_string_pretty()
}
pub fn to_script_tag(&self) -> String {
format!(
"<script type=\"importmap\">{}</script>",
escape_for_script(&self.to_value().to_string_compact())
)
}
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 to_value(&self) -> Doc<'_> {
Doc(&self.imports)
}
}
struct Doc<'a>(&'a BTreeMap<String, String>);
impl Doc<'_> {
fn json(&self) -> serde_json::Value {
let body: serde_json::Map<String, serde_json::Value> = self
.0
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
serde_json::json!({ "imports": body })
}
fn to_string_pretty(&self) -> String {
serde_json::to_string_pretty(&self.json()).expect("string map serializes")
}
fn to_string_compact(&self) -> String {
serde_json::to_string(&self.json()).expect("string map serializes")
}
}
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(_)
));
}
}