Skip to content

Commit 28955bc

Browse files
committed
feat: rewrite import map to strictly follow WHATWG spec, expose as ImportMap class
1 parent 09cc9b9 commit 28955bc

6 files changed

Lines changed: 697 additions & 134 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
"typescript": "^5.9.3"
1717
},
1818
"devDependencies": {
19-
"@types/node": "^24.12.0",
20-
"esbuild": "^0.27.5",
21-
"tsdown": "^0.21.7"
19+
"@types/node": "^24.12.2",
20+
"esbuild": "^0.28.0",
21+
"tsdown": "^0.22.0"
2222
},
2323
"author": "YieldRay",
2424
"license": "MIT",

src/importmap.test.ts

Lines changed: 201 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,238 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert/strict";
3-
import { resolveFromImportMap } from "./importmap.ts";
4-
import type { ImportMapData } from "./importmap.ts";
3+
import { ImportMap, type SpecifierResolutionRecord } from "./importmap.ts";
54

65
// parseImportMaps() requires a DOM — tested via the browser demo only
76

7+
const BASE = new URL("https://example.com/");
8+
9+
function parse(json: object): ImportMap {
10+
return ImportMap.parse(JSON.stringify(json), BASE);
11+
}
12+
13+
function resolve(specifier: string, map: ImportMap, base: string | URL = BASE): string | undefined {
14+
return ImportMap.resolve(specifier, map, base)?.url;
15+
}
16+
817
describe("resolveFromImportMap", () => {
9-
it("resolves from global imports", () => {
10-
const map: ImportMapData = { imports: { react: "https://esm.sh/react" } };
11-
assert.equal(resolveFromImportMap("react", map), "https://esm.sh/react");
18+
it("resolves from global imports (exact match)", () => {
19+
const map = parse({ imports: { react: "https://esm.sh/react" } });
20+
assert.equal(resolve("react", map), "https://esm.sh/react");
1221
});
1322

1423
it("returns undefined for unknown specifier", () => {
15-
const map: ImportMapData = { imports: { react: "https://esm.sh/react" } };
16-
assert.equal(resolveFromImportMap("vue", map), undefined);
24+
const map = parse({ imports: { react: "https://esm.sh/react" } });
25+
assert.equal(resolve("vue", map), undefined);
1726
});
1827

1928
it("resolves from matching scope over global imports", () => {
20-
const map: ImportMapData = {
29+
const map = parse({
2130
imports: { react: "https://esm.sh/react" },
22-
scopes: {
23-
"https://example.com/app/": { react: "https://esm.sh/react@18" },
24-
},
25-
};
31+
scopes: { "https://example.com/app/": { react: "https://esm.sh/react@18" } },
32+
});
2633
assert.equal(
27-
resolveFromImportMap("react", map, "https://example.com/app/main.js"),
34+
resolve("react", map, "https://example.com/app/main.js"),
2835
"https://esm.sh/react@18",
2936
);
3037
});
3138

3239
it("falls back to global imports when no scope matches", () => {
33-
const map: ImportMapData = {
40+
const map = parse({
3441
imports: { react: "https://esm.sh/react" },
35-
scopes: {
36-
"https://example.com/other/": { react: "https://esm.sh/react@17" },
37-
},
38-
};
42+
scopes: { "https://example.com/other/": { react: "https://esm.sh/react@17" } },
43+
});
3944
assert.equal(
40-
resolveFromImportMap("react", map, "https://example.com/app/main.js"),
45+
resolve("react", map, "https://example.com/app/main.js"),
4146
"https://esm.sh/react",
4247
);
4348
});
4449

45-
it("prefers more specific scope", () => {
46-
const map: ImportMapData = {
50+
it("prefers more specific scope (longer prefix wins)", () => {
51+
const map = parse({
4752
scopes: {
4853
"https://example.com/": { react: "https://esm.sh/react@17" },
4954
"https://example.com/app/": { react: "https://esm.sh/react@18" },
5055
},
51-
};
56+
});
5257
assert.equal(
53-
resolveFromImportMap("react", map, "https://example.com/app/main.js"),
58+
resolve("react", map, "https://example.com/app/main.js"),
5459
"https://esm.sh/react@18",
5560
);
5661
});
5762

5863
it("returns undefined when imports and scopes are empty", () => {
59-
assert.equal(resolveFromImportMap("react", {}), undefined);
64+
assert.equal(resolve("react", new ImportMap()), undefined);
65+
});
66+
67+
it("resolves trailing-slash prefix match", () => {
68+
const map = parse({ imports: { "moment/": "https://esm.sh/moment/src/" } });
69+
assert.equal(
70+
resolve("moment/locale/zh-cn.js", map),
71+
"https://esm.sh/moment/src/locale/zh-cn.js",
72+
);
73+
});
74+
75+
it("throws TypeError for null (blocked) entry on exact match", () => {
76+
const map: ImportMap = { imports: new Map([["react", null]]), scopes: new Map(), integrity: new Map() };
77+
assert.throws(
78+
() => resolve("react", map),
79+
(e: unknown) => e instanceof TypeError && /blocked/.test(e.message),
80+
);
81+
});
82+
83+
it("throws TypeError for backtrack attempt via prefix match", () => {
84+
const map: ImportMap = {
85+
imports: new Map([["pkg/", new URL("https://cdn.example/pkg/")]]),
86+
scopes: new Map(),
87+
integrity: new Map(),
88+
};
89+
assert.throws(() => resolve("pkg/../../secret", map), TypeError);
90+
});
91+
92+
it("resolves URL-like specifier remapped via import map", () => {
93+
const map = parse({
94+
imports: { "https://cdn.example.com/vue.js": "https://local.example.com/vue.js" },
95+
});
96+
assert.equal(
97+
resolve("https://cdn.example.com/vue.js", map),
98+
"https://local.example.com/vue.js",
99+
);
100+
});
101+
102+
it("returns record with serializedBaseURL and normalizedSpecifier", () => {
103+
const map = parse({ imports: { react: "https://esm.sh/react" } });
104+
const result = ImportMap.resolve("react", map, "https://example.com/app/main.js");
105+
assert.ok(result != null);
106+
assert.equal(result!.record.serializedBaseURL, "https://example.com/app/main.js");
107+
assert.equal(result!.record.specifier, "react");
108+
assert.equal(result!.record.specifierAsURL, null);
109+
});
110+
111+
it("returns record with specifierAsURL for URL-like specifiers", () => {
112+
const map = parse({
113+
imports: { "https://cdn.example.com/vue.js": "https://local.example.com/vue.js" },
114+
});
115+
const result = ImportMap.resolve("https://cdn.example.com/vue.js", map, BASE);
116+
assert.ok(result != null);
117+
assert.ok(result!.record.specifierAsURL instanceof URL);
118+
assert.equal(result!.record.specifierAsURL!.href, "https://cdn.example.com/vue.js");
119+
});
120+
});
121+
122+
describe("parseImportMapString", () => {
123+
it("normalizes relative specifier keys against baseURL", () => {
124+
const map = ImportMap.parse(
125+
JSON.stringify({ imports: { "/app/helper": "./node_modules/helper/index.mjs" } }),
126+
new URL("https://example.com/base/page.html"),
127+
);
128+
assert.ok(map.imports.has("https://example.com/app/helper"), "key should be normalized to absolute URL");
129+
assert.equal(
130+
map.imports.get("https://example.com/app/helper")?.href,
131+
"https://example.com/base/node_modules/helper/index.mjs",
132+
);
133+
});
134+
135+
it("stores null for an address that is not a valid URL", () => {
136+
const map = parse({ imports: { react: "not a url" } });
137+
assert.equal(map.imports.get("react"), null);
138+
});
139+
140+
it("stores null for trailing-slash key with non-trailing-slash address", () => {
141+
const map = parse({ imports: { "moment/": "https://esm.sh/moment" } });
142+
assert.equal(map.imports.get("moment/"), null);
143+
});
144+
145+
it("throws TypeError for non-object top-level", () => {
146+
assert.throws(() => ImportMap.parse("[]", BASE), TypeError);
147+
});
148+
149+
it("throws TypeError for non-object imports value", () => {
150+
assert.throws(() => ImportMap.parse(JSON.stringify({ imports: [] }), BASE), TypeError);
151+
});
152+
153+
it("sorts specifier keys in descending order", () => {
154+
const map = parse({ imports: { "a/": "https://x.com/a/", "a/b/": "https://x.com/ab/" } });
155+
const keys = [...map.imports.keys()];
156+
assert.ok(keys.indexOf("a/b/") < keys.indexOf("a/"), "more specific key should come first");
157+
});
158+
159+
it("parses integrity field", () => {
160+
const map = parse({
161+
integrity: { "https://example.com/a.js": "sha384-abc" },
162+
});
163+
assert.equal(map.integrity.get("https://example.com/a.js"), "sha384-abc");
164+
});
165+
});
166+
167+
describe("mergeExistingAndNewImportMaps", () => {
168+
it("merges non-conflicting rules from both maps", () => {
169+
const old = parse({ imports: { "/app/": "./original-app/" } });
170+
const newMap = parse({ imports: { "/app/helper": "./helper/index.mjs" } });
171+
ImportMap.merge(old, newMap, []);
172+
assert.ok(old.imports.has("https://example.com/app/"));
173+
assert.ok(old.imports.has("https://example.com/app/helper"));
174+
});
175+
176+
it("existing rules win on conflict (first-wins)", () => {
177+
const old = parse({ imports: { "/app/helper": "./helper/index.mjs" } });
178+
const newMap = parse({ imports: { "/app/helper": "./main/helper/index.mjs" } });
179+
ImportMap.merge(old, newMap, []);
180+
assert.equal(
181+
old.imports.get("https://example.com/app/helper")?.href,
182+
"https://example.com/helper/index.mjs",
183+
);
184+
});
185+
186+
it("drops new import rules that match an already-resolved specifier", () => {
187+
const old = parse({ imports: { lodash: "https://esm.sh/lodash" } });
188+
const newMap = parse({ imports: { "/app/helper": "./helper/index.mjs", lodash: "https://cdn.example/lodash" } });
189+
const resolved: SpecifierResolutionRecord[] = [
190+
{ serializedBaseURL: "https://example.com/", specifier: "https://example.com/app/helper", specifierAsURL: new URL("https://example.com/app/helper") },
191+
];
192+
ImportMap.merge(old, newMap, resolved);
193+
// lodash is already in old, so new lodash is dropped; /app/helper matches resolved → also dropped
194+
assert.equal(old.imports.get("lodash")?.href, "https://esm.sh/lodash");
195+
assert.ok(!old.imports.has("https://example.com/app/helper"));
196+
});
197+
198+
it("drops new scope rules that match an already-resolved module from that scope", () => {
199+
const old = new ImportMap();
200+
const newMap = parse({
201+
scopes: { "https://example.com/app/": { "/app/helper": "./helper/index.mjs" } },
202+
imports: { lodash: "https://esm.sh/lodash" },
203+
});
204+
const resolved: SpecifierResolutionRecord[] = [
205+
{
206+
serializedBaseURL: "https://example.com/app/main.mjs",
207+
specifier: "https://example.com/app/helper",
208+
specifierAsURL: new URL("https://example.com/app/helper"),
209+
},
210+
];
211+
ImportMap.merge(old, newMap, resolved);
212+
const scope = old.scopes.get("https://example.com/app/");
213+
assert.ok(!scope?.has("https://example.com/app/helper"), "scoped rule should be dropped");
214+
assert.equal(old.imports.get("lodash")?.href, "https://esm.sh/lodash");
215+
});
216+
217+
it("merges scopes, combining existing and new when scope prefix already exists", () => {
218+
const old = parse({ scopes: { "https://example.com/app/": { react: "https://esm.sh/react@17" } } });
219+
const newMap = parse({ scopes: { "https://example.com/app/": { vue: "https://esm.sh/vue" } } });
220+
ImportMap.merge(old, newMap, []);
221+
const scope = old.scopes.get("https://example.com/app/");
222+
assert.ok(scope?.has("react"));
223+
assert.ok(scope?.has("vue"));
224+
});
225+
226+
it("merges integrity (first-wins)", () => {
227+
const old = parse({ integrity: { "https://example.com/a.js": "sha384-old" } });
228+
const newMap = parse({
229+
integrity: {
230+
"https://example.com/a.js": "sha384-new",
231+
"https://example.com/b.js": "sha384-b",
232+
},
233+
});
234+
ImportMap.merge(old, newMap, []);
235+
assert.equal(old.integrity.get("https://example.com/a.js"), "sha384-old");
236+
assert.equal(old.integrity.get("https://example.com/b.js"), "sha384-b");
60237
});
61238
});

0 commit comments

Comments
 (0)