UNPKG

@jspm/import-map

Version:
648 lines (616 loc) 21.9 kB
import { baseUrl, rebase, isPlain, isURL, getCommonBase, resolve, sameOrigin, } from "./url.js"; import { alphabetize } from "./alphabetize.js"; let crypto; export interface IImportMap { imports?: Record<string, string>; scopes?: { [scope: string]: Record<string, string>; }; integrity?: { [url: string]: string } } export class ImportMap implements IImportMap { imports: Record<string, string> = Object.create(null); scopes: Record<string, Record<string, string>> = Object.create(null); integrity: Record<string, string> = Object.create(null); /** * The absolute URL of the import map, for determining relative resolutions * When using file:/// URLs this allows relative modules to be co-located */ mapUrl: URL; /** * The URL to use for root-level resolutions in the import map * If null, root resolutions are not resolved and instead left as-is * * By default, rootUrl is null unless the mapUrl is an http or https URL, * in which case it is taken to be the root of the mapUrl. */ rootUrl: URL | null; /** * Create a new import map instance * * @param opts import map options, can be an optional bag of { map?, mapUrl?, rootUrl? } or just a direct mapUrl */ constructor( opts: | { map?: IImportMap; mapUrl?: string | URL; rootUrl?: string | URL | null; } | string | URL ) { let { map, mapUrl = baseUrl, rootUrl, } = opts instanceof URL || typeof opts === "string" || typeof opts === "undefined" ? { mapUrl: opts, map: undefined, rootUrl: undefined } : opts; if (typeof mapUrl === "string") mapUrl = new URL(mapUrl); this.mapUrl = mapUrl; if ( rootUrl === undefined && (this.mapUrl.protocol === "http:" || this.mapUrl.protocol === "https:") ) rootUrl = new URL("/", this.mapUrl); else if (typeof rootUrl === "string") rootUrl = new URL(rootUrl); this.rootUrl = rootUrl || null; if (map) this.extend(map); } /** * Clones the import map * @returns Cloned import map */ clone() { return new ImportMap({ mapUrl: this.mapUrl, rootUrl: this.rootUrl }).extend( this ); } /** * Extends the import map mappings * @param map Import map to extend with * @param overrideScopes Set to true to have scopes be replacing instead of extending * @returns ImportMap for chaining */ extend(map: IImportMap, overrideScopes = false) { Object.assign(this.imports, map.imports); if (overrideScopes) { Object.assign(this.scopes, map.scopes); } else if (map.scopes) { for (const scope of Object.keys(map.scopes)) Object.assign( (this.scopes[scope] = this.scopes[scope] || Object.create(null)), map.scopes[scope] ); } Object.assign(this.integrity, map.integrity); this.rebase(); return this; } /** * Performs an alphanumerical sort on the import map imports and scopes * @returns ImportMap for chaining */ sort() { this.imports = alphabetize(this.imports); this.scopes = alphabetize(this.scopes); for (const scope of Object.keys(this.scopes)) this.scopes[scope] = alphabetize(this.scopes[scope]); this.integrity = alphabetize(this.integrity); return this; } /** * Set a specific entry in the import map * * @param name Specifier to set * @param target Target of the map * @param parent Optional parent scope * @returns ImportMap for chaining */ set(name: string, target: string, parent?: string) { if (!parent) { this.imports[name] = target; } else { this.scopes[parent] = this.scopes[parent] || Object.create(null); this.scopes[parent][name] = target; } return this; } /** * @param target URL target * @param integrity Integrity */ setIntegrity(target: string, integrity: string) { this.integrity[target] = integrity; const targetRebased = rebase(target, this.mapUrl, this.rootUrl); if (targetRebased !== target && this.integrity[targetRebased]) delete this.integrity[targetRebased]; if (targetRebased.startsWith('./') && target !== targetRebased.slice(2) && this.integrity[targetRebased.slice(2)]) delete this.integrity[targetRebased.slice(2)]; } /** * @param target URL target * @param integrity Integrity */ getIntegrity(target: string, integrity: string) { const targetResolved = resolve(target, this.mapUrl, this.rootUrl); if (this.integrity[targetResolved]) return this.integrity[targetResolved]; const targetRebased = rebase(targetResolved, this.mapUrl, this.rootUrl); if (this.integrity[targetRebased]) return this.integrity[targetRebased]; if (this.integrity[targetRebased.slice(2)]) return this.integrity[targetRebased.slice(2)]; } /** * Bulk replace URLs in the import map * Provide a URL ending in "/" to perform path replacements * * @param url {String} The URL to replace * @param newUrl {String} The URL to replace it with * @returns ImportMap for chaining */ replace(url: string, newUrl: string) { const replaceSubpaths = url.endsWith("/"); if (!isURL(url)) throw new Error("URL remapping only supports URLs"); const newRelPkgUrl = rebase(newUrl, this.mapUrl, this.rootUrl); if (this.imports[url]) { this.imports[newRelPkgUrl] = this.imports[url]; delete this.imports[url]; } if (replaceSubpaths) { for (const impt of Object.keys(this.imports)) { const target = this.imports[impt]; if (target.startsWith(url)) { this.imports[impt] = newRelPkgUrl + target.slice(url.length); } } } for (const scope of Object.keys(this.scopes)) { const scopeImports = this.scopes[scope]; const scopeUrl = resolve(scope, this.mapUrl, this.rootUrl); if ((replaceSubpaths && scopeUrl.startsWith(url)) || scopeUrl === url) { const newScope = newRelPkgUrl + scopeUrl.slice(url.length); delete this.scopes[scope]; this.scopes[newScope] = scopeImports; } for (const impt of Object.keys(scopeImports)) { const target = scopeImports[impt]; if ((replaceSubpaths && target.startsWith(url)) || target === url) scopeImports[impt] = newRelPkgUrl + target.slice(url.length); } } if (this.integrity[url]) { this.integrity[newRelPkgUrl] = this.integrity[url]; delete this.integrity[url]; } return this; } /** * Groups subpath mappings into path mappings when multiple exact subpaths * exist under the same path. * * For two mappings like { "base/a.js": "/a.js", "base/b.js": "/b.js" }, * these will be replaced with a single path mapping { "base/": "/" }. * Groupings are done throughout all import scopes individually. * * @returns ImportMap for chaining */ combineSubpaths() { // iterate possible bases and submappings, grouping bases greedily const combineSubpathMappings = (mappings: Record<string, string>) => { let outMappings: Record<string, string> = Object.create(null); for (let impt of Object.keys(mappings)) { const target = mappings[impt]; // Check if this import is already handled by an existing path mapping // If so, either merge with it or continue on let mapMatch; if (isPlain(impt)) { mapMatch = getMapMatch(impt, outMappings); } else { mapMatch = getMapMatch( (impt = rebase(impt, this.mapUrl, this.rootUrl)), outMappings ) || (this.rootUrl && getMapMatch( (impt = rebase(impt, this.mapUrl, null)), outMappings )) || undefined; } if ( mapMatch && impt.slice(mapMatch.length) === resolve(target, this.mapUrl, this.rootUrl).slice( resolve(outMappings[mapMatch], this.mapUrl, this.rootUrl).length ) ) { continue; } let newbase = false; const targetParts = mappings[impt].split("/"); const imptParts = impt.split("/"); for (let j = imptParts.length - 1; j > 0; j--) { const subpath = imptParts.slice(j).join("/"); const subpathTarget = targetParts .slice(targetParts.length - (imptParts.length - j)) .join("/"); if (subpath !== subpathTarget) { outMappings[impt] = mappings[impt]; break; } const base = imptParts.slice(0, j).join("/") + "/"; if (outMappings[base]) continue; const baseTarget = targetParts .slice(0, targetParts.length - (imptParts.length - j)) .join("/") + "/"; // Dedupe existing mappings against the new base to remove them // And if we dont dedupe against anything then dont perform this basing for (let impt of Object.keys(outMappings)) { const target = outMappings[impt]; let matches = false; if (isPlain(impt)) { matches = impt.startsWith(base); } else { matches = (impt = rebase(impt, this.mapUrl, this.rootUrl)).startsWith( base ) || (impt = rebase(impt, this.mapUrl, this.rootUrl)).startsWith( base ); } if ( matches && impt.slice(base.length) === resolve(target, this.mapUrl, this.rootUrl).slice( resolve(baseTarget, this.mapUrl, this.rootUrl).length ) ) { newbase = true; delete outMappings[impt]; } } if (newbase) { outMappings[base] = baseTarget; break; } } if (!newbase) outMappings[impt] = target; } return outMappings; }; // Only applies for scopes since "imports" are generally treated as // an authoritative entry point list for (const scope of Object.keys(this.scopes)) { this.scopes[scope] = combineSubpathMappings(this.scopes[scope]); } return this; } /** * Groups the import map scopes to shared URLs to reduce duplicate mappings. * * For two given scopes, "https://site.com/x/" and "https://site.com/y/", * a single scope will be constructed for "https://site.com/" including * their shared mappings, only retaining the scopes if they have differences. * * In the case where the scope is on the same origin as the mapUrl, the grouped * scope is determined based on determining the common baseline over all local scopes * * @returns ImportMap for chaining */ flatten() { // First, determine the common base for the local mappings if any let localScopemapUrl: string | null = null; for (const scope of Object.keys(this.scopes)) { const resolvedScope = resolve(scope, this.mapUrl, this.rootUrl); if (isURL(resolvedScope)) { const scopeUrl = new URL(resolvedScope); if (sameOrigin(scopeUrl, this.mapUrl)) { if (!localScopemapUrl) localScopemapUrl = scopeUrl.href; else localScopemapUrl = getCommonBase(scopeUrl.href, localScopemapUrl); } } else { if (!localScopemapUrl) localScopemapUrl = resolvedScope; else localScopemapUrl = getCommonBase(resolvedScope, localScopemapUrl); } } // for each scope, update its mappings to be in the shared base where possible const relativeLocalScopemapUrl = localScopemapUrl ? rebase(localScopemapUrl, this.mapUrl, this.rootUrl) : null; for (const scope of Object.keys(this.scopes)) { const scopeImports = this.scopes[scope]; let scopemapUrl: string; const resolvedScope = resolve(scope, this.mapUrl, this.rootUrl); if (isURL(resolvedScope)) { const scopeUrl = new URL(resolvedScope); if (sameOrigin(scopeUrl, this.mapUrl)) { scopemapUrl = relativeLocalScopemapUrl; } else { scopemapUrl = scopeUrl.protocol + "//" + scopeUrl.hostname + (scopeUrl.port ? ":" + scopeUrl.port : "") + "/"; } } else { scopemapUrl = relativeLocalScopemapUrl; } let scopeBase: Record<string, string> | null = this.scopes[scopemapUrl] || Object.create(null); if (scopeBase === scopeImports) scopeBase = null; let flattenedAll = true; for (const name of Object.keys(scopeImports)) { const target = scopeImports[name]; if ( this.imports[name] && resolve(this.imports[name], this.mapUrl, this.rootUrl) === resolve(target, this.mapUrl, this.rootUrl) ) { delete scopeImports[name]; } else if ( scopeBase && (!scopeBase[name] || resolve(scopeBase[name], this.mapUrl, this.rootUrl) === resolve(target, this.mapUrl, this.rootUrl)) ) { scopeBase[name] = rebase(target, this.mapUrl, this.rootUrl); delete scopeImports[name]; this.scopes[<string>scopemapUrl] = alphabetize(scopeBase); } else { flattenedAll = false; } } if (flattenedAll) delete this.scopes[scope]; } return this; } /** * Rebase the entire import map to a new mapUrl and rootUrl * * If the rootUrl is not provided, it will remain null if it was * already set to null. * * Otherwise, just like the constructor options, the rootUrl * will default to the mapUrl base if it is an http: or https: * scheme URL, and null otherwise keeping absolute URLs entirely * in-tact. * * @param mapUrl The new map URL to use * @param rootUrl The new root URL to use * @returns ImportMap for chaining */ rebase(mapUrl: URL | string = this.mapUrl, rootUrl?: URL | string | null) { if (typeof mapUrl === "string") mapUrl = new URL(mapUrl); if (rootUrl === undefined) { if (mapUrl.href === this.mapUrl.href) rootUrl = this.rootUrl; else rootUrl = this.rootUrl === null || (mapUrl.protocol !== "https:" && mapUrl.protocol !== "http:") ? null : new URL("/", mapUrl); } else if (typeof rootUrl === "string") rootUrl = new URL(rootUrl); let changedImportProps = false; for (const impt of Object.keys(this.imports)) { const target = this.imports[impt]; this.imports[impt] = rebase( resolve(target, this.mapUrl, this.rootUrl), mapUrl, rootUrl ); if (!isPlain(impt)) { const newImpt = rebase( resolve(impt, this.mapUrl, this.rootUrl), mapUrl, rootUrl ); if (newImpt !== impt) { changedImportProps = true; this.imports[newImpt] = this.imports[impt]; delete this.imports[impt]; } } } if (changedImportProps) this.imports = alphabetize(this.imports); let changedScopeProps = false; // Create a temporary map to collect scopes by their rebased URLs const rebasedScopes: Record<string, Record<string, string>> = Object.create(null); for (const scope of Object.keys(this.scopes)) { const scopeImports = this.scopes[scope]; let changedScopeImportProps = false; for (const impt of Object.keys(scopeImports)) { const target = scopeImports[impt]; scopeImports[impt] = rebase( resolve(target, this.mapUrl, this.rootUrl), mapUrl, rootUrl ); if (!isPlain(impt)) { const newName = rebase( resolve(impt, this.mapUrl, this.rootUrl), mapUrl, rootUrl ); if (newName !== impt) { changedScopeImportProps = true; scopeImports[newName] = scopeImports[impt]; delete scopeImports[impt]; } } } if (changedScopeImportProps) this.scopes[scope] = alphabetize(scopeImports); const newScope = rebase( resolve(scope, this.mapUrl, this.rootUrl), mapUrl, rootUrl ); // Check if this scope URL already exists in our rebased collection if (rebasedScopes[newScope]) { // Merge the imports from this scope into the existing one Object.assign(rebasedScopes[newScope], scopeImports); changedScopeProps = true; } else { // First time seeing this rebased scope URL rebasedScopes[newScope] = scopeImports; if (scope !== newScope) { changedScopeProps = true; } } } // Replace the scopes with the unified rebased scopes this.scopes = rebasedScopes; if (changedScopeProps) this.scopes = alphabetize(this.scopes); let changedIntegrityProps = false; for (const target of Object.keys(this.integrity)) { const newTarget = rebase( resolve(target, this.mapUrl, this.rootUrl), mapUrl, rootUrl ); if (target !== newTarget) { this.integrity[newTarget] = this.integrity[target]; delete this.integrity[target]; changedIntegrityProps = true; } } if (changedIntegrityProps) this.integrity = alphabetize(this.integrity); this.mapUrl = mapUrl; this.rootUrl = rootUrl; return this; } /** * Perform a module resolution against the import map * * @param specifier Specifier to resolve * @param parentUrl Parent URL to resolve against * @returns Resolved URL string */ resolve(specifier: string, parentUrl: string | URL = this.mapUrl): string { if (typeof parentUrl !== "string") parentUrl = parentUrl.toString(); parentUrl = resolve(parentUrl, this.mapUrl, this.rootUrl); let specifierUrl: URL | undefined; if (!isPlain(specifier)) { specifierUrl = new URL(specifier, parentUrl); specifier = specifierUrl.href; } const scopeMatches = getScopeMatches( parentUrl, this.scopes, this.mapUrl, this.rootUrl ); for (const [scope] of scopeMatches) { let mapMatch = getMapMatch(specifier, this.scopes[scope]); if (!mapMatch && specifierUrl) { mapMatch = getMapMatch( (specifier = rebase(specifier, this.mapUrl, this.rootUrl)), this.scopes[scope] ) || (this.rootUrl && getMapMatch( (specifier = rebase(specifier, this.mapUrl, null)), this.scopes[scope] )) || undefined; } if (mapMatch) { const target = this.scopes[scope][mapMatch]; return resolve( target + specifier.slice(mapMatch.length), this.mapUrl, this.rootUrl ); } } let mapMatch = getMapMatch(specifier, this.imports); if (!mapMatch && specifierUrl) { mapMatch = getMapMatch( (specifier = rebase(specifier, this.mapUrl, this.rootUrl)), this.imports ) || (this.rootUrl && getMapMatch( (specifier = rebase(specifier, this.mapUrl, null)), this.imports )) || undefined; } if (mapMatch) { const target = this.imports[mapMatch]; return resolve( target + specifier.slice(mapMatch.length), this.mapUrl, this.rootUrl ); } if (specifierUrl) return specifierUrl.href; throw new Error(`Unable to resolve ${specifier} in ${parentUrl}`); } /** * Get the import map JSON data * * @returns Import map data */ toJSON(): IImportMap { const obj: any = {}; if (Object.keys(this.imports).length) obj.imports = this.imports; if (Object.keys(this.scopes).length) obj.scopes = this.scopes; if (Object.keys(this.integrity).length) obj.integrity = this.integrity; return JSON.parse(JSON.stringify(obj)); } } export function getScopeMatches( parentUrl: string, scopes: Record<string, Record<string, string>>, mapUrl: URL, rootUrl?: URL ): [string, string][] { let scopeCandidates = Object.keys(scopes).map((scope) => [ scope, resolve(scope, mapUrl, rootUrl), ]); scopeCandidates = scopeCandidates.sort(([, matchA], [, matchB]) => matchA.length < matchB.length ? 1 : -1 ); return scopeCandidates.filter(([, scopeUrl]) => { return ( scopeUrl === parentUrl || (scopeUrl.endsWith("/") && parentUrl.startsWith(scopeUrl)) ); }) as [string, string][]; } export function getMapMatch<T = any>( specifier: string, map: Record<string, T> ): string | undefined { if (specifier in map) return specifier; let curMatch; for (const match of Object.keys(map)) { const wildcard = match.endsWith("*"); if (!match.endsWith("/") && !wildcard) continue; if (specifier.startsWith(wildcard ? match.slice(0, -1) : match)) { if (!curMatch || match.length > curMatch.length) curMatch = match; } } return curMatch; }