use deno::ErrBox; use deno::ModuleSpecifier; use indexmap::IndexMap; use serde_json::Map; use serde_json::Value; use std::cmp::Ordering; use std::error::Error; use std::fmt; use std::fs; use std::io; use url::Url; #[derive(Debug)] pub struct ImportMapError { pub msg: String, } impl ImportMapError { pub fn new(msg: &str) -> Self { ImportMapError { msg: msg.to_string(), } } } impl fmt::Display for ImportMapError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.pad(&self.msg) } } impl Error for ImportMapError {} // NOTE: here is difference between deno and reference implementation - deno currently // can't resolve URL with other schemes (eg. data:, about:, blob:) const SUPPORTED_FETCH_SCHEMES: [&str; 3] = ["http", "https", "file"]; type SpecifierMap = IndexMap>; type ScopesMap = IndexMap; #[derive(Debug)] pub struct ImportMap { base_url: String, imports: SpecifierMap, scopes: ScopesMap, } impl ImportMap { pub fn load(file_path: &str) -> Result { let file_url = ModuleSpecifier::resolve_url_or_path(file_path)?.to_string(); let resolved_path = std::env::current_dir().unwrap().join(file_path); debug!( "Attempt to load import map: {}", resolved_path.to_str().unwrap() ); // Load the contents of import map let json_string = fs::read_to_string(&resolved_path).map_err(|err| { io::Error::new( io::ErrorKind::InvalidInput, format!( "Error retrieving import map file at \"{}\": {}", resolved_path.to_str().unwrap(), err.to_string() ) .as_str(), ) })?; // The URL of the import map is the base URL for its values. ImportMap::from_json(&file_url, &json_string).map_err(ErrBox::from) } pub fn from_json( base_url: &str, json_string: &str, ) -> Result { let v: Value = match serde_json::from_str(json_string) { Ok(v) => v, Err(_) => { return Err(ImportMapError::new("Unable to parse import map JSON")); } }; match v { Value::Object(_) => {} _ => { return Err(ImportMapError::new("Import map JSON must be an object")); } } let normalized_imports = match &v.get("imports") { Some(imports_map) => { if !imports_map.is_object() { return Err(ImportMapError::new( "Import map's 'imports' must be an object", )); } let imports_map = imports_map.as_object().unwrap(); ImportMap::parse_specifier_map(imports_map, base_url) } None => IndexMap::new(), }; let normalized_scopes = match &v.get("scopes") { Some(scope_map) => { if !scope_map.is_object() { return Err(ImportMapError::new( "Import map's 'scopes' must be an object", )); } let scope_map = scope_map.as_object().unwrap(); ImportMap::parse_scope_map(scope_map, base_url)? } None => IndexMap::new(), }; let import_map = ImportMap { base_url: base_url.to_string(), imports: normalized_imports, scopes: normalized_scopes, }; Ok(import_map) } fn try_url_like_specifier(specifier: &str, base: &str) -> Option { // this should never fail if specifier.starts_with('/') || specifier.starts_with("./") || specifier.starts_with("../") { let base_url = Url::parse(base).unwrap(); let url = base_url.join(specifier).unwrap(); return Some(url); } if let Ok(url) = Url::parse(specifier) { if SUPPORTED_FETCH_SCHEMES.contains(&url.scheme()) { return Some(url); } } None } /// Parse provided key as import map specifier. /// /// Specifiers must be valid URLs (eg. "https://deno.land/x/std/testing/mod.ts") /// or "bare" specifiers (eg. "moment"). // TODO: add proper error handling: https://github.com/WICG/import-maps/issues/100 fn normalize_specifier_key( specifier_key: &str, base_url: &str, ) -> Option { // ignore empty keys if specifier_key.is_empty() { return None; } if let Some(url) = ImportMap::try_url_like_specifier(specifier_key, base_url) { return Some(url.to_string()); } // "bare" specifier Some(specifier_key.to_string()) } /// Parse provided addresses as valid URLs. /// /// Non-valid addresses are skipped. fn normalize_addresses( specifier_key: &str, base_url: &str, potential_addresses: Vec, ) -> Vec { let mut normalized_addresses: Vec = vec![]; for potential_address in potential_addresses { let url = match ImportMap::try_url_like_specifier(&potential_address, base_url) { Some(url) => url, None => continue, }; let url_string = url.to_string(); if specifier_key.ends_with('/') && !url_string.ends_with('/') { eprintln!( "Invalid target address {:?} for package specifier {:?}.\ Package address targets must end with \"/\".", url_string, specifier_key ); continue; } normalized_addresses.push(url.into()); } normalized_addresses } /// Convert provided JSON map to valid SpecifierMap. /// /// From specification: /// - order of iteration must be retained /// - SpecifierMap's keys are sorted in longest and alphabetic order fn parse_specifier_map( json_map: &Map, base_url: &str, ) -> SpecifierMap { let mut normalized_map: SpecifierMap = SpecifierMap::new(); // Order is preserved because of "preserve_order" feature of "serde_json". for (specifier_key, value) in json_map.iter() { let normalized_specifier_key = match ImportMap::normalize_specifier_key(specifier_key, base_url) { Some(s) => s, None => continue, }; let potential_addresses: Vec = match value { Value::String(address) => vec![address.to_string()], Value::Array(address_array) => { let mut string_addresses: Vec = vec![]; for address in address_array { match address { Value::String(address) => { string_addresses.push(address.to_string()) } _ => continue, } } string_addresses } Value::Null => vec![], _ => vec![], }; let normalized_address_array = ImportMap::normalize_addresses( &normalized_specifier_key, base_url, potential_addresses, ); debug!( "normalized specifier {:?}; {:?}", normalized_specifier_key, normalized_address_array ); normalized_map.insert(normalized_specifier_key, normalized_address_array); } // Sort in longest and alphabetical order. normalized_map.sort_by(|k1, _v1, k2, _v2| match k1.cmp(&k2) { Ordering::Greater => Ordering::Less, Ordering::Less => Ordering::Greater, Ordering::Equal => k2.cmp(k1), }); normalized_map } /// Convert provided JSON map to valid ScopeMap. /// /// From specification: /// - order of iteration must be retained /// - ScopeMap's keys are sorted in longest and alphabetic order fn parse_scope_map( scope_map: &Map, base_url: &str, ) -> Result { let mut normalized_map: ScopesMap = ScopesMap::new(); // Order is preserved because of "preserve_order" feature of "serde_json". for (scope_prefix, potential_specifier_map) in scope_map.iter() { if !potential_specifier_map.is_object() { return Err(ImportMapError::new(&format!( "The value for the {:?} scope prefix must be an object", scope_prefix ))); } let potential_specifier_map = potential_specifier_map.as_object().unwrap(); let scope_prefix_url = match Url::parse(base_url).unwrap().join(scope_prefix) { Ok(url) => { if !SUPPORTED_FETCH_SCHEMES.contains(&url.scheme()) { eprintln!( "Invalid scope {:?}. Scope URLs must have a valid fetch scheme.", url.to_string() ); continue; } url.to_string() } _ => continue, }; let norm_map = ImportMap::parse_specifier_map(potential_specifier_map, base_url); normalized_map.insert(scope_prefix_url, norm_map); } // Sort in longest and alphabetical order. normalized_map.sort_by(|k1, _v1, k2, _v2| match k1.cmp(&k2) { Ordering::Greater => Ordering::Less, Ordering::Less => Ordering::Greater, Ordering::Equal => k2.cmp(k1), }); Ok(normalized_map) } pub fn resolve_scopes_match( scopes: &ScopesMap, normalized_specifier: &str, referrer: &str, ) -> Result, ImportMapError> { // exact-match if let Some(scope_imports) = scopes.get(referrer) { if let Ok(scope_match) = ImportMap::resolve_imports_match(scope_imports, normalized_specifier) { // Return only if there was actual match (not None). if scope_match.is_some() { return Ok(scope_match); } } } for (normalized_scope_key, scope_imports) in scopes.iter() { if normalized_scope_key.ends_with('/') && referrer.starts_with(normalized_scope_key) { if let Ok(scope_match) = ImportMap::resolve_imports_match(scope_imports, normalized_specifier) { // Return only if there was actual match (not None). if scope_match.is_some() { return Ok(scope_match); } } } } Ok(None) } // TODO: https://github.com/WICG/import-maps/issues/73#issuecomment-439327758 // for some more optimized candidate implementations. pub fn resolve_imports_match( imports: &SpecifierMap, normalized_specifier: &str, ) -> Result, ImportMapError> { // exact-match if let Some(address_vec) = imports.get(normalized_specifier) { if address_vec.is_empty() { return Err(ImportMapError::new(&format!( "Specifier {:?} was mapped to no addresses.", normalized_specifier ))); } else if address_vec.len() == 1 { let address = address_vec.first().unwrap(); debug!( "Specifier {:?} was mapped to {:?}.", normalized_specifier, address ); return Ok(Some(address.clone())); } else { return Err(ImportMapError::new( "Multi-address mappings are not yet supported", )); } } // package-prefix match // "most-specific wins", i.e. when there are multiple matching keys, // choose the longest. // https://github.com/WICG/import-maps/issues/102 for (specifier_key, address_vec) in imports.iter() { if specifier_key.ends_with('/') && normalized_specifier.starts_with(specifier_key) { if address_vec.is_empty() { return Err(ImportMapError::new(&format!("Specifier {:?} was mapped to no addresses (via prefix specifier key {:?}).", normalized_specifier, specifier_key))); } else if address_vec.len() == 1 { let address = address_vec.first().unwrap(); let after_prefix = &normalized_specifier[specifier_key.len()..]; let base_url = address.as_url(); if let Ok(url) = base_url.join(after_prefix) { debug!("Specifier {:?} was mapped to {:?} (via prefix specifier key {:?}).", normalized_specifier, url, address); return Ok(Some(ModuleSpecifier::from(url))); } unreachable!(); } else { return Err(ImportMapError::new( "Multi-address mappings are not yet supported", )); } } } debug!( "Specifier {:?} was not mapped in import map.", normalized_specifier ); Ok(None) } // TODO: add support for built-in modules /// Currently we support two types of specifiers: URL (http://, https://, file://) /// and "bare" (moment, jquery, lodash) /// /// Scenarios: /// 1. import resolved using import map -> String /// 2. import restricted by import map -> ImportMapError /// 3. import not mapped -> None pub fn resolve( &self, specifier: &str, referrer: &str, ) -> Result, ImportMapError> { let resolved_url: Option = ImportMap::try_url_like_specifier(specifier, referrer); let normalized_specifier = match &resolved_url { Some(url) => url.to_string(), None => specifier.to_string(), }; let scopes_match = ImportMap::resolve_scopes_match( &self.scopes, &normalized_specifier, &referrer.to_string(), )?; // match found in scopes map if scopes_match.is_some() { return Ok(scopes_match); } let imports_match = ImportMap::resolve_imports_match(&self.imports, &normalized_specifier)?; // match found in import map if imports_match.is_some() { return Ok(imports_match); } // no match in import map but we got resolvable URL if let Some(resolved_url) = resolved_url { return Ok(Some(ModuleSpecifier::from(resolved_url))); } Err(ImportMapError::new(&format!( "Unmapped bare specifier {:?}", normalized_specifier ))) } } #[cfg(test)] mod tests { use super::*; #[test] fn load_nonexistent() { let file_path = "nonexistent_import_map.json"; assert!(ImportMap::load(file_path).is_err()); } #[test] fn from_json_1() { let base_url = "https://deno.land"; // empty JSON assert!(ImportMap::from_json(base_url, "{}").is_ok()); let non_object_strings = vec!["null", "true", "1", "\"foo\"", "[]"]; // invalid JSON for non_object in non_object_strings.to_vec() { assert!(ImportMap::from_json(base_url, non_object).is_err()); } // invalid schema: 'imports' is non-object for non_object in non_object_strings.to_vec() { assert!(ImportMap::from_json( base_url, &format!("{{\"imports\": {}}}", non_object), ) .is_err()); } // invalid schema: 'scopes' is non-object for non_object in non_object_strings.to_vec() { assert!(ImportMap::from_json( base_url, &format!("{{\"scopes\": {}}}", non_object), ) .is_err()); } } #[test] fn from_json_2() { let json_map = r#"{ "imports": { "foo": "https://example.com/1", "bar": ["https://example.com/2"], "fizz": null } }"#; let result = ImportMap::from_json("https://deno.land", json_map); assert!(result.is_ok()); } #[test] fn parse_specifier_keys_relative() { // Should absolutize strings prefixed with ./, ../, or / into the corresponding URLs.. let json_map = r#"{ "imports": { "./foo": "/dotslash", "../foo": "/dotdotslash", "/foo": "/slash" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map .imports .get("https://base.example/path1/path2/foo") .unwrap()[0], "https://base.example/dotslash".to_string() ); assert_eq!( import_map .imports .get("https://base.example/path1/foo") .unwrap()[0], "https://base.example/dotdotslash".to_string() ); assert_eq!( import_map.imports.get("https://base.example/foo").unwrap()[0], "https://base.example/slash".to_string() ); // Should absolutize the literal strings ./, ../, or / with no suffix.. let json_map = r#"{ "imports": { "./": "/dotslash/", "../": "/dotdotslash/", "/": "/slash/" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map .imports .get("https://base.example/path1/path2/") .unwrap()[0], "https://base.example/dotslash/".to_string() ); assert_eq!( import_map .imports .get("https://base.example/path1/") .unwrap()[0], "https://base.example/dotdotslash/".to_string() ); assert_eq!( import_map.imports.get("https://base.example/").unwrap()[0], "https://base.example/slash/".to_string() ); // Should treat percent-encoded variants of ./, ../, or / as bare specifiers.. let json_map = r#"{ "imports": { "%2E/": "/dotSlash1/", "%2E%2E/": "/dotDotSlash1/", ".%2F": "/dotSlash2", "..%2F": "/dotDotSlash2", "%2F": "/slash2", "%2E%2F": "/dotSlash3", "%2E%2E%2F": "/dotDotSlash3" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("%2E/").unwrap()[0], "https://base.example/dotSlash1/".to_string() ); assert_eq!( import_map.imports.get("%2E%2E/").unwrap()[0], "https://base.example/dotDotSlash1/".to_string() ); assert_eq!( import_map.imports.get(".%2F").unwrap()[0], "https://base.example/dotSlash2".to_string() ); assert_eq!( import_map.imports.get("..%2F").unwrap()[0], "https://base.example/dotDotSlash2".to_string() ); assert_eq!( import_map.imports.get("%2F").unwrap()[0], "https://base.example/slash2".to_string() ); assert_eq!( import_map.imports.get("%2E%2F").unwrap()[0], "https://base.example/dotSlash3".to_string() ); assert_eq!( import_map.imports.get("%2E%2E%2F").unwrap()[0], "https://base.example/dotDotSlash3".to_string() ); } #[test] fn parse_specifier_keys_absolute() { // Should only accept absolute URL specifier keys with fetch schemes,. // treating others as bare specifiers. let json_map = r#"{ "imports": { "file:///good": "/file", "http://good/": "/http/", "https://good/": "/https/", "about:bad": "/about", "blob:bad": "/blob", "data:bad": "/data", "filesystem:bad": "/filesystem", "ftp://bad/": "/ftp/", "import:bad": "/import", "mailto:bad": "/mailto", "javascript:bad": "/javascript", "wss:bad": "/wss" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("http://good/").unwrap()[0], "https://base.example/http/".to_string() ); assert_eq!( import_map.imports.get("https://good/").unwrap()[0], "https://base.example/https/".to_string() ); assert_eq!( import_map.imports.get("file:///good").unwrap()[0], "https://base.example/file".to_string() ); assert_eq!( import_map.imports.get("http://good/").unwrap()[0], "https://base.example/http/".to_string() ); assert_eq!( import_map.imports.get("import:bad").unwrap()[0], "https://base.example/import".to_string() ); assert_eq!( import_map.imports.get("mailto:bad").unwrap()[0], "https://base.example/mailto".to_string() ); assert_eq!( import_map.imports.get("javascript:bad").unwrap()[0], "https://base.example/javascript".to_string() ); assert_eq!( import_map.imports.get("wss:bad").unwrap()[0], "https://base.example/wss".to_string() ); assert_eq!( import_map.imports.get("about:bad").unwrap()[0], "https://base.example/about".to_string() ); assert_eq!( import_map.imports.get("blob:bad").unwrap()[0], "https://base.example/blob".to_string() ); assert_eq!( import_map.imports.get("data:bad").unwrap()[0], "https://base.example/data".to_string() ); // Should parse absolute URLs, treating unparseable ones as bare specifiers.. let json_map = r#"{ "imports": { "https://ex ample.org/": "/unparseable1/", "https://example.com:demo": "/unparseable2", "http://[www.example.com]/": "/unparseable3/", "https:example.org": "/invalidButParseable1/", "https://///example.com///": "/invalidButParseable2/", "https://example.net": "/prettyNormal/", "https://ex%41mple.com/": "/percentDecoding/", "https://example.com/%41": "/noPercentDecoding" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("https://ex ample.org/").unwrap()[0], "https://base.example/unparseable1/".to_string() ); assert_eq!( import_map.imports.get("https://example.com:demo").unwrap()[0], "https://base.example/unparseable2".to_string() ); assert_eq!( import_map.imports.get("http://[www.example.com]/").unwrap()[0], "https://base.example/unparseable3/".to_string() ); assert_eq!( import_map.imports.get("https://example.org/").unwrap()[0], "https://base.example/invalidButParseable1/".to_string() ); assert_eq!( import_map.imports.get("https://example.com///").unwrap()[0], "https://base.example/invalidButParseable2/".to_string() ); assert_eq!( import_map.imports.get("https://example.net/").unwrap()[0], "https://base.example/prettyNormal/".to_string() ); assert_eq!( import_map.imports.get("https://example.com/").unwrap()[0], "https://base.example/percentDecoding/".to_string() ); assert_eq!( import_map.imports.get("https://example.com/%41").unwrap()[0], "https://base.example/noPercentDecoding".to_string() ); } #[test] fn parse_scope_keys_relative() { // Should work with no prefix.. let json_map = r#"{ "scopes": { "foo": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/foo")); // Should work with ./, ../, and / prefixes.. let json_map = r#"{ "scopes": { "./foo": {}, "../foo": {}, "/foo": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/foo")); assert!(import_map .scopes .contains_key("https://base.example/path1/foo")); assert!(import_map.scopes.contains_key("https://base.example/foo")); // Should work with /s, ?s, and #s.. let json_map = r#"{ "scopes": { "foo/bar?baz#qux": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/foo/bar?baz#qux")); // Should work with an empty string scope key.. let json_map = r#"{ "scopes": { "": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/path3")); // Should work with / suffixes.. let json_map = r#"{ "scopes": { "foo/": {}, "./foo/": {}, "../foo/": {}, "/foo/": {}, "/foo//": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/foo/")); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/foo/")); assert!(import_map .scopes .contains_key("https://base.example/path1/foo/")); assert!(import_map.scopes.contains_key("https://base.example/foo/")); assert!(import_map.scopes.contains_key("https://base.example/foo//")); // Should deduplicate based on URL parsing rules.. let json_map = r#"{ "scopes": { "foo/\\": {}, "foo//": {}, "foo\\\\": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map .scopes .contains_key("https://base.example/path1/path2/foo//")); assert_eq!(import_map.scopes.len(), 1); } #[test] fn parse_scope_keys_absolute() { // Should only accept absolute URL scope keys with fetch schemes.. let json_map = r#"{ "scopes": { "http://good/": {}, "https://good/": {}, "file:///good": {}, "about:bad": {}, "blob:bad": {}, "data:bad": {}, "filesystem:bad": {}, "ftp://bad/": {}, "import:bad": {}, "mailto:bad": {}, "javascript:bad": {}, "wss:bad": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map.scopes.contains_key("http://good/")); assert!(import_map.scopes.contains_key("https://good/")); assert!(import_map.scopes.contains_key("file:///good")); assert_eq!(import_map.scopes.len(), 3); // Should parse absolute URL scope keys, ignoring unparseable ones.. let json_map = r#"{ "scopes": { "https://ex ample.org/": {}, "https://example.com:demo": {}, "http://[www.example.com]/": {}, "https:example.org": {}, "https://///example.com///": {}, "https://example.net": {}, "https://ex%41mple.com/foo/": {}, "https://example.com/%41": {} } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); // tricky case! remember we have a base URL assert!(import_map .scopes .contains_key("https://base.example/path1/path2/example.org")); assert!(import_map.scopes.contains_key("https://example.com///")); assert!(import_map.scopes.contains_key("https://example.net/")); assert!(import_map.scopes.contains_key("https://example.com/foo/")); assert!(import_map.scopes.contains_key("https://example.com/%41")); assert_eq!(import_map.scopes.len(), 5); } #[test] fn parse_addresses_relative_url_like() { // Should accept strings prefixed with ./, ../, or /.. let json_map = r#"{ "imports": { "dotSlash": "./foo", "dotDotSlash": "../foo", "slash": "/foo" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("dotSlash").unwrap(), &vec!["https://base.example/path1/path2/foo".to_string()] ); assert_eq!( import_map.imports.get("dotDotSlash").unwrap(), &vec!["https://base.example/path1/foo".to_string()] ); assert_eq!( import_map.imports.get("slash").unwrap(), &vec!["https://base.example/foo".to_string()] ); // Should accept the literal strings ./, ../, or / with no suffix.. let json_map = r#"{ "imports": { "dotSlash": "./", "dotDotSlash": "../", "slash": "/" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("dotSlash").unwrap(), &vec!["https://base.example/path1/path2/".to_string()] ); assert_eq!( import_map.imports.get("dotDotSlash").unwrap(), &vec!["https://base.example/path1/".to_string()] ); assert_eq!( import_map.imports.get("slash").unwrap(), &vec!["https://base.example/".to_string()] ); // Should ignore percent-encoded variants of ./, ../, or /.. let json_map = r#"{ "imports": { "dotSlash1": "%2E/", "dotDotSlash1": "%2E%2E/", "dotSlash2": ".%2F", "dotDotSlash2": "..%2F", "slash2": "%2F", "dotSlash3": "%2E%2F", "dotDotSlash3": "%2E%2E%2F" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map.imports.get("dotSlash1").unwrap().is_empty()); assert!(import_map.imports.get("dotDotSlash1").unwrap().is_empty()); assert!(import_map.imports.get("dotSlash2").unwrap().is_empty()); assert!(import_map.imports.get("dotDotSlash2").unwrap().is_empty()); assert!(import_map.imports.get("slash2").unwrap().is_empty()); assert!(import_map.imports.get("dotSlash3").unwrap().is_empty()); assert!(import_map.imports.get("dotDotSlash3").unwrap().is_empty()); } #[test] fn parse_addresses_absolute_with_fetch_schemes() { // Should only accept absolute URL addresses with fetch schemes.. let json_map = r#"{ "imports": { "http": "http://good/", "https": "https://good/", "file": "file:///good", "about": "about:bad", "blob": "blob:bad", "data": "data:bad", "filesystem": "filesystem:bad", "ftp": "ftp://good/", "import": "import:bad", "mailto": "mailto:bad", "javascript": "javascript:bad", "wss": "wss:bad" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("file").unwrap(), &vec!["file:///good".to_string()] ); assert_eq!( import_map.imports.get("http").unwrap(), &vec!["http://good/".to_string()] ); assert_eq!( import_map.imports.get("https").unwrap(), &vec!["https://good/".to_string()] ); assert!(import_map.imports.get("about").unwrap().is_empty()); assert!(import_map.imports.get("blob").unwrap().is_empty()); assert!(import_map.imports.get("data").unwrap().is_empty()); assert!(import_map.imports.get("filesystem").unwrap().is_empty()); assert!(import_map.imports.get("ftp").unwrap().is_empty()); assert!(import_map.imports.get("import").unwrap().is_empty()); assert!(import_map.imports.get("mailto").unwrap().is_empty()); assert!(import_map.imports.get("javascript").unwrap().is_empty()); assert!(import_map.imports.get("wss").unwrap().is_empty()); } #[test] fn parse_addresses_absolute_with_fetch_schemes_arrays() { // Should only accept absolute URL addresses with fetch schemes inside arrays.. let json_map = r#"{ "imports": { "http": ["http://good/"], "https": ["https://good/"], "file": ["file:///good"], "about": ["about:bad"], "blob": ["blob:bad"], "data": ["data:bad"], "filesystem": ["filesystem:bad"], "ftp": ["ftp://good/"], "import": ["import:bad"], "mailto": ["mailto:bad"], "javascript": ["javascript:bad"], "wss": ["wss:bad"] } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("file").unwrap(), &vec!["file:///good".to_string()] ); assert_eq!( import_map.imports.get("http").unwrap(), &vec!["http://good/".to_string()] ); assert_eq!( import_map.imports.get("https").unwrap(), &vec!["https://good/".to_string()] ); assert!(import_map.imports.get("about").unwrap().is_empty()); assert!(import_map.imports.get("blob").unwrap().is_empty()); assert!(import_map.imports.get("data").unwrap().is_empty()); assert!(import_map.imports.get("filesystem").unwrap().is_empty()); assert!(import_map.imports.get("ftp").unwrap().is_empty()); assert!(import_map.imports.get("import").unwrap().is_empty()); assert!(import_map.imports.get("mailto").unwrap().is_empty()); assert!(import_map.imports.get("javascript").unwrap().is_empty()); assert!(import_map.imports.get("wss").unwrap().is_empty()); } #[test] fn parse_addresses_unparseable() { // Should parse absolute URLs, ignoring unparseable ones.. let json_map = r#"{ "imports": { "unparseable1": "https://ex ample.org/", "unparseable2": "https://example.com:demo", "unparseable3": "http://[www.example.com]/", "invalidButParseable1": "https:example.org", "invalidButParseable2": "https://///example.com///", "prettyNormal": "https://example.net", "percentDecoding": "https://ex%41mple.com/", "noPercentDecoding": "https://example.com/%41" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("invalidButParseable1").unwrap(), &vec!["https://example.org/".to_string()] ); assert_eq!( import_map.imports.get("invalidButParseable2").unwrap(), &vec!["https://example.com///".to_string()] ); assert_eq!( import_map.imports.get("prettyNormal").unwrap(), &vec!["https://example.net/".to_string()] ); assert_eq!( import_map.imports.get("percentDecoding").unwrap(), &vec!["https://example.com/".to_string()] ); assert_eq!( import_map.imports.get("noPercentDecoding").unwrap(), &vec!["https://example.com/%41".to_string()] ); assert!(import_map.imports.get("unparseable1").unwrap().is_empty()); assert!(import_map.imports.get("unparseable2").unwrap().is_empty()); assert!(import_map.imports.get("unparseable3").unwrap().is_empty()); } #[test] fn parse_addresses_unparseable_arrays() { // Should parse absolute URLs, ignoring unparseable ones inside arrays.. let json_map = r#"{ "imports": { "unparseable1": ["https://ex ample.org/"], "unparseable2": ["https://example.com:demo"], "unparseable3": ["http://[www.example.com]/"], "invalidButParseable1": ["https:example.org"], "invalidButParseable2": ["https://///example.com///"], "prettyNormal": ["https://example.net"], "percentDecoding": ["https://ex%41mple.com/"], "noPercentDecoding": ["https://example.com/%41"] } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("invalidButParseable1").unwrap(), &vec!["https://example.org/".to_string()] ); assert_eq!( import_map.imports.get("invalidButParseable2").unwrap(), &vec!["https://example.com///".to_string()] ); assert_eq!( import_map.imports.get("prettyNormal").unwrap(), &vec!["https://example.net/".to_string()] ); assert_eq!( import_map.imports.get("percentDecoding").unwrap(), &vec!["https://example.com/".to_string()] ); assert_eq!( import_map.imports.get("noPercentDecoding").unwrap(), &vec!["https://example.com/%41".to_string()] ); assert!(import_map.imports.get("unparseable1").unwrap().is_empty()); assert!(import_map.imports.get("unparseable2").unwrap().is_empty()); assert!(import_map.imports.get("unparseable3").unwrap().is_empty()); } #[test] fn parse_addresses_mismatched_trailing_slashes() { // Should parse absolute URLs, ignoring unparseable ones inside arrays.. let json_map = r#"{ "imports": { "trailer/": "/notrailer" } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map.imports.get("trailer/").unwrap().is_empty()); // TODO: I'd be good to assert that warning was shown } #[test] fn parse_addresses_mismatched_trailing_slashes_array() { // Should warn for a mismatch alone in an array.. let json_map = r#"{ "imports": { "trailer/": ["/notrailer"] } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert!(import_map.imports.get("trailer/").unwrap().is_empty()); // TODO: I'd be good to assert that warning was shown } #[test] fn parse_addresses_mismatched_trailing_slashes_with_nonmismatched_array() { // Should warn for a mismatch alone in an array.. let json_map = r#"{ "imports": { "trailer/": ["/atrailer/", "/notrailer"] } }"#; let import_map = ImportMap::from_json("https://base.example/path1/path2/path3", json_map) .unwrap(); assert_eq!( import_map.imports.get("trailer/").unwrap(), &vec!["https://base.example/atrailer/".to_string()] ); // TODO: I'd be good to assert that warning was shown } #[test] fn parse_addresses_other_invalid() { // Should ignore unprefixed strings that are not absolute URLs. for bad in &["bar", "\\bar", "~bar", "#bar", "?bar"] { let json_map = json!({ "imports": { "foo": bad } }); let import_map = ImportMap::from_json( "https://base.example/path1/path2/path3", &json_map.to_string(), ) .unwrap(); assert!(import_map.imports.get("foo").unwrap().is_empty()); } } fn get_empty_import_map() -> ImportMap { ImportMap { base_url: "https://example.com/app/main.ts".to_string(), imports: IndexMap::new(), scopes: IndexMap::new(), } } fn assert_resolve( result: Result, ImportMapError>, expected_url: &str, ) { let maybe_url = result .unwrap_or_else(|err| panic!("ImportMap::resolve failed: {:?}", err)); let resolved_url = maybe_url.unwrap_or_else(|| panic!("Unexpected None resolved URL")); assert_eq!(resolved_url, expected_url.to_string()); } #[test] fn resolve_unmapped_relative_specifiers() { let referrer_url = "https://example.com/js/script.ts"; let import_map = get_empty_import_map(); // Should resolve ./ specifiers as URLs. assert_resolve( import_map.resolve("./foo", referrer_url), "https://example.com/js/foo", ); assert_resolve( import_map.resolve("./foo/bar", referrer_url), "https://example.com/js/foo/bar", ); assert_resolve( import_map.resolve("./foo/../bar", referrer_url), "https://example.com/js/bar", ); assert_resolve( import_map.resolve("./foo/../../bar", referrer_url), "https://example.com/bar", ); // Should resolve ../ specifiers as URLs. assert_resolve( import_map.resolve("../foo", referrer_url), "https://example.com/foo", ); assert_resolve( import_map.resolve("../foo/bar", referrer_url), "https://example.com/foo/bar", ); assert_resolve( import_map.resolve("../../../foo/bar", referrer_url), "https://example.com/foo/bar", ); } #[test] fn resolve_unmapped_absolute_specifiers() { let referrer_url = "https://example.com/js/script.ts"; let import_map = get_empty_import_map(); // Should resolve / specifiers as URLs. assert_resolve( import_map.resolve("/foo", referrer_url), "https://example.com/foo", ); assert_resolve( import_map.resolve("/foo/bar", referrer_url), "https://example.com/foo/bar", ); assert_resolve( import_map.resolve("../../foo/bar", referrer_url), "https://example.com/foo/bar", ); assert_resolve( import_map.resolve("/../foo/../bar", referrer_url), "https://example.com/bar", ); // Should parse absolute fetch-scheme URLs. assert_resolve( import_map.resolve("https://example.net", referrer_url), "https://example.net/", ); assert_resolve( import_map.resolve("https://ex%41mple.com/", referrer_url), "https://example.com/", ); assert_resolve( import_map.resolve("https:example.org", referrer_url), "https://example.org/", ); assert_resolve( import_map.resolve("https://///example.com///", referrer_url), "https://example.com///", ); } #[test] fn resolve_unmapped_bad_specifiers() { let referrer_url = "https://example.com/js/script.ts"; let import_map = get_empty_import_map(); // Should fail for absolute non-fetch-scheme URLs. assert!(import_map.resolve("about:good", referrer_url).is_err()); assert!(import_map.resolve("mailto:bad", referrer_url).is_err()); assert!(import_map.resolve("import:bad", referrer_url).is_err()); assert!(import_map.resolve("javascript:bad", referrer_url).is_err()); assert!(import_map.resolve("wss:bad", referrer_url).is_err()); // Should fail for string not parseable as absolute URLs and not starting with ./, ../ or /. assert!(import_map.resolve("foo", referrer_url).is_err()); assert!(import_map.resolve("\\foo", referrer_url).is_err()); assert!(import_map.resolve(":foo", referrer_url).is_err()); assert!(import_map.resolve("@foo", referrer_url).is_err()); assert!(import_map.resolve("%2E/foo", referrer_url).is_err()); assert!(import_map.resolve("%2E%2Efoo", referrer_url).is_err()); assert!(import_map.resolve(".%2Efoo", referrer_url).is_err()); assert!(import_map .resolve("https://ex ample.org", referrer_url) .is_err()); assert!(import_map .resolve("https://example.org:deno", referrer_url) .is_err()); assert!(import_map .resolve("https://[example.org]", referrer_url) .is_err()); } #[test] fn resolve_imports_mapped() { let base_url = "https://example.com/app/main.ts"; let referrer_url = "https://example.com/js/script.ts"; // Should fail when mapping is to an empty array. let json_map = r#"{ "imports": { "moment": null, "lodash": [] } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); assert!(import_map.resolve("moment", referrer_url).is_err()); assert!(import_map.resolve("lodash", referrer_url).is_err()); } #[test] fn resolve_imports_package_like_modules() { let base_url = "https://example.com/app/main.ts"; let referrer_url = "https://example.com/js/script.ts"; let json_map = r#"{ "imports": { "moment": "/deps/moment/src/moment.js", "moment/": "/deps/moment/src/", "lodash-dot": "./deps/lodash-es/lodash.js", "lodash-dot/": "./deps/lodash-es/", "lodash-dotdot": "../deps/lodash-es/lodash.js", "lodash-dotdot/": "../deps/lodash-es/", "nowhere/": [] } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); // Should work for package main modules. assert_resolve( import_map.resolve("moment", referrer_url), "https://example.com/deps/moment/src/moment.js", ); assert_resolve( import_map.resolve("lodash-dot", referrer_url), "https://example.com/app/deps/lodash-es/lodash.js", ); assert_resolve( import_map.resolve("lodash-dotdot", referrer_url), "https://example.com/deps/lodash-es/lodash.js", ); // Should work for package submodules. assert_resolve( import_map.resolve("moment/foo", referrer_url), "https://example.com/deps/moment/src/foo", ); assert_resolve( import_map.resolve("lodash-dot/foo", referrer_url), "https://example.com/app/deps/lodash-es/foo", ); assert_resolve( import_map.resolve("lodash-dotdot/foo", referrer_url), "https://example.com/deps/lodash-es/foo", ); // Should work for package names that end in a slash. assert_resolve( import_map.resolve("moment/", referrer_url), "https://example.com/deps/moment/src/", ); // Should fail for package modules that are not declared. assert!(import_map.resolve("underscore/", referrer_url).is_err()); assert!(import_map.resolve("underscore/foo", referrer_url).is_err()); // Should fail for package submodules that map to nowhere. assert!(import_map.resolve("nowhere/foo", referrer_url).is_err()); } #[test] fn resolve_imports_tricky_specifiers() { let base_url = "https://example.com/app/main.ts"; let referrer_url = "https://example.com/js/script.ts"; let json_map = r#"{ "imports": { "package/withslash": "/deps/package-with-slash/index.mjs", "not-a-package": "/lib/not-a-package.mjs", ".": "/lib/dot.mjs", "..": "/lib/dotdot.mjs", "..\\\\": "/lib/dotdotbackslash.mjs", "%2E": "/lib/percent2e.mjs", "%2F": "/lib/percent2f.mjs" } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); // Should work for explicitly-mapped specifiers that happen to have a slash. assert_resolve( import_map.resolve("package/withslash", referrer_url), "https://example.com/deps/package-with-slash/index.mjs", ); // Should work when the specifier has punctuation. assert_resolve( import_map.resolve(".", referrer_url), "https://example.com/lib/dot.mjs", ); assert_resolve( import_map.resolve("..", referrer_url), "https://example.com/lib/dotdot.mjs", ); assert_resolve( import_map.resolve("..\\\\", referrer_url), "https://example.com/lib/dotdotbackslash.mjs", ); assert_resolve( import_map.resolve("%2E", referrer_url), "https://example.com/lib/percent2e.mjs", ); assert_resolve( import_map.resolve("%2F", referrer_url), "https://example.com/lib/percent2f.mjs", ); // Should fail for attempting to get a submodule of something not declared with a trailing slash. assert!(import_map .resolve("not-a-package/foo", referrer_url) .is_err()); } #[test] fn resolve_imports_url_like_specifier() { let base_url = "https://example.com/app/main.ts"; let referrer_url = "https://example.com/js/script.ts"; let json_map = r#"{ "imports": { "/node_modules/als-polyfill/index.mjs": "std:kv-storage", "/lib/foo.mjs": "./more/bar.mjs", "./dotrelative/foo.mjs": "/lib/dot.mjs", "../dotdotrelative/foo.mjs": "/lib/dotdot.mjs", "/lib/no.mjs": null, "./dotrelative/no.mjs": [], "/": "/lib/slash-only/", "./": "/lib/dotslash-only/", "/test/": "/lib/url-trailing-slash/", "./test/": "/lib/url-trailing-slash-dot/", "/test": "/lib/test1.mjs", "../test": "/lib/test2.mjs" } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); // Should remap to other URLs. assert_resolve( import_map.resolve("https://example.com/lib/foo.mjs", referrer_url), "https://example.com/app/more/bar.mjs", ); assert_resolve( import_map.resolve("https://///example.com/lib/foo.mjs", referrer_url), "https://example.com/app/more/bar.mjs", ); assert_resolve( import_map.resolve("/lib/foo.mjs", referrer_url), "https://example.com/app/more/bar.mjs", ); assert_resolve( import_map .resolve("https://example.com/app/dotrelative/foo.mjs", referrer_url), "https://example.com/lib/dot.mjs", ); assert_resolve( import_map.resolve("../app/dotrelative/foo.mjs", referrer_url), "https://example.com/lib/dot.mjs", ); assert_resolve( import_map .resolve("https://example.com/dotdotrelative/foo.mjs", referrer_url), "https://example.com/lib/dotdot.mjs", ); assert_resolve( import_map.resolve("../dotdotrelative/foo.mjs", referrer_url), "https://example.com/lib/dotdot.mjs", ); // Should fail for URLs that remap to empty arrays. assert!(import_map .resolve("https://example.com/lib/no.mjs", referrer_url) .is_err()); assert!(import_map.resolve("/lib/no.mjs", referrer_url).is_err()); assert!(import_map.resolve("../lib/no.mjs", referrer_url).is_err()); assert!(import_map .resolve("https://example.com/app/dotrelative/no.mjs", referrer_url) .is_err()); assert!(import_map .resolve("/app/dotrelative/no.mjs", referrer_url) .is_err()); assert!(import_map .resolve("../app/dotrelative/no.mjs", referrer_url) .is_err()); // Should remap URLs that are just composed from / and .. assert_resolve( import_map.resolve("https://example.com/", referrer_url), "https://example.com/lib/slash-only/", ); assert_resolve( import_map.resolve("/", referrer_url), "https://example.com/lib/slash-only/", ); assert_resolve( import_map.resolve("../", referrer_url), "https://example.com/lib/slash-only/", ); assert_resolve( import_map.resolve("https://example.com/app/", referrer_url), "https://example.com/lib/dotslash-only/", ); assert_resolve( import_map.resolve("/app/", referrer_url), "https://example.com/lib/dotslash-only/", ); assert_resolve( import_map.resolve("../app/", referrer_url), "https://example.com/lib/dotslash-only/", ); // Should remap URLs that are prefix-matched by keys with trailing slashes. assert_resolve( import_map.resolve("/test/foo.mjs", referrer_url), "https://example.com/lib/url-trailing-slash/foo.mjs", ); assert_resolve( import_map.resolve("https://example.com/app/test/foo.mjs", referrer_url), "https://example.com/lib/url-trailing-slash-dot/foo.mjs", ); // Should use the last entry's address when URL-like specifiers parse to the same absolute URL. // // NOTE: this works properly because of "preserve_order" feature flag to "serde_json" crate assert_resolve( import_map.resolve("/test", referrer_url), "https://example.com/lib/test2.mjs", ); } #[test] fn resolve_imports_overlapping_entities_with_trailing_slashes() { let base_url = "https://example.com/app/main.ts"; let referrer_url = "https://example.com/js/script.ts"; // Should favor the most-specific key (no empty arrays). { let json_map = r#"{ "imports": { "a": "/1", "a/": "/2/", "a/b": "/3", "a/b/": "/4/" } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); assert_resolve( import_map.resolve("a", referrer_url), "https://example.com/1", ); assert_resolve( import_map.resolve("a/", referrer_url), "https://example.com/2/", ); assert_resolve( import_map.resolve("a/b", referrer_url), "https://example.com/3", ); assert_resolve( import_map.resolve("a/b/", referrer_url), "https://example.com/4/", ); assert_resolve( import_map.resolve("a/b/c", referrer_url), "https://example.com/4/c", ); } // Should favor the most-specific key when empty arrays are involved for less-specific keys. { let json_map = r#"{ "imports": { "a": [], "a/": [], "a/b": "/3", "a/b/": "/4/" } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); assert!(import_map.resolve("a", referrer_url).is_err()); assert!(import_map.resolve("a/", referrer_url).is_err()); assert!(import_map.resolve("a/x", referrer_url).is_err()); assert_resolve( import_map.resolve("a/b", referrer_url), "https://example.com/3", ); assert_resolve( import_map.resolve("a/b/", referrer_url), "https://example.com/4/", ); assert_resolve( import_map.resolve("a/b/c", referrer_url), "https://example.com/4/c", ); assert!(import_map.resolve("a/x/c", referrer_url).is_err()); } } #[test] fn resolve_scopes_map_to_empty_array() { let base_url = "https://example.com/app/main.ts"; let referrer_url = "https://example.com/js"; let json_map = r#"{ "scopes": { "/js/": { "moment": "null", "lodash": [] } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); assert!(import_map.resolve("moment", referrer_url).is_err()); assert!(import_map.resolve("lodash", referrer_url).is_err()); } #[test] fn resolve_scopes_exact_vs_prefix_matching() { let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "scopes": { "/js": { "moment": "/only-triggered-by-exact/moment", "moment/": "/only-triggered-by-exact/moment/" }, "/js/": { "moment": "/triggered-by-any-subpath/moment", "moment/": "/triggered-by-any-subpath/moment/" } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); let js_non_dir = "https://example.com/js"; let js_in_dir = "https://example.com/js/app.mjs"; let with_js_prefix = "https://example.com/jsiscool"; assert_resolve( import_map.resolve("moment", js_non_dir), "https://example.com/only-triggered-by-exact/moment", ); assert_resolve( import_map.resolve("moment/foo", js_non_dir), "https://example.com/only-triggered-by-exact/moment/foo", ); assert_resolve( import_map.resolve("moment", js_in_dir), "https://example.com/triggered-by-any-subpath/moment", ); assert_resolve( import_map.resolve("moment/foo", js_in_dir), "https://example.com/triggered-by-any-subpath/moment/foo", ); assert!(import_map.resolve("moment", with_js_prefix).is_err()); assert!(import_map.resolve("moment/foo", with_js_prefix).is_err()); } #[test] fn resolve_scopes_only_exact_in_map() { let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "scopes": { "/js": { "moment": "/only-triggered-by-exact/moment", "moment/": "/only-triggered-by-exact/moment/" } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); // Should match correctly when only an exact match is in the map. let js_non_dir = "https://example.com/js"; let js_in_dir = "https://example.com/js/app.mjs"; let with_js_prefix = "https://example.com/jsiscool"; assert_resolve( import_map.resolve("moment", js_non_dir), "https://example.com/only-triggered-by-exact/moment", ); assert_resolve( import_map.resolve("moment/foo", js_non_dir), "https://example.com/only-triggered-by-exact/moment/foo", ); assert!(import_map.resolve("moment", js_in_dir).is_err()); assert!(import_map.resolve("moment/foo", js_in_dir).is_err()); assert!(import_map.resolve("moment", with_js_prefix).is_err()); assert!(import_map.resolve("moment/foo", with_js_prefix).is_err()); } #[test] fn resolve_scopes_only_prefix_in_map() { let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "scopes": { "/js/": { "moment": "/triggered-by-any-subpath/moment", "moment/": "/triggered-by-any-subpath/moment/" } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); // Should match correctly when only a prefix match is in the map. let js_non_dir = "https://example.com/js"; let js_in_dir = "https://example.com/js/app.mjs"; let with_js_prefix = "https://example.com/jsiscool"; assert!(import_map.resolve("moment", js_non_dir).is_err()); assert!(import_map.resolve("moment/foo", js_non_dir).is_err()); assert_resolve( import_map.resolve("moment", js_in_dir), "https://example.com/triggered-by-any-subpath/moment", ); assert_resolve( import_map.resolve("moment/foo", js_in_dir), "https://example.com/triggered-by-any-subpath/moment/foo", ); assert!(import_map.resolve("moment", with_js_prefix).is_err()); assert!(import_map.resolve("moment/foo", with_js_prefix).is_err()); } #[test] fn resolve_scopes_package_like() { let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "imports": { "moment": "/node_modules/moment/src/moment.js", "moment/": "/node_modules/moment/src/", "lodash-dot": "./node_modules/lodash-es/lodash.js", "lodash-dot/": "./node_modules/lodash-es/", "lodash-dotdot": "../node_modules/lodash-es/lodash.js", "lodash-dotdot/": "../node_modules/lodash-es/" }, "scopes": { "/": { "moment": "/node_modules_3/moment/src/moment.js", "vue": "/node_modules_3/vue/dist/vue.runtime.esm.js" }, "/js/": { "lodash-dot": "./node_modules_2/lodash-es/lodash.js", "lodash-dot/": "./node_modules_2/lodash-es/", "lodash-dotdot": "../node_modules_2/lodash-es/lodash.js", "lodash-dotdot/": "../node_modules_2/lodash-es/" } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); // Should match correctly when only a prefix match is in the map. let js_in_dir = "https://example.com/js/app.mjs"; let top_level = "https://example.com/app.mjs"; // Should resolve scoped. assert_resolve( import_map.resolve("lodash-dot", js_in_dir), "https://example.com/app/node_modules_2/lodash-es/lodash.js", ); assert_resolve( import_map.resolve("lodash-dotdot", js_in_dir), "https://example.com/node_modules_2/lodash-es/lodash.js", ); assert_resolve( import_map.resolve("lodash-dot/foo", js_in_dir), "https://example.com/app/node_modules_2/lodash-es/foo", ); assert_resolve( import_map.resolve("lodash-dotdot/foo", js_in_dir), "https://example.com/node_modules_2/lodash-es/foo", ); // Should apply best scope match. assert_resolve( import_map.resolve("moment", top_level), "https://example.com/node_modules_3/moment/src/moment.js", ); assert_resolve( import_map.resolve("moment", js_in_dir), "https://example.com/node_modules_3/moment/src/moment.js", ); assert_resolve( import_map.resolve("vue", js_in_dir), "https://example.com/node_modules_3/vue/dist/vue.runtime.esm.js", ); // Should fallback to "imports". assert_resolve( import_map.resolve("moment/foo", top_level), "https://example.com/node_modules/moment/src/foo", ); assert_resolve( import_map.resolve("moment/foo", js_in_dir), "https://example.com/node_modules/moment/src/foo", ); assert_resolve( import_map.resolve("lodash-dot", top_level), "https://example.com/app/node_modules/lodash-es/lodash.js", ); assert_resolve( import_map.resolve("lodash-dotdot", top_level), "https://example.com/node_modules/lodash-es/lodash.js", ); assert_resolve( import_map.resolve("lodash-dot/foo", top_level), "https://example.com/app/node_modules/lodash-es/foo", ); assert_resolve( import_map.resolve("lodash-dotdot/foo", top_level), "https://example.com/node_modules/lodash-es/foo", ); // Should still fail for package-like specifiers that are not declared. assert!(import_map.resolve("underscore/", js_in_dir).is_err()); assert!(import_map.resolve("underscore/foo", js_in_dir).is_err()); } #[test] fn resolve_scopes_inheritance() { // https://github.com/WICG/import-maps#scope-inheritance let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "imports": { "a": "/a-1.mjs", "b": "/b-1.mjs", "c": "/c-1.mjs" }, "scopes": { "/scope2/": { "a": "/a-2.mjs" }, "/scope2/scope3/": { "b": "/b-3.mjs" } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); let scope_1_url = "https://example.com/scope1/foo.mjs"; let scope_2_url = "https://example.com/scope2/foo.mjs"; let scope_3_url = "https://example.com/scope2/scope3/foo.mjs"; // Should fall back to "imports" when none match. assert_resolve( import_map.resolve("a", scope_1_url), "https://example.com/a-1.mjs", ); assert_resolve( import_map.resolve("b", scope_1_url), "https://example.com/b-1.mjs", ); assert_resolve( import_map.resolve("c", scope_1_url), "https://example.com/c-1.mjs", ); // Should use a direct scope override. assert_resolve( import_map.resolve("a", scope_2_url), "https://example.com/a-2.mjs", ); assert_resolve( import_map.resolve("b", scope_2_url), "https://example.com/b-1.mjs", ); assert_resolve( import_map.resolve("c", scope_2_url), "https://example.com/c-1.mjs", ); // Should use an indirect scope override. assert_resolve( import_map.resolve("a", scope_3_url), "https://example.com/a-2.mjs", ); assert_resolve( import_map.resolve("b", scope_3_url), "https://example.com/b-3.mjs", ); assert_resolve( import_map.resolve("c", scope_3_url), "https://example.com/c-1.mjs", ); } #[test] fn resolve_scopes_relative_url_keys() { // https://github.com/WICG/import-maps#scope-inheritance let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "imports": { "a": "/a-1.mjs", "b": "/b-1.mjs", "c": "/c-1.mjs" }, "scopes": { "": { "a": "/a-empty-string.mjs" }, "./": { "b": "/b-dot-slash.mjs" }, "../": { "c": "/c-dot-dot-slash.mjs" } } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); let in_same_dir_as_map = "https://example.com/app/foo.mjs"; let in_dir_above_map = "https://example.com/foo.mjs"; // Should resolve an empty string scope using the import map URL. assert_resolve( import_map.resolve("a", base_url), "https://example.com/a-empty-string.mjs", ); assert_resolve( import_map.resolve("a", in_same_dir_as_map), "https://example.com/a-1.mjs", ); // Should resolve a ./ scope using the import map URL's directory. assert_resolve( import_map.resolve("b", base_url), "https://example.com/b-dot-slash.mjs", ); assert_resolve( import_map.resolve("b", in_same_dir_as_map), "https://example.com/b-dot-slash.mjs", ); // Should resolve a ../ scope using the import map URL's directory. assert_resolve( import_map.resolve("c", base_url), "https://example.com/c-dot-dot-slash.mjs", ); assert_resolve( import_map.resolve("c", in_same_dir_as_map), "https://example.com/c-dot-dot-slash.mjs", ); assert_resolve( import_map.resolve("c", in_dir_above_map), "https://example.com/c-dot-dot-slash.mjs", ); } #[test] fn cant_resolve_to_built_in() { let base_url = "https://example.com/app/main.ts"; let import_map = ImportMap::from_json(base_url, "{}").unwrap(); assert!(import_map.resolve("std:blank", base_url).is_err()); } #[test] fn resolve_builtins_remap() { let base_url = "https://example.com/app/main.ts"; let json_map = r#"{ "imports": { "std:blank": "./blank.mjs", "std:none": "./none.mjs" } }"#; let import_map = ImportMap::from_json(base_url, json_map).unwrap(); assert_resolve( import_map.resolve("std:blank", base_url), "https://example.com/app/blank.mjs", ); assert_resolve( import_map.resolve("std:none", base_url), "https://example.com/app/none.mjs", ); } }