Skip to content

Commit

Permalink
Cascading import maps implementation (#2009)
Browse files Browse the repository at this point in the history
  • Loading branch information
guybedford committed Aug 29, 2019
1 parent 36854be commit e6bd2d3
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 125 deletions.
6 changes: 6 additions & 0 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ System.constructor.prototype.createContext = function (url) {
};
```

#### prepareImport() -> Promise

This function is called before any `System.import` or dynamic import, returning a Promise that is resolved before continuing to perform the import.

This is used in SystemJS core to ensure that import maps are loaded so that the `System.resolve` function remains synchronous.

#### getRegister() -> [deps: String[], declare: Function]

> This hook is intended for custom module format integrations only.
Expand Down
42 changes: 37 additions & 5 deletions docs/import-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ To more clearly define package folders we can use package folder mappings:
In this scenario `import 'lodash'` will resolve to `/path/to/lodash/index.js` while `import 'lodash/x'` will
resolve to `/path/to/lodash/x`.

Note that the _right hand side_ of the import map must always be a valid relative, absolute or full URL (`./x`, `/x`, `https://site.com/x`).

Bare specifiers such as `x` on the right hand side will not match and throw an error.

### Scopes

Import maps also provide support for scoped mappings, where the mapping should only be applied within
Expand All @@ -91,16 +95,44 @@ This can be achieved with scoped import maps:
</script>
```

> Note scopes must end with a trailing `/` to match all subpaths within that path.
Scopes still fallback to applying the global imports, so we only need to do this for imports that are different
from their global resolutions.

### Composition

_Note: This is an advanced feature, which is not necessary for most use cases._

Multiple import maps are supported with each successive import map composing with the previous one.

When composing import maps, they are combined in order, with the _right hand side_ resolutions of the new import map applying the resolution
rules of the import map that came before.

This means import maps can reference resolutions from previous import maps:


```html
<script type="systemjs-importmap">
{
"imports": {
"x": "/path/to/x.js"
}
}
</script>
<script type="systemjs-importmap">
{
"imports": {
"y": "x" // resolves to /path/to/x.js
}
}
</script>
```

#### Spec and Implementation Feedback

Part of the benefit of giving users a working version of an early spec is being able to get real user feedback on
the spec.
Part of the benefit of giving users a working version of an early spec is being able to get real user feedback on the spec.

If you have suggestions, or notice cases where this implementation seems not to be following the spec properly feel free to post an issue.

The edge cases are still being worked out, so there will still be work to do here too.

[Read the full specification for the exact behaviours further](https://github.com/domenic/import-maps/blob/master/spec.md).
See the [import maps specification](https://github.com/domenic/import-maps/blob/master/spec.md) for exact resolution behaviours.
1 change: 1 addition & 0 deletions minify-extras.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mkdir -p dist/extras
cp src/extras/* dist/extras/
cd dist/extras
rm *.min.js
Expand Down
93 changes: 44 additions & 49 deletions src/common.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export const hasSelf = typeof self !== 'undefined';

export const hasDocument = typeof document !== 'undefined';

const envGlobal = hasSelf ? self : global;
export { envGlobal as global };

export let baseUrl;

if (typeof document !== 'undefined') {
if (hasDocument) {
const baseEl = document.querySelector('base[href]');
if (baseEl)
baseUrl = baseEl.href;
Expand Down Expand Up @@ -108,46 +110,42 @@ export function resolveIfNotPlainOrUrl (relUrl, parentUrl) {
*/

function resolveUrl (relUrl, parentUrl) {
return resolveIfNotPlainOrUrl(relUrl, parentUrl) ||
relUrl.indexOf(':') !== -1 && relUrl ||
resolveIfNotPlainOrUrl('./' + relUrl, parentUrl);
return resolveIfNotPlainOrUrl(relUrl, parentUrl) || (relUrl.indexOf(':') !== -1 ? relUrl : resolveIfNotPlainOrUrl('./' + relUrl, parentUrl));
}

function objectAssign (to, from) {
for (let p in from)
to[p] = from[p];
return to;
}

function resolvePackages(pkgs, baseUrl) {
var outPkgs = {};
for (var p in pkgs) {
var value = pkgs[p];
// TODO package fallback support
if (typeof value !== 'string')
function resolveAndComposePackages (packages, outPackages, baseUrl, parentMap, parentUrl) {
for (let p in packages) {
const rhs = packages[p];
// package fallbacks not currently supported
if (typeof rhs !== 'string')
continue;
outPkgs[resolveIfNotPlainOrUrl(p, baseUrl) || p] = resolveUrl(value, baseUrl);
const mapped = resolveImportMap(parentMap, resolveIfNotPlainOrUrl(rhs, baseUrl) || rhs, parentUrl);
if (!mapped)
targetWarning(p, rhs, 'bare specifier did not resolve');
else
outPackages[p] = mapped;
}
return outPkgs;
}

export function parseImportMap (json, baseUrl) {
const imports = resolvePackages(json.imports, baseUrl) || {};
const scopes = {};
if (json.scopes) {
for (let scopeName in json.scopes) {
const scope = json.scopes[scopeName];
let resolvedScopeName = resolveUrl(scopeName, baseUrl);
if (resolvedScopeName[resolvedScopeName.length - 1] !== '/')
resolvedScopeName += '/';
scopes[resolvedScopeName] = resolvePackages(scope, baseUrl) || {};
}
}
export function resolveAndComposeImportMap (json, baseUrl, parentMap) {
const outMap = { imports: objectAssign({}, parentMap.imports), scopes: objectAssign({}, parentMap.scopes) };

return { imports: imports, scopes: scopes };
}
if (json.imports)
resolveAndComposePackages(json.imports, outMap.imports, baseUrl, parentMap, null);

export function mergeImportMap(originalMap, newMap) {
for (let i in newMap.imports) {
originalMap.imports[i] = newMap.imports[i];
}
for (let i in newMap.scopes) {
originalMap.scopes[i] = newMap.scopes[i];
}
if (json.scopes)
for (let s in json.scopes) {
const resolvedScope = resolveUrl(s, baseUrl);
resolveAndComposePackages(json.scopes[s], outMap.scopes[resolvedScope] || (outMap.scopes[resolvedScope] = {}), baseUrl, parentMap, resolvedScope);
}

return outMap;
}

function getMatch (path, matchObj) {
Expand All @@ -167,26 +165,23 @@ function applyPackages (id, packages) {
const pkg = packages[pkgName];
if (pkg === null) return;
if (id.length > pkgName.length && pkg[pkg.length - 1] !== '/')
console.warn("Invalid package target " + pkg + " for '" + pkgName + "' should have a trailing '/'.");
return pkg + id.slice(pkgName.length);
targetWarning(pkgName, pkg, "should have a trailing '/'");
else
return pkg + id.slice(pkgName.length);
}
}

export function resolveImportMap (id, parentUrl, importMap) {
const urlResolved = resolveIfNotPlainOrUrl(id, parentUrl) || id.indexOf(':') !== -1 && id;
if (urlResolved)
id = urlResolved;
const scopeName = getMatch(parentUrl, importMap.scopes);
if (scopeName) {
const scopePackages = importMap.scopes[scopeName];
const packageResolution = applyPackages(id, scopePackages);
function targetWarning (match, target, msg) {
console.warn("Package target " + msg + ", resolving target '" + target + "' for " + match);
}

export function resolveImportMap (importMap, resolvedOrPlain, parentUrl) {
let scopeUrl = parentUrl && getMatch(parentUrl, importMap.scopes);
while (scopeUrl) {
const packageResolution = applyPackages(resolvedOrPlain, importMap.scopes[scopeUrl]);
if (packageResolution)
return packageResolution;
scopeUrl = getMatch(scopeUrl.slice(0, scopeUrl.lastIndexOf('/')), importMap.scopes);
}
return applyPackages(id, importMap.imports) || urlResolved || throwBare(id, parentUrl);
return applyPackages(resolvedOrPlain, importMap.imports) || resolvedOrPlain.indexOf(':') !== -1 && resolvedOrPlain;
}

export function throwBare (id, parentUrl) {
throw Error('Unable to resolve bare specifier "' + id + (parentUrl ? '" from ' + parentUrl : '"'));
}

35 changes: 19 additions & 16 deletions src/features/import-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@
*
* There is no support for dynamic import maps injection currently.
*/
import { baseUrl, parseImportMap, resolveImportMap, mergeImportMap } from '../common.js';
import { baseUrl, resolveAndComposeImportMap, resolveImportMap, resolveIfNotPlainOrUrl, hasDocument } from '../common.js';
import { systemJSPrototype } from '../system-core.js';

const importMap = { imports: {}, scopes: {} };
let acquiringImportMaps = typeof document !== 'undefined';
let importMap = { imports: {}, scopes: {} }, importMapPromise;

if (acquiringImportMaps) {
if (hasDocument) {
Array.prototype.forEach.call(document.querySelectorAll('script[type="systemjs-importmap"][src]'), function (script) {
script._j = fetch(script.src).then(function (res) {
return res.json();
Expand All @@ -25,22 +24,26 @@ if (acquiringImportMaps) {
}

systemJSPrototype.prepareImport = function () {
if (acquiringImportMaps) {
acquiringImportMaps = false;
let importMapPromise = Promise.resolve();
Array.prototype.forEach.call(document.querySelectorAll('script[type="systemjs-importmap"]'), function (script) {
importMapPromise = importMapPromise.then(function () {
return (script._j || script.src && fetch(script.src).then(function (resp) { return resp.json(); }) || Promise.resolve(JSON.parse(script.innerHTML)))
.then(function (json) {
mergeImportMap(importMap, parseImportMap(json, script.src || baseUrl));
if (!importMapPromise) {
importMapPromise = Promise.resolve();
if (hasDocument)
Array.prototype.forEach.call(document.querySelectorAll('script[type="systemjs-importmap"]'), function (script) {
importMapPromise = importMapPromise.then(function () {
return (script._j || script.src && fetch(script.src).then(function (resp) { return resp.json(); }) || Promise.resolve(JSON.parse(script.innerHTML)))
.then(function (json) {
importMap = resolveAndComposeImportMap(json, script.src || baseUrl, importMap);
});
});
});
});
return importMapPromise;
}
return importMapPromise;
};

systemJSPrototype.resolve = function (id, parentUrl) {
parentUrl = parentUrl || baseUrl;
return resolveImportMap(id, parentUrl, importMap);
};
return resolveImportMap(importMap, resolveIfNotPlainOrUrl(id, parentUrl) || id, parentUrl) || throwUnresolved(id, parentUrl);
};

function throwUnresolved (id, parentUrl) {
throw Error("Unable to resolve specifier '" + id + (parentUrl ? "' from " + parentUrl : "'"));
}
4 changes: 2 additions & 2 deletions test/fixtures/browser/importmap.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
"g/": "https://site.com/"
},
"scopes": {
"scope-test": {
"scope-test/": {
"maptest": "./contextual-map-dep.js",
"maptest/": "./contextual-map-dep/"
},
"wasm": {
"wasm/": {
"example": "./wasm/example.js"
}
}
Expand Down
4 changes: 2 additions & 2 deletions test/fixtures/browser/named-amd.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
define('c', ['exports', './d'], function (exports, b) {
define('c', ['exports', 'd'], function (exports, b) {
exports.a = b.b;
});

define('d', [], function () {
return { b: 'b' };
});
});

0 comments on commit e6bd2d3

Please sign in to comment.