From 730171bd8740d834974d68401fc4b267ca74c7bd Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 1 Mar 2026 19:06:34 +0800 Subject: [PATCH 1/6] Refactor add.ts --- src/add.ts | 344 ++++++++++++++++++++++++++--------------------------- 1 file changed, 168 insertions(+), 176 deletions(-) diff --git a/src/add.ts b/src/add.ts index 0124746..765f1ee 100644 --- a/src/add.ts +++ b/src/add.ts @@ -4,23 +4,36 @@ import type { ImportMap } from "./importmap.ts"; type ImportInfo = { name: string; version: string; - subPath: string; - github: boolean; - jsr: boolean; - external: boolean; - dev: boolean; + subPath?: string; + github?: boolean; + jsr?: boolean; + pr?: boolean; + external?: boolean; + dev?: boolean; }; type ImportMeta = ImportInfo & { module: string; integrity: string; - exports: string[]; - imports: string[]; - peerImports: string[]; + dts?: string; + exports?: string[]; + imports?: string[]; + peerImports?: string[]; }; type Fetcher = (url: string | URL) => Promise; +let fetch: Fetcher = globalThis.fetch; + +/** + * Set the fetcher to use for fetching import meta. + * + * @param fetcher - The fetcher to use. + */ +export function setFetcher(fetcher: Fetcher): void { + fetch = fetcher; +} + const KNOWN_TARGETS = new Set([ "es2015", "es2016", @@ -35,7 +48,7 @@ const KNOWN_TARGETS = new Set([ "esnext", ]); -const ESM_SEGMENTS = new Set([ +const ESM_TARGETS = new Set([ "es2015", "es2016", "es2017", @@ -53,7 +66,7 @@ const ESM_SEGMENTS = new Set([ ]); const SPECIFIER_MARK_SEPARATOR = "\x00"; -const META_CACHE = new Map>(); +const META_CACHE_MEMO = new Map>(); /** * Add an import from esm.sh CDN to the import map. @@ -109,10 +122,13 @@ async function addImportImpl( pruneEmptyScopes(importMap); } - const allDeps = [ - ...imp.peerImports.map((pathname) => ({ pathname, isPeer: true })), - ...imp.imports.map((pathname) => ({ pathname, isPeer: false })), - ]; + let allDeps: { pathname: string; isPeer: boolean }[] = []; + if (imp.peerImports) { + allDeps.push(...imp.peerImports.map((pathname) => ({ pathname, isPeer: true }))); + } + if (imp.imports) { + allDeps.push(...imp.imports.map((pathname) => ({ pathname, isPeer: false }))); + } await Promise.all( allDeps.map(async ({ pathname, isPeer }) => { @@ -199,45 +215,6 @@ async function updateIntegrity( } } -function parseImportSpecifier(specifier: string): ImportInfo { - let source = specifier.trim(); - const imp: ImportInfo = { - name: "", - version: "", - subPath: "", - github: false, - jsr: false, - external: false, - dev: false, - }; - - if (source.startsWith("gh:")) { - imp.github = true; - source = source.slice(3); - } else if (source.startsWith("jsr:")) { - imp.jsr = true; - source = source.slice(4); - } - - let scopeName = ""; - if ((source.startsWith("@") || imp.github) && source.includes("/")) { - [scopeName, source] = splitByFirst(source, "/"); - } - - let packageAndVersion = ""; - [packageAndVersion, imp.subPath] = splitByFirst(source, "/"); - [imp.name, imp.version] = splitByFirst(packageAndVersion, "@"); - if (scopeName) { - imp.name = scopeName + "/" + imp.name; - } - - if (!imp.name) { - throw new Error("invalid package name or version: " + specifier); - } - - return imp; -} - function normalizeTarget(target: string | undefined): string { if (target && KNOWN_TARGETS.has(target)) { return target; @@ -268,26 +245,123 @@ function esmSpecifierOf(imp: ImportMeta): string { return prefix + external + imp.name + "@" + imp.version; } -function registryPrefix(imp: ImportInfo): string { - if (imp.github) { - return "gh/"; +function parseImportSpecifier(specifier: string): ImportInfo { + const imp: ImportInfo = { name: "", version: "" }; + + let source = specifier.trim(); + if (source.startsWith("gh:")) { + imp.github = true; + source = source.slice(3); + } else if (source.startsWith("jsr:")) { + imp.jsr = true; + source = source.slice(4); + } else if (source.startsWith("pr:")) { + imp.pr = true; + source = source.slice(3); } - if (imp.jsr) { - return "jsr/"; + + let scopeName = ""; + if (source.startsWith("@") || imp.github) { + const index = source.indexOf("/"); + if (index === -1) { + throw new Error("invalid specifiern: " + specifier); + } + scopeName = source.slice(0, index); + source = source.slice(index + 1); } - return ""; + + let [maybePkgNameAndVersion, ...subPath] = source.split("/"); + let [pkgNameNoScope, pkgVersion] = maybePkgNameAndVersion.split("@", 2); + if (scopeName) { + imp.name = scopeName + "/" + pkgNameNoScope; + imp.version = pkgVersion; + } else { + imp.name = pkgNameNoScope; + imp.version = pkgVersion; + } + imp.subPath = subPath.join("/"); + + if (!imp.name) { + throw new Error("invalid package name or version: " + specifier); + } + + return imp; } -function hasExternalImports(meta: ImportMeta): boolean { - if (meta.peerImports.length > 0) { - return true; +function parseEsmPath(pathnameOrUrl: string): ImportInfo { + let pathname: string; + if (pathnameOrUrl.startsWith("https://") || pathnameOrUrl.startsWith("http://")) { + pathname = new URL(pathnameOrUrl).pathname; + } else if (pathnameOrUrl.startsWith("/")) { + pathname = pathnameOrUrl.split("#")[0].split("?")[0]; + } else { + throw new Error("invalid pathname or url: " + pathnameOrUrl); + } + + const imp: ImportInfo = { name: "", version: "" }; + + if (pathname.startsWith("/gh/")) { + imp.github = true; + pathname = pathname.slice(3); + } else if (pathname.startsWith("/jsr/")) { + imp.jsr = true; + pathname = pathname.slice(4); + } else if (pathname.startsWith("/pr/")) { + imp.pr = true; + pathname = pathname.slice(3); + } + + const segs = pathname.split("/").filter(Boolean); + if (segs.length === 0) { + throw new Error("invalid pathname: " + pathnameOrUrl); + } + + let seg0 = segs[0]; + if (seg0.startsWith("*")) { + seg0 = seg0.slice(1); + } + + let pkgNameNoScope: string; + let pkgVersion: string; + let subPath: string; + let hasTargetSegment: boolean; + + if (seg0.startsWith("@")) { + if (!segs[1]) { + throw new Error("invalid pathname: " + pathnameOrUrl); + } + [pkgNameNoScope, pkgVersion] = segs[1].split("@", 2); + imp.name = seg0 + "/" + pkgNameNoScope; + imp.version = pkgVersion; + hasTargetSegment = ESM_TARGETS.has(segs[2]); + subPath = segs.slice(hasTargetSegment ? 3 : 2).join("/"); + } else { + [pkgNameNoScope, pkgVersion] = seg0.split("@", 2); + imp.name = pkgNameNoScope; + imp.version = pkgVersion; + hasTargetSegment = ESM_TARGETS.has(segs[1]); + subPath = segs.slice(hasTargetSegment ? 2 : 1).join("/"); } - for (const dep of meta.imports) { - if (!dep.startsWith("/node/") && !dep.startsWith("/" + meta.name + "@")) { - return true; + + if (subPath) { + if (hasTargetSegment && subPath.endsWith(".mjs")) { + subPath = subPath.slice(0, -4); + if (subPath.endsWith(".development")) { + subPath = subPath.slice(0, -12); + imp.dev = true; + } + if (subPath !== pkgNameNoScope) { + if (subPath === "__" + pkgNameNoScope) { + subPath = pkgNameNoScope; + } + imp.subPath = subPath; + } + } else { + imp.subPath = subPath; } } - return false; + + return imp; } function moduleUrlOf(cdnOrigin: string, target: string, imp: ImportMeta): string { @@ -305,15 +379,31 @@ function moduleUrlOf(cdnOrigin: string, target: string, imp: ImportMeta): string return url + fileName + ".mjs"; } -let fetcher: Fetcher = globalThis.fetch; +function registryPrefix(imp: ImportInfo): string { + if (imp.github) { + return "gh/"; + } + if (imp.jsr) { + return "jsr/"; + } + if (imp.pr) { + return "pr/"; + } + return ""; +} -/** - * Set the fetcher to use for fetching import meta. - * - * @param f - The fetcher to use. - */ -export function setFetcher(f: Fetcher): void { - fetcher = f; +function hasExternalImports(meta: ImportMeta): boolean { + if (meta.peerImports && meta.peerImports.length > 0) { + return true; + } + if (meta.imports) { + for (const dep of meta.imports) { + if (!dep.startsWith("/node/") && !dep.startsWith("/" + meta.name + "@")) { + return true; + } + } + } + return false; } async function fetchImportMeta(cdnOrigin: string, imp: ImportInfo, target: string): Promise { @@ -323,13 +413,13 @@ async function fetchImportMeta(cdnOrigin: string, imp: ImportInfo, target: strin const targetQuery = target !== "es2022" ? "&target=" + encodeURIComponent(target) : ""; const url = cdnOrigin + "/" + star + registryPrefix(imp) + imp.name + version + subPath + "?meta" + targetQuery; - const cached = META_CACHE.get(url); + const cached = META_CACHE_MEMO.get(url); if (cached) { return cached; } const pending = (async () => { - const res = await fetcher(url); + const res = await fetch(url); if (res.status === 404) { throw new Error("package not found: " + imp.name + version + subPath); } @@ -360,11 +450,11 @@ async function fetchImportMeta(cdnOrigin: string, imp: ImportInfo, target: strin }; })(); - META_CACHE.set(url, pending); + META_CACHE_MEMO.set(url, pending); try { return await pending; } catch (error) { - META_CACHE.delete(url); + META_CACHE_MEMO.delete(url); throw error; } } @@ -400,101 +490,3 @@ function pruneScopeSpecifiersShadowedByImports(importMap: ImportMap): void { } } } - -function parseEsmPath(pathnameOrUrl: string): ImportInfo { - let pathname: string; - if (pathnameOrUrl.startsWith("https://") || pathnameOrUrl.startsWith("http://")) { - pathname = new URL(pathnameOrUrl).pathname; - } else if (pathnameOrUrl.startsWith("/")) { - pathname = splitByFirst(splitByFirst(pathnameOrUrl, "#")[0], "?")[0]; - } else { - throw new Error("invalid pathname or url: " + pathnameOrUrl); - } - - const imp: ImportInfo = { - name: "", - version: "", - subPath: "", - github: false, - jsr: false, - external: false, - dev: false, - }; - - if (pathname.startsWith("/gh/")) { - imp.github = true; - pathname = pathname.slice(3); - } else if (pathname.startsWith("/jsr/")) { - imp.jsr = true; - pathname = pathname.slice(4); - } - - const segs = pathname.split("/").filter(Boolean); - if (segs.length === 0) { - throw new Error("invalid pathname: " + pathnameOrUrl); - } - - if (segs[0]!.startsWith("@")) { - if (!segs[1]) { - throw new Error("invalid pathname: " + pathnameOrUrl); - } - const [name, version] = splitByLast(segs[1]!, "@"); - imp.name = trimLeadingStar(segs[0] + "/" + name); - imp.version = version; - segs.splice(0, 2); - } else { - const [name, version] = splitByLast(segs[0]!, "@"); - imp.name = trimLeadingStar(name); - imp.version = version; - segs.splice(0, 1); - } - - let hasTargetSegment = false; - if (segs[0] && ESM_SEGMENTS.has(segs[0]!)) { - hasTargetSegment = true; - segs.shift(); - } - - if (segs.length > 0) { - if (hasTargetSegment && pathname.endsWith(".mjs")) { - let subPath = segs.join("/"); - if (subPath.endsWith(".mjs")) { - subPath = subPath.slice(0, -4); - } - if (subPath.endsWith(".development")) { - subPath = subPath.slice(0, -12); - imp.dev = true; - } - if (subPath.includes("/") || (subPath !== imp.name && !imp.name.endsWith("/" + subPath))) { - imp.subPath = subPath; - } - } else { - imp.subPath = segs.join("/"); - } - } - - return imp; -} - -function trimLeadingStar(value: string): string { - if (value.startsWith("*")) { - return value.slice(1); - } - return value; -} - -function splitByFirst(value: string, separator: string): [string, string] { - const idx = value.indexOf(separator); - if (idx < 0) { - return [value, ""]; - } - return [value.slice(0, idx), value.slice(idx + separator.length)]; -} - -function splitByLast(value: string, separator: string): [string, string] { - const idx = value.lastIndexOf(separator); - if (idx < 0) { - return [value, ""]; - } - return [value.slice(0, idx), value.slice(idx + separator.length)]; -} From 2f44572ca54600ebc036dabc2b8aa8885dfefeb8 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 1 Mar 2026 19:22:44 +0800 Subject: [PATCH 2/6] Update src/add.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/add.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/add.ts b/src/add.ts index 765f1ee..2fe5e30 100644 --- a/src/add.ts +++ b/src/add.ts @@ -264,7 +264,7 @@ function parseImportSpecifier(specifier: string): ImportInfo { if (source.startsWith("@") || imp.github) { const index = source.indexOf("/"); if (index === -1) { - throw new Error("invalid specifiern: " + specifier); + throw new Error("invalid specifier: " + specifier); } scopeName = source.slice(0, index); source = source.slice(index + 1); From 03cc5307c804a1a47e1d3cccc0de457e45945fe8 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 1 Mar 2026 19:32:59 +0800 Subject: [PATCH 3/6] Add support for 'pr' prefix in import specifiers and update related tests --- src/add.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/add.ts | 17 ++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/add.test.ts b/src/add.test.ts index 715cd3a..9899b32 100644 --- a/src/add.test.ts +++ b/src/add.test.ts @@ -164,4 +164,39 @@ describe("addImport", () => { setFetcher(globalThis.fetch); } }); + + test("preserves pr registry prefix in specifier and URL generation", async () => { + const im = new ImportMap(); + const requests: string[] = []; + + setFetcher(async (url) => { + const text = url.toString(); + requests.push(text); + if (text === "https://esm.sh/pr/pkg@1?meta" || text === "https://esm.sh/pr/pkg@1.0.0?meta") { + return new Response( + JSON.stringify({ + name: "pkg", + version: "1.0.0", + module: "/pr/pkg@1.0.0/es2022/pkg.mjs", + integrity: "sha384-pr-pkg", + exports: [], + imports: [], + peerImports: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + + try { + await addImport(im, "pr:pkg@1"); + + expect(im.imports["pr:pkg"]).toBe("https://esm.sh/pr/pkg@1.0.0/es2022/pkg.mjs"); + expect(im.imports.pkg).toBeUndefined(); + expect(requests.some((url) => url.startsWith("https://esm.sh/pr/pkg@"))).toBeTrue(); + } finally { + setFetcher(globalThis.fetch); + } + }); }); diff --git a/src/add.ts b/src/add.ts index 2fe5e30..957368f 100644 --- a/src/add.ts +++ b/src/add.ts @@ -3,7 +3,7 @@ import type { ImportMap } from "./importmap.ts"; type ImportInfo = { name: string; - version: string; + version?: string; subPath?: string; github?: boolean; jsr?: boolean; @@ -235,12 +235,19 @@ function normalizeCdnOrigin(cdn: string | undefined): string { } function specifierOf(imp: ImportInfo): string { - const prefix = imp.github ? "gh:" : imp.jsr ? "jsr:" : ""; + let prefix = ""; + if (imp.github) { + prefix = "github:"; + } else if (imp.jsr) { + prefix = "jsr:"; + } else if (imp.pr) { + prefix = "pr:"; + } return prefix + imp.name + (imp.subPath ? "/" + imp.subPath : ""); } function esmSpecifierOf(imp: ImportMeta): string { - const prefix = imp.github ? "gh/" : imp.jsr ? "jsr/" : ""; + const prefix = imp.github ? "gh/" : imp.jsr ? "jsr/" : imp.pr ? "pr/" : ""; const external = hasExternalImports(imp) ? "*" : ""; return prefix + external + imp.name + "@" + imp.version; } @@ -252,6 +259,9 @@ function parseImportSpecifier(specifier: string): ImportInfo { if (source.startsWith("gh:")) { imp.github = true; source = source.slice(3); + } else if (source.startsWith("github:")) { + imp.github = true; + source = source.slice(7); } else if (source.startsWith("jsr:")) { imp.jsr = true; source = source.slice(4); @@ -440,6 +450,7 @@ async function fetchImportMeta(cdnOrigin: string, imp: ImportInfo, target: strin subPath: imp.subPath, github: imp.github, jsr: imp.jsr, + pr: imp.pr, external: imp.external, dev: imp.dev, module: data.module ?? "", From a271b8e2992f12135e67d2fa18ab6d276add2993 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 1 Mar 2026 21:23:49 +0800 Subject: [PATCH 4/6] remove pr prefix --- src/add.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/add.ts b/src/add.ts index 957368f..bc814df 100644 --- a/src/add.ts +++ b/src/add.ts @@ -7,7 +7,6 @@ type ImportInfo = { subPath?: string; github?: boolean; jsr?: boolean; - pr?: boolean; external?: boolean; dev?: boolean; }; @@ -237,17 +236,15 @@ function normalizeCdnOrigin(cdn: string | undefined): string { function specifierOf(imp: ImportInfo): string { let prefix = ""; if (imp.github) { - prefix = "github:"; + prefix = "gh:"; } else if (imp.jsr) { prefix = "jsr:"; - } else if (imp.pr) { - prefix = "pr:"; } return prefix + imp.name + (imp.subPath ? "/" + imp.subPath : ""); } function esmSpecifierOf(imp: ImportMeta): string { - const prefix = imp.github ? "gh/" : imp.jsr ? "jsr/" : imp.pr ? "pr/" : ""; + const prefix = imp.github ? "gh/" : imp.jsr ? "jsr/" : ""; const external = hasExternalImports(imp) ? "*" : ""; return prefix + external + imp.name + "@" + imp.version; } @@ -259,15 +256,9 @@ function parseImportSpecifier(specifier: string): ImportInfo { if (source.startsWith("gh:")) { imp.github = true; source = source.slice(3); - } else if (source.startsWith("github:")) { - imp.github = true; - source = source.slice(7); } else if (source.startsWith("jsr:")) { imp.jsr = true; source = source.slice(4); - } else if (source.startsWith("pr:")) { - imp.pr = true; - source = source.slice(3); } let scopeName = ""; @@ -316,9 +307,6 @@ function parseEsmPath(pathnameOrUrl: string): ImportInfo { } else if (pathname.startsWith("/jsr/")) { imp.jsr = true; pathname = pathname.slice(4); - } else if (pathname.startsWith("/pr/")) { - imp.pr = true; - pathname = pathname.slice(3); } const segs = pathname.split("/").filter(Boolean); @@ -396,9 +384,6 @@ function registryPrefix(imp: ImportInfo): string { if (imp.jsr) { return "jsr/"; } - if (imp.pr) { - return "pr/"; - } return ""; } @@ -450,7 +435,6 @@ async function fetchImportMeta(cdnOrigin: string, imp: ImportInfo, target: strin subPath: imp.subPath, github: imp.github, jsr: imp.jsr, - pr: imp.pr, external: imp.external, dev: imp.dev, module: data.module ?? "", From 4fca4bb47a91142b127b2e4cdd53323f88b2fca6 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 1 Mar 2026 21:25:02 +0800 Subject: [PATCH 5/6] fix --- src/add.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/add.ts b/src/add.ts index bc814df..1669a49 100644 --- a/src/add.ts +++ b/src/add.ts @@ -324,7 +324,7 @@ function parseEsmPath(pathnameOrUrl: string): ImportInfo { let subPath: string; let hasTargetSegment: boolean; - if (seg0.startsWith("@")) { + if (seg0.startsWith("@") || imp.github) { if (!segs[1]) { throw new Error("invalid pathname: " + pathnameOrUrl); } From 32d73b76936345446284837e2b8142679ef304ff Mon Sep 17 00:00:00 2001 From: Je Xia Date: Sun, 1 Mar 2026 21:25:44 +0800 Subject: [PATCH 6/6] fix test --- src/add.test.ts | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/src/add.test.ts b/src/add.test.ts index 9899b32..715cd3a 100644 --- a/src/add.test.ts +++ b/src/add.test.ts @@ -164,39 +164,4 @@ describe("addImport", () => { setFetcher(globalThis.fetch); } }); - - test("preserves pr registry prefix in specifier and URL generation", async () => { - const im = new ImportMap(); - const requests: string[] = []; - - setFetcher(async (url) => { - const text = url.toString(); - requests.push(text); - if (text === "https://esm.sh/pr/pkg@1?meta" || text === "https://esm.sh/pr/pkg@1.0.0?meta") { - return new Response( - JSON.stringify({ - name: "pkg", - version: "1.0.0", - module: "/pr/pkg@1.0.0/es2022/pkg.mjs", - integrity: "sha384-pr-pkg", - exports: [], - imports: [], - peerImports: [], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - } - return new Response("not found", { status: 404 }); - }); - - try { - await addImport(im, "pr:pkg@1"); - - expect(im.imports["pr:pkg"]).toBe("https://esm.sh/pr/pkg@1.0.0/es2022/pkg.mjs"); - expect(im.imports.pkg).toBeUndefined(); - expect(requests.some((url) => url.startsWith("https://esm.sh/pr/pkg@"))).toBeTrue(); - } finally { - setFetcher(globalThis.fetch); - } - }); });