Skip to content

Commit 2693439

Browse files
authored
Merge pull request #6 from dburles/resolve-override
Resolve override
2 parents d5f776b + 1a406e7 commit 2693439

File tree

6 files changed

+172
-59
lines changed

6 files changed

+172
-59
lines changed

createResolveLinkRelations.mjs

Lines changed: 122 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import resolveImportMap from "./resolve-import-map/resolveImportMap.mjs";
1414

1515
/** @typedef {Map<any, any> | AsyncMap} AsyncMapLike */
1616

17+
/** @typedef {(specifier: string) => string} ResolveSpecifier */
18+
1719
// The import map parser requries a base url. We don't require one for our purposes,
1820
// but it allows us to use the parser without modifying the source. One quirk is that it will try map
1921
// this url to files locally if it's specified, but no one should do that.
@@ -46,18 +48,67 @@ async function exists(filePath) {
4648
}
4749
}
4850

51+
/**
52+
* Takes a specifier and resolves through an import map.
53+
* @param {string} specifier Import specifier.
54+
* @param {object} options Options.
55+
* @param {string} options.url The module URL to resolve.
56+
* @param {object} [options.parsedImportMap] A parsed import map.
57+
* @param {ResolveSpecifier} [options.resolveSpecifierOverride] Override specifier resolution.
58+
*/
59+
function resolveSpecifier(
60+
specifier,
61+
{ url, parsedImportMap, resolveSpecifierOverride = (x) => x },
62+
) {
63+
// If an import map is supplied, everything resolves through it.
64+
if (parsedImportMap) {
65+
const importMapResolved = resolveImportMap(
66+
specifier,
67+
parsedImportMap,
68+
new URL(url, `https://${DUMMY_HOSTNAME}`),
69+
);
70+
71+
if (importMapResolved.hostname === DUMMY_HOSTNAME) {
72+
// It will match if it's a local module.
73+
return {
74+
importMap: true,
75+
importMapResolved,
76+
specifier: resolveSpecifierOverride(importMapResolved.pathname),
77+
};
78+
}
79+
}
80+
81+
return {
82+
importMap: false,
83+
specifier: resolveSpecifierOverride(specifier),
84+
};
85+
}
86+
4987
/**
5088
* Recursively parses and resolves a module's imports.
5189
* @param {string} module The path to the module.
5290
* @param {object} options Options.
5391
* @param {string} options.url The module URL to resolve.
5492
* @param {object} [options.parsedImportMap] A parsed import map.
93+
* @param {ResolveSpecifier} [options.resolveSpecifierOverride] Override specifier resolution.
94+
* @param {string} options.rootPath The absolute path to the specified application root.
5595
* @param {boolean} [root] Whether the module is the root module.
56-
* @returns An array containing paths to modules that can be preloaded.
96+
* @returns {Promise<Set<string>>} A `Set` containing paths to modules that can be preloaded, or otherwise `undefined`.
5797
*/
58-
async function resolveImports(module, { url, parsedImportMap }, root = true) {
59-
/** @type {Array<string>} */
60-
let modules = [];
98+
async function resolveImports(
99+
module,
100+
{ url, parsedImportMap, resolveSpecifierOverride, rootPath },
101+
root = true,
102+
visited = new Set(),
103+
) {
104+
/** @type {Set<string>} */
105+
const modules = new Set();
106+
107+
if (visited.has(module)) {
108+
return modules;
109+
}
110+
111+
visited.add(module);
61112

62113
const source = await tryReadFile(module);
63114

@@ -71,46 +122,40 @@ async function resolveImports(module, { url, parsedImportMap }, root = true) {
71122
imports.map(async ({ n: specifier, d }) => {
72123
const dynamic = d > -1;
73124
if (specifier && !dynamic) {
74-
let importMapResolved = null;
75-
76-
// If an import map is supplied, everything resolves through it.
77-
if (parsedImportMap) {
78-
importMapResolved = resolveImportMap(
79-
specifier,
80-
parsedImportMap,
81-
new URL(url, `https://${DUMMY_HOSTNAME}`),
82-
);
83-
}
84-
85-
let resolvedModule;
86-
87-
// Are we resolving with an import map?
88-
if (importMapResolved !== null) {
89-
// It will match if it's a local module.
90-
if (importMapResolved.hostname === DUMMY_HOSTNAME) {
91-
resolvedModule = path.resolve(
92-
path.dirname(module),
93-
`.${importMapResolved.pathname}`,
94-
);
95-
}
96-
} else {
97-
resolvedModule = path.resolve(path.dirname(module), specifier);
98-
}
125+
const resolvedSpecifier = resolveSpecifier(specifier, {
126+
url,
127+
parsedImportMap,
128+
resolveSpecifierOverride,
129+
});
130+
const resolvedModule = path.join(
131+
resolvedSpecifier.importMap ? rootPath : path.dirname(module),
132+
resolvedSpecifier.specifier,
133+
);
99134

100135
// If the module has resolved to a local file (and it exists), then it's preloadable.
101-
if (resolvedModule && (await exists(resolvedModule))) {
136+
if (
137+
resolvedModule &&
138+
resolvedModule.startsWith(rootPath) &&
139+
(await exists(resolvedModule))
140+
) {
102141
if (!root) {
103-
modules.push(resolvedModule);
142+
modules.add(resolvedModule);
104143
}
105144

106145
const graph = await resolveImports(
107146
resolvedModule,
108-
{ parsedImportMap, url },
147+
{
148+
parsedImportMap,
149+
url: resolvedSpecifier.importMapResolved?.pathname || url,
150+
resolveSpecifierOverride,
151+
rootPath,
152+
},
109153
false,
154+
visited,
110155
);
111156

112-
if (graph.length > 0) {
113-
graph.forEach((module) => modules.push(module));
157+
if (graph.size > 0) {
158+
graph.forEach((module) => modules.add(module));
114159
}
115160
}
116161
}
@@ -127,17 +172,27 @@ async function resolveImports(module, { url, parsedImportMap }, root = true) {
127172
* @param {AsyncMapLike} options.cache Resolved imports cache.
128173
* @param {string} options.url The module URL to resolve.
129174
* @param {object} [options.parsedImportMap] A parsed import map.
130-
* @returns An array containing paths to modules that can be preloaded, or otherwise `undefined`.
175+
* @param {ResolveSpecifier} [options.resolveSpecifierOverride] Override specifier resolution.
176+
* @param {string} options.rootPath The absolute path to the specified application root.
177+
* @returns {Promise<Set<string> | undefined>} A `Set` containing paths to modules that can be preloaded, or otherwise `undefined`.
131178
*/
132-
async function resolveImportsCached(module, { cache, url, parsedImportMap }) {
179+
async function resolveImportsCached(
180+
module,
181+
{ cache, url, parsedImportMap, resolveSpecifierOverride, rootPath },
182+
) {
133183
const paths = await cache.get(module);
134184

135185
if (paths) {
136186
return paths;
137187
} else {
138-
const graph = await resolveImports(module, { parsedImportMap, url });
188+
const graph = await resolveImports(module, {
189+
parsedImportMap,
190+
url,
191+
resolveSpecifierOverride,
192+
rootPath,
193+
});
139194

140-
if (graph.length > 0) {
195+
if (graph.size > 0) {
141196
await cache.set(module, graph);
142197
return graph;
143198
}
@@ -156,33 +211,46 @@ export default function createResolveLinkRelations(
156211
appPath,
157212
{ importMap: importMapString, cache = new Map() } = {},
158213
) {
214+
/** @type {object} */
215+
let parsedImportMap;
216+
217+
if (importMapString !== undefined) {
218+
parsedImportMap = parseFromString(
219+
importMapString,
220+
`https://${DUMMY_HOSTNAME}`,
221+
);
222+
}
223+
159224
/**
160225
* Resolves link relations for a given URL.
161226
* @param {string} url The module URL to resolve.
162-
* @returns An array containing relative paths to modules that can be preloaded, or otherwise `undefined`.
227+
* @param {object} [options] Options.
228+
* @param {ResolveSpecifier} [options.resolveSpecifier] Override specifier resolution.
229+
* @returns {Promise<Array<string> | undefined>} An array containing relative paths to modules that can be preloaded, or otherwise `undefined`.
163230
*/
164-
return async function resolveLinkRelations(url) {
165-
let parsedImportMap;
166-
167-
if (importMapString !== undefined) {
168-
parsedImportMap = parseFromString(
169-
importMapString,
170-
`https://${DUMMY_HOSTNAME}`,
171-
);
172-
}
173-
231+
return async function resolveLinkRelations(
232+
url,
233+
{ resolveSpecifier: resolveSpecifierOverride } = {},
234+
) {
174235
const rootPath = path.resolve(appPath);
175-
const resolvedFile = path.join(rootPath, url);
236+
const resolvedSpecifier = resolveSpecifier(url, {
237+
url,
238+
parsedImportMap,
239+
resolveSpecifierOverride,
240+
});
241+
const resolvedModule = path.join(rootPath, resolvedSpecifier.specifier);
176242

177-
if (resolvedFile.startsWith(rootPath)) {
178-
const modules = await resolveImportsCached(resolvedFile, {
243+
if (resolvedModule.startsWith(rootPath)) {
244+
const modules = await resolveImportsCached(resolvedModule, {
179245
cache,
180246
url,
181247
parsedImportMap,
248+
resolveSpecifierOverride,
249+
rootPath,
182250
});
183251

184-
if (Array.isArray(modules) && modules.length > 0) {
185-
const resolvedModules = modules.map((module) => {
252+
if (modules && modules.size > 0) {
253+
const resolvedModules = Array.from(modules).map((module) => {
186254
return "/" + path.relative(rootPath, module);
187255
});
188256

createResolveLinkRelations.test.mjs

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,20 @@ test("createResolveLinkRelations", async (t) => {
3434
assert.equal(resolvedModulesCached.length, 4);
3535
});
3636

37-
await t.test("can't reach outside of appPath", async () => {
38-
const resolveLinkRelations = createResolveLinkRelations("test-fixtures");
39-
const resolvedModules = await resolveLinkRelations("../../a.mjs");
37+
await t.test("can't reach outside of appPath", async (tt) => {
38+
await tt.test("root module", async () => {
39+
const resolveLinkRelations = createResolveLinkRelations("test-fixtures");
40+
const resolvedModules = await resolveLinkRelations("../../a.mjs");
4041

41-
assert.equal(resolvedModules, undefined);
42+
assert.equal(resolvedModules, undefined);
43+
});
44+
45+
await tt.test("imports", async () => {
46+
const resolveLinkRelations = createResolveLinkRelations("test-fixtures");
47+
const resolvedModules = await resolveLinkRelations("./outside.mjs");
48+
49+
assert.equal(resolvedModules, undefined);
50+
});
4251
});
4352

4453
await t.test("module without imports", async () => {
@@ -78,6 +87,17 @@ test("createResolveLinkRelations", async (t) => {
7887

7988
assert.ok(resolvedModules.includes("/z.mjs"));
8089
});
90+
91+
await tt.test("resolves root module", async () => {
92+
const resolveLinkRelations = createResolveLinkRelations("test-fixtures", {
93+
importMap: '{ "imports": { "e": "./e.mjs", "g": "./g.mjs" } }',
94+
});
95+
const resolvedModules = await resolveLinkRelations("e");
96+
97+
assert.ok(Array.isArray(resolvedModules));
98+
99+
assert.ok(resolvedModules.includes("/g.mjs"));
100+
});
81101
});
82102

83103
await t.test("async cache", async () => {
@@ -128,4 +148,26 @@ test("createResolveLinkRelations", async (t) => {
128148

129149
assert.equal(resolvedModulesCached.length, 4);
130150
});
151+
152+
await t.test("no duplicates", async () => {
153+
const resolveLinkRelations = createResolveLinkRelations("test-fixtures");
154+
const resolvedModules = await resolveLinkRelations("/a.mjs");
155+
156+
assert.ok(Array.isArray(resolvedModules));
157+
});
158+
159+
await t.test("resolveSpecifier", async (tt) => {
160+
await tt.test("basic", async () => {
161+
const resolveLinkRelations = createResolveLinkRelations("test-fixtures");
162+
const resolvedModules = await resolveLinkRelations("/a.mjs", {
163+
resolveSpecifier(specifier) {
164+
return specifier.replace("/a", "/b");
165+
},
166+
});
167+
168+
assert.ok(Array.isArray(resolvedModules));
169+
170+
assert.ok(resolvedModules.includes("/d.mjs"));
171+
});
172+
});
131173
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "modulepreload-link-relations",
3-
"version": "3.0.0",
3+
"version": "3.1.0",
44
"description": "Utility for generating modulepreload link relations based on a JavaScript module import graph.",
55
"repository": {
66
"type": "git",

test-fixtures/d.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
import "./missing-import.mjs";
2+
export default "foo";

test-fixtures/lib/aa.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
import "./bb.mjs";
2+
import "../c.mjs";

test-fixtures/outside.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "../createResolveLinkRelations.mjs";

0 commit comments

Comments
 (0)