From 6623a40137475dafaefaaadcd8744959ba1dc24a Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Wed, 28 Jan 2026 21:15:25 -0500 Subject: [PATCH] feat: add jsr resolution support Signed-off-by: Okiki Ojo --- core/_repl.ts | 1 + core/build.ts | 4 +- core/context.ts | 2 +- core/plugins/cdn.ts | 118 +++- core/plugins/tar.ts | 2 +- core/types.ts | 2 +- core/utils/cdn-format.ts | 666 ++++++++++++++++--- core/utils/resolve-conditions.ts | 460 ------------- edge/bundle.ts | 2 +- utils/deno.jsonc | 10 +- utils/jsr-spec.ts | 1029 ++++++++++++++++++++++++++++++ utils/mod.ts | 17 +- utils/npm-deps-spec.ts | 270 -------- utils/npm-search.ts | 565 ++++++++-------- utils/npm-spec.ts | 878 +++++++++++++++++++++++++ utils/parse-package-name.ts | 376 ++++++++++- utils/resolve-conditions.ts | 540 ++++++++++++++++ utils/resolve-import-map.ts | 725 +++++++++++++++++++++ utils/validate-package-name.ts | 324 ++++++++++ 19 files changed, 4901 insertions(+), 1090 deletions(-) delete mode 100644 core/utils/resolve-conditions.ts create mode 100644 utils/jsr-spec.ts delete mode 100644 utils/npm-deps-spec.ts create mode 100644 utils/npm-spec.ts create mode 100644 utils/resolve-conditions.ts create mode 100644 utils/resolve-import-map.ts create mode 100644 utils/validate-package-name.ts diff --git a/core/_repl.ts b/core/_repl.ts index 39c9e20..adc92b9 100644 --- a/core/_repl.ts +++ b/core/_repl.ts @@ -88,6 +88,7 @@ console.log("\n"); // await setFile(fs, "/new.tsx", "export * from \"@okikio/native\";"); await setFile(fs, "/new.tsx", "export * from \"https://pkg.pr.new/@tanstack/react-query@7988\"") await setFile(fs, "/new.tsx", "export * from 'iconv-lite';\nexport { default } from 'iconv-lite';") +// await setFile(fs, "/new.tsx", "export * from \"jsr:@okikio/sparql\";") // await setFile(fs, "/other.tsx", `\ // export * as Other from "/index.tsx"; // export * from "@okikio/emitter";`); diff --git a/core/build.ts b/core/build.ts index 41cf7b3..04c8756 100644 --- a/core/build.ts +++ b/core/build.ts @@ -162,9 +162,7 @@ export async function build(opts: BuildConfig = {}, filesystem: Promise { ctx.state.target.failedExtensionChecks.clear(); ctx.state.target.failedManifestUrls.clear(); ctx.state.target.sideEffectsMatchersCache.clear(); - ctx.state.target.filesystem?.clear?.(); + // ctx.state.target.filesystem?.clear?.(); // ctx.state.target.packageManifests.clear(); build_result = await ctx.rebuild(); diff --git a/core/plugins/cdn.ts b/core/plugins/cdn.ts index 189c926..1028112 100644 --- a/core/plugins/cdn.ts +++ b/core/plugins/cdn.ts @@ -5,6 +5,7 @@ * - Modern exports/imports resolution from package.json * - Legacy main/module/browser field resolution * - npm alias unwrapping (npm:package@version) + * - JSR specifiers (jsr:@scope/name@version) * - URL-based version routing (https://pkg.pr.new/...) * * @module @@ -18,6 +19,15 @@ * // https://unpkg.com/react@18.2.0/index.js * ``` * + * @example JSR import + * ```ts + * // In user code: + * import { join } from "jsr:@std/path@^1.0.0"; + * + * // Resolved directly to JSR registry: + * // https://jsr.io/@std/path/1.0.8/mod.ts + * ``` + * * @example npm alias in package.json * ```json * { @@ -70,7 +80,7 @@ import { joinSubpath, appendUrlSubpath, getUnsupportedSpecError, -} from "@bundle/utils/npm-deps-spec"; +} from "@bundle/utils/npm-spec"; import { computeEsbuildSideEffects } from "../utils/side-effects.ts"; import { extname, isBareImport, join } from "@bundle/utils/path"; @@ -81,7 +91,17 @@ import { determineExtension, HTTP_NAMESPACE } from "./http.ts"; import { dispatchEvent, LOGGER_WARN } from "../configs/events.ts"; import { getCDNUrl, getCDNStyle, DEFAULT_CDN_HOST } from "../utils/cdn-format.ts"; -import { getLegacyMainFields, getResolverConditions } from "../utils/resolve-conditions.ts"; +import { getLegacyMainFields, getResolverConditions } from "@bundle/utils/resolve-conditions"; + +// JSR (jsr.io) support +import { + parseJSRSpec, + looksLikeJSRSpec, + getJSRModuleUrl, + resolveJSRVersion, + getJSRVersionMeta, + jsrToEsmSh, +} from "../../utils/jsr-spec.ts"; /** CDN Plugin Namespace */ export const CDN_NAMESPACE = "cdn-url"; @@ -191,6 +211,100 @@ export function CdnResolution(StateContext: Context>) { } catch (_) { } } + // ======================================================================== + // Handle JSR specifiers (jsr:@scope/name@version/subpath) + // ======================================================================== + + if (looksLikeJSRSpec(argPath)) { + const jsrSpec = parseJSRSpec(argPath); + + if (jsrSpec) { + try { + // Resolve version range to exact version if needed + let resolvedVersion = jsrSpec.version; + if (!resolvedVersion || resolvedVersion.includes("^") || resolvedVersion.includes("~") || resolvedVersion === "latest") { + resolvedVersion = await resolveJSRVersion({ + scope: jsrSpec.scope, + name: jsrSpec.name, + version: jsrSpec.version, + }); + } + + if (!resolvedVersion) { + return { + errors: [{ + text: `Failed to resolve JSR version: ${argPath}`, + detail: `Could not find a version matching "${jsrSpec.version || "latest"}" for @${jsrSpec.scope}/${jsrSpec.name}`, + }], + }; + } + + // Get version metadata to resolve exports + let resolvedSubpath = jsrSpec.subpath || "/mod.ts"; + + try { + const versionMeta = await getJSRVersionMeta(jsrSpec.scope, jsrSpec.name, resolvedVersion); + + // If subpath provided, try to resolve through exports + if (jsrSpec.subpath && versionMeta.exports) { + // Normalize subpath for exports lookup (./foo or .) + const exportsKey = jsrSpec.subpath === "/" ? "." : `.${jsrSpec.subpath}`; + const altKey = jsrSpec.subpath.replace(/^\//, "./"); + + if (versionMeta.exports[exportsKey]) { + resolvedSubpath = versionMeta.exports[exportsKey]; + } else if (versionMeta.exports[altKey]) { + resolvedSubpath = versionMeta.exports[altKey]; + } + // If no match, use the subpath directly + } else if (!jsrSpec.subpath && versionMeta.exports?.["."]) { + // Default export + resolvedSubpath = versionMeta.exports["."]; + } + } catch { + // If we can't get version meta, fall back to subpath or default + } + + // Generate direct JSR module URL + const moduleUrl = getJSRModuleUrl( + jsrSpec.scope, + jsrSpec.name, + resolvedVersion, + resolvedSubpath + ); + + // Resolve through HTTP plugin + const pathWithExt = await determineExtension(moduleUrl); + + return { + namespace: HTTP_NAMESPACE, + path: pathWithExt.url, + pluginData: Object.assign({}, args.pluginData, { + manifest: { + name: jsrSpec.fullName, + version: resolvedVersion, + // JSR packages can have their own dependencies we might need to track + peerDependencies: initialManifest?.peerDependencies ?? {}, + }, + }), + }; + } catch (e) { + // If direct JSR resolution fails, fall back to esm.sh proxy + dispatchEvent(LOGGER_WARN, `JSR direct resolution failed for ${argPath}, falling back to esm.sh proxy`); + dispatchEvent(LOGGER_WARN, e); + + const esmShUrl = jsrToEsmSh(jsrSpec); + const pathWithExt = await determineExtension(esmShUrl); + + return { + namespace: HTTP_NAMESPACE, + path: pathWithExt.url, + pluginData: args.pluginData, + }; + } + } + } + // ======================================================================== // Handle bare imports (react, @scope/pkg, lodash/get) // ======================================================================== diff --git a/core/plugins/tar.ts b/core/plugins/tar.ts index fac1e96..db9c770 100644 --- a/core/plugins/tar.ts +++ b/core/plugins/tar.ts @@ -31,7 +31,7 @@ import { fetchWithCache } from "@bundle/utils/fetch-and-cache"; import { VIRTUAL_FILESYSTEM_NAMESPACE } from "./fs.ts"; import { dispatchEvent, LOGGER_INFO, LOGGER_WARN, LOGGER_ERROR } from "../configs/events.ts"; -import { getResolverConditions } from "../utils/resolve-conditions.ts"; +import { getResolverConditions } from "../../utils/resolve-conditions.ts"; import { setFile, getFile } from "../utils/filesystem.ts"; import { getCDNStyle } from "../utils/cdn-format.ts"; diff --git a/core/types.ts b/core/types.ts index f56a036..93e4e46 100644 --- a/core/types.ts +++ b/core/types.ts @@ -6,7 +6,7 @@ import type { InitOptions } from "./init.ts"; import type { record } from "./context/context.ts"; import type { IFileSystem } from "./utils/filesystem.ts"; import type { TarballState } from "./plugins/tar.ts"; -import type { ResolverConditionInputs } from "./utils/resolve-conditions.ts"; +import type { ResolverConditionInputs } from "../utils/resolve-conditions.ts"; import type { SideEffectsMatchers } from "./utils/side-effects.ts"; export type { ESBUILD, ESBUILD_WASM }; diff --git a/core/utils/cdn-format.ts b/core/utils/cdn-format.ts index 19246ec..038eba6 100644 --- a/core/utils/cdn-format.ts +++ b/core/utils/cdn-format.ts @@ -1,110 +1,628 @@ /** - * The default CDN host + * CDN Format Utilities + * + * Utilities for working with various JavaScript CDNs and registries. + * Supports npm CDNs (unpkg, esm.sh, jsdelivr, skypack), JSR, GitHub, and Deno. + * + * This module provides the URL handling logic for bundlejs, enabling + * cross-platform package resolution from multiple sources. + * + * @module + * + * @example CDN Style Detection + * ```ts + * import { getCDNStyle, getCDNOrigin, getCDNUrl } from "./cdn-format.ts"; + * + * getCDNStyle("esm:react") + * // "npm" + * + * getCDNStyle("jsr:@std/path") + * // "jsr" + * + * getCDNStyle("https://jsr.io/@std/path/1.0.0/mod.ts") + * // "jsr" + * + * getCDNStyle("github:user/repo") + * // "github" + * ``` + * + * @example URL Generation + * ```ts + * getCDNUrl("jsr:@std/path@1.0.0") + * // { + * // import: "jsr:@std/path@1.0.0", + * // path: "@std/path@1.0.0", + * // origin: "https://jsr.io/", + * // cdn: "https://jsr.io/", + * // url: URL { href: "https://jsr.io/@std/path@1.0.0" } + * // } + * + * getCDNUrl("esm:react@18") + * // { + * // import: "esm:react@18", + * // path: "react@18", + * // origin: "https://esm.sh/", + * // cdn: "https://esm.sh/", + * // url: URL { href: "https://esm.sh/react@18" } + * // } + * ``` + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * CDN style categories. + * + * Different CDNs have different URL patterns and capabilities: + * - `npm`: Supports semver versions in URLs (unpkg, esm.sh, jsdelivr, skypack) + * - `jsr`: JSR registry - TypeScript-first, direct module access + * - `github`: Raw GitHub file access (jsdelivr.gh, raw.githubusercontent.com) + * - `deno`: Deno's deno.land/x registry + * - `tarball`: Tarball providers (pkg.pr.new for PR previews) + * - `other`: Unknown CDN patterns + */ +export type CDNStyle = "npm" | "jsr" | "github" | "deno" | "tarball" | "other"; + +/** + * URL scheme prefixes that indicate a specific CDN. + */ +export type CDNScheme = + | "skypack" + | "esm" + | "esm.sh" + | "unpkg" + | "jsdelivr" + | "esm.run" + | "jsdelivr.gh" + | "github" + | "deno" + | "jsr"; + +/** + * Result from getCDNUrl. + */ +export interface CDNUrlResult { + /** Original import string */ + import: string; + /** Pure import path (without scheme/host) */ + path: string; + /** CDN origin URL (with trailing /) */ + origin: string; + /** Default CDN that was used */ + cdn: string; + /** Full resolved URL */ + url: URL; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Default CDN host for npm packages. */ export const DEFAULT_CDN_HOST = "https://unpkg.com"; /** - * Returns the CDN style supported by certain CDN's - * e.g. - * - `npm` - unpkg, skypack, esm.sh, and jsdelivr all support `npm` style imports for example they support adding versions to their URLs like this `https://unpkg.com/@okikio/animate@beta` - * - `github` - github, and jsdelivr.gh both support `github` style imports, as in they don't support adding versions to their URLs like this `https://cdn.jsdelivr.net/gh/jquery/jquery/dist/jquery.min.js` - * - `deno` - deno supports `deno` style imports, as in they don't support adding versions to their URLs like this `https://deno.land/x/brotli/mod.ts` - * - `tarball` - tarball package providers such as `pkg.pr.new` - * - `other` - CDNs that haven't been added to the list + * JSR registry base URL. */ -export const getCDNStyle = (urlStr: string) => { +export const JSR_REGISTRY = "https://jsr.io"; + +/** + * Map of CDN schemes to their origin URLs. + * + * @example + * ```ts + * CDN_SCHEME_TO_ORIGIN["esm"] + * // "https://esm.sh" + * + * CDN_SCHEME_TO_ORIGIN["jsr"] + * // "https://jsr.io" + * ``` + */ +export const CDN_SCHEME_TO_ORIGIN: Record = { + skypack: "https://cdn.skypack.dev", + esm: "https://esm.sh", + "esm.sh": "https://esm.sh", + unpkg: "https://unpkg.com", + jsdelivr: "https://cdn.jsdelivr.net/npm", + "esm.run": "https://cdn.jsdelivr.net/npm", + "jsdelivr.gh": "https://cdn.jsdelivr.net/gh", + github: "https://raw.githubusercontent.com", + deno: "https://deno.land/x", + jsr: "https://jsr.io", +}; + +// ============================================================================= +// CDN Style Detection +// ============================================================================= + +/** + * Detect the CDN style from a URL or scheme. + * + * Returns the category of CDN, which determines how URLs should be handled: + * - `npm`: Package + version in URL, npm registry compatible + * - `jsr`: JSR registry, TypeScript-first + * - `github`: Raw GitHub file access + * - `deno`: Deno's deno.land/x + * - `tarball`: Tarball providers like pkg.pr.new + * - `other`: Unknown + * + * @param urlStr URL string or scheme prefix + * @returns CDN style category + * + * @example Scheme detection + * ```ts + * getCDNStyle("esm:react") // "npm" + * getCDNStyle("esm.sh:react") // "npm" + * getCDNStyle("unpkg:lodash") // "npm" + * getCDNStyle("skypack:react") // "npm" + * getCDNStyle("jsdelivr:axios") // "npm" + * getCDNStyle("esm.run:vue") // "npm" + * ``` + * + * @example JSR detection + * ```ts + * getCDNStyle("jsr:@std/path") // "jsr" + * getCDNStyle("https://jsr.io/@std/path/1.0.0/mod.ts") // "jsr" + * ``` + * + * @example GitHub detection + * ```ts + * getCDNStyle("github:user/repo/file.js") // "github" + * getCDNStyle("jsdelivr.gh:user/repo/file.js") // "github" + * getCDNStyle("https://raw.githubusercontent.com/user/repo") // "github" + * getCDNStyle("https://cdn.jsdelivr.net/gh/user/repo") // "github" + * ``` + * + * @example Deno detection + * ```ts + * getCDNStyle("deno:oak") // "deno" + * getCDNStyle("https://deno.land/x/oak/mod.ts") // "deno" + * ``` + * + * @example Tarball detection + * ```ts + * getCDNStyle("https://pkg.pr.new/user/repo@commit") // "tarball" + * ``` + */ +export function getCDNStyle(urlStr: string): CDNStyle { + // JSR - check first as it's a distinct ecosystem + if ( + /^jsr\:/.test(urlStr) || + /^https?:\/\/(jsr\.io)/.test(urlStr) + ) { + return "jsr"; + } + + // npm-style CDNs (unpkg, esm.sh, jsdelivr/npm, skypack) if ( /^(skypack|esm|esm\.sh|unpkg|jsdelivr|esm\.run)\:?/.test(urlStr) || - /^https?:\/\/(cdn\.skypack\.dev|cdn\.esm\.sh|cdn\.jsdelivr\.net\/npm|unpkg\.com)/.test(urlStr) - ) return "npm"; + /^https?:\/\/(cdn\.skypack\.dev|cdn\.esm\.sh|esm\.sh|cdn\.jsdelivr\.net\/npm|unpkg\.com)/.test(urlStr) + ) { + return "npm"; + } - else if ( + // GitHub-style (raw files) + if ( /^(jsdelivr\.gh|github)\:?/.test(urlStr) || /^https?:\/\/(cdn\.jsdelivr\.net\/gh|raw\.githubusercontent\.com)/.test(urlStr) - ) return "github"; + ) { + return "github"; + } - else if ( - /^(deno)\:?/.test(urlStr) || - /^https?:\/\/(deno\.land\/x)/.test(urlStr) - ) return "deno"; + // Deno-style + if ( + /^deno\:/.test(urlStr) || + /^https?:\/\/deno\.land\/x/.test(urlStr) + ) { + return "deno"; + } - else if ( - /^https?:\/\/(pkg\.pr\.new)/.test(urlStr) - ) return "tarball"; + // Tarball providers (PR previews, etc.) + if (/^https?:\/\/pkg\.pr\.new/.test(urlStr)) { + return "tarball"; + } return "other"; -}; +} /** - * Based on the URL scheme of the import, this method will return an actual CDN host origin to use, - * e.g. + * Check if a URL or scheme uses a specific CDN style. + * + * @param urlStr URL string or scheme prefix + * @param style CDN style to check + * @returns True if the URL matches the style + * + * @example * ```ts - * getCDNHost("react") //= https://unpkg.com - * getCDNHost("react", "https://cdn.skypack.dev") //= https://cdn.skypack.dev/ - * - * // CDN URL Schemes take precedence above everything - * getCDNHost("esm:react", "https://cdn.skypack.dev") //= https://cdn.esm.sh/ + * isCDNStyle("esm:react", "npm") // true + * isCDNStyle("jsr:@std/path", "jsr") // true + * isCDNStyle("jsr:@std/path", "npm") // false * ``` - * - * > _**Note**: The returned CDN URL string will end with a '/' e.g. `https://cdn.esm.sh/`_ - * - * @param importStr imports to find a CDN for - * @param cdn The default CDN host to use. This can change based on the config of the user. This may be diregarded if the `importStr` has a CDN URL Scheme - * @returns CDN URL host string */ -export const getCDNOrigin = (importStr: string, cdn = DEFAULT_CDN_HOST) => { - // `skypack:` --> `https://cdn.skypack.dev` - if (/^skypack\:/.test(importStr)) - cdn = "https://cdn.skypack.dev"; - - // `jsr:` --> `https://esm.sh` - else if (/^(jsr)\:/.test(importStr)) - cdn = `https://esm.sh/jsr`; - - // `esm.sh:` or `esm:` --> `https://esm.sh` - else if (/^(esm\.sh|esm)\:/.test(importStr)) - cdn = "https://esm.sh"; - - // `unpkg:` --> `https://unpkg.com` - else if (/^unpkg\:/.test(importStr)) - cdn = "https://unpkg.com"; - - // (NPM) `jsdelivr:` or `esm.run:` --> `https://cdn.jsdelivr.net/npm` - else if (/^(jsdelivr|esm\.run)\:/.test(importStr)) - cdn = "https://cdn.jsdelivr.net/npm"; +export function isCDNStyle(urlStr: string, style: CDNStyle): boolean { + return getCDNStyle(urlStr) === style; +} - // (GitHub) `jsdelivr.gh:` --> `https://cdn.jsdelivr.net/gh` - else if (/^(jsdelivr\.gh)\:/.test(importStr)) - cdn = "https://cdn.jsdelivr.net/gh"; +// ============================================================================= +// CDN Origin Resolution +// ============================================================================= - // `deno:` --> `https://deno.land/x` - else if (/^(deno)\:/.test(importStr)) - cdn = "https://deno.land/x"; +/** + * Get the CDN origin URL for an import string. + * + * Handles scheme prefixes (esm:, jsr:, etc.) and falls back to the + * provided default CDN. + * + * @param importStr Import string (may include scheme prefix) + * @param cdn Default CDN host to use if no scheme present + * @returns CDN origin URL (always ends with /) + * + * @example Scheme prefixes + * ```ts + * getCDNOrigin("skypack:react") + * // "https://cdn.skypack.dev/" + * + * getCDNOrigin("esm:react") + * // "https://esm.sh/" + * + * getCDNOrigin("esm.sh:react") + * // "https://esm.sh/" + * + * getCDNOrigin("unpkg:lodash") + * // "https://unpkg.com/" + * + * getCDNOrigin("jsdelivr:axios") + * // "https://cdn.jsdelivr.net/npm/" + * + * getCDNOrigin("esm.run:vue") + * // "https://cdn.jsdelivr.net/npm/" + * ``` + * + * @example JSR + * ```ts + * getCDNOrigin("jsr:@std/path") + * // "https://jsr.io/" + * ``` + * + * @example GitHub + * ```ts + * getCDNOrigin("jsdelivr.gh:user/repo") + * // "https://cdn.jsdelivr.net/gh/" + * + * getCDNOrigin("github:user/repo") + * // "https://raw.githubusercontent.com/" + * ``` + * + * @example Deno + * ```ts + * getCDNOrigin("deno:oak") + * // "https://deno.land/x/" + * ``` + * + * @example Default fallback + * ```ts + * getCDNOrigin("react") + * // "https://unpkg.com/" (default) + * + * getCDNOrigin("react", "https://cdn.esm.sh") + * // "https://cdn.esm.sh/" + * ``` + */ +export function getCDNOrigin(importStr: string, cdn = DEFAULT_CDN_HOST): string { + // Check for scheme prefixes + if (/^skypack\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.skypack; + } else if (/^jsr\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.jsr; + } else if (/^(esm\.sh|esm)\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.esm; + } else if (/^unpkg\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.unpkg; + } else if (/^(jsdelivr|esm\.run)\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.jsdelivr; + } else if (/^jsdelivr\.gh\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN["jsdelivr.gh"]; + } else if (/^deno\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.deno; + } else if (/^github\:/.test(importStr)) { + cdn = CDN_SCHEME_TO_ORIGIN.github; + } - // `github:` --> `https://raw.githubusercontent.com` - else if (/^(github)\:/.test(importStr)) - cdn = "https://raw.githubusercontent.com"; + // Ensure trailing slash + return cdn.endsWith("/") ? cdn : `${cdn}/`; +} - return /\/$/.test(cdn) ? cdn : `${cdn}/`; -}; +// ============================================================================= +// Path Extraction +// ============================================================================= /** - * Remove CDN URL Schemes like `deno:...`, `unpkg:...`, etc... and known CDN hosts, e.g. `https://raw.githubusercontent.com/...`, `https://cdn.skypack.dev/...`, etc... Leaving only the import path + * Remove CDN scheme prefixes and known CDN hosts from an import string. + * + * Returns just the package/file path portion. + * + * @param importStr Import string + * @returns Pure import path + * + * @example Scheme removal + * ```ts + * getPureImportPath("esm:react@18") + * // "react@18" + * + * getPureImportPath("jsr:@std/path@1.0.0") + * // "@std/path@1.0.0" + * + * getPureImportPath("unpkg:lodash@4.17.0/get") + * // "lodash@4.17.0/get" + * ``` + * + * @example URL host removal + * ```ts + * getPureImportPath("https://esm.sh/react@18") + * // "react@18" + * + * getPureImportPath("https://jsr.io/@std/path/1.0.0/mod.ts") + * // "@std/path/1.0.0/mod.ts" + * + * getPureImportPath("https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js") + * // "lodash@4/lodash.min.js" + * ``` */ -export const getPureImportPath = (importStr: string) => - importStr +export function getPureImportPath(importStr: string): string { + return importStr + // Remove scheme prefixes .replace(/^(skypack|esm|esm\.sh|unpkg|jsdelivr|jsdelivr\.gh|esm\.run|deno|github|jsr)\:/, "") - .replace(/^(https?:\/\/)?(cdn\.skypack\.dev|(cdn\.)?esm\.sh|(cdn\.)?esm\.sh\/jsr|cdn\.jsdelivr\.net\/npm|unpkg\.com|cdn\.jsdelivr\.net\/gh|raw\.githubusercontent\.com|deno\.land\/x)/, "") + // Remove known CDN hosts + .replace( + /^(https?:\/\/)?(cdn\.skypack\.dev|(cdn\.)?esm\.sh|cdn\.jsdelivr\.net\/npm|unpkg\.com|cdn\.jsdelivr\.net\/gh|raw\.githubusercontent\.com|deno\.land\/x|jsr\.io)/, + "" + ) + // Remove leading slash .replace(/^\//, ""); +} + +// ============================================================================= +// URL Generation +// ============================================================================= /** - * Generates a CDN URL for an import, taking advantage of CDN URL Schemes in imports and the default CDN hosts parameter to decide the CDN host - * Read through {@link getCDNOrigin} and {@link getPureImportPath} + * Generate a full CDN URL for an import. + * + * Combines origin resolution and path extraction to produce + * a complete URL object. + * + * @param importStr Import string (with optional scheme prefix) + * @param cdn Default CDN host + * @returns CDN URL result with all components + * + * @example npm packages + * ```ts + * getCDNUrl("react@18") + * // { + * // import: "react@18", + * // path: "react@18", + * // origin: "https://unpkg.com/", + * // cdn: "https://unpkg.com", + * // url: URL { href: "https://unpkg.com/react@18" } + * // } + * + * getCDNUrl("esm:react@18") + * // { + * // import: "esm:react@18", + * // path: "react@18", + * // origin: "https://esm.sh/", + * // cdn: "https://unpkg.com", // default unchanged + * // url: URL { href: "https://esm.sh/react@18" } + * // } + * ``` + * + * @example JSR packages + * ```ts + * getCDNUrl("jsr:@std/path@1.0.0") + * // { + * // import: "jsr:@std/path@1.0.0", + * // path: "@std/path@1.0.0", + * // origin: "https://jsr.io/", + * // cdn: "https://unpkg.com", + * // url: URL { href: "https://jsr.io/@std/path@1.0.0" } + * // } + * ``` + * + * @example GitHub files + * ```ts + * getCDNUrl("github:user/repo/main/file.js") + * // { + * // import: "github:user/repo/main/file.js", + * // path: "user/repo/main/file.js", + * // origin: "https://raw.githubusercontent.com/", + * // cdn: "https://unpkg.com", + * // url: URL { href: "https://raw.githubusercontent.com/user/repo/main/file.js" } + * // } + * ``` */ -export const getCDNUrl = (importStr: string, cdn = DEFAULT_CDN_HOST) => { +export function getCDNUrl(importStr: string, cdn = DEFAULT_CDN_HOST): CDNUrlResult { const origin = getCDNOrigin(importStr, cdn); const path = getPureImportPath(importStr); const url = new URL(path, origin); - return { import: importStr, path, origin, cdn, url }; -}; + return { + import: importStr, + path, + origin, + cdn, + url, + }; +} + +// ============================================================================= +// JSR-Specific Utilities +// ============================================================================= + +/** + * Parse a JSR specifier into its components. + * + * JSR specifiers have the format: jsr:@scope/name@version/subpath + * + * @param specifier JSR specifier string + * @returns Parsed components or null if invalid + * + * @example + * ```ts + * parseJSRSpecifier("jsr:@std/path@1.0.0") + * // { scope: "std", name: "path", version: "1.0.0", subpath: "" } + * + * parseJSRSpecifier("jsr:@std/path@1.0.0/posix") + * // { scope: "std", name: "path", version: "1.0.0", subpath: "/posix" } + * + * parseJSRSpecifier("jsr:@std/path") + * // { scope: "std", name: "path", version: null, subpath: "" } + * + * parseJSRSpecifier("npm:lodash") + * // null (not a JSR specifier) + * ``` + */ +export function parseJSRSpecifier(specifier: string): { + scope: string; + name: string; + version: string | null; + subpath: string; +} | null { + const match = /^jsr:@([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)(?:@([^\/]+))?(\/.*)?$/.exec(specifier); + if (!match) return null; + + const [, scope, name, version, subpath] = match; + return { + scope, + name, + version: version ?? null, + subpath: subpath ?? "", + }; +} + +/** + * Generate a direct JSR module URL. + * + * Creates a URL that can be used directly in import statements + * without needing a CDN proxy like esm.sh. + * + * @param scope Package scope (without @) + * @param name Package name + * @param version Version string + * @param path File path (default: /mod.ts) + * @returns Direct JSR URL + * + * @example + * ```ts + * getJSRDirectUrl("std", "path", "1.0.0") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * + * getJSRDirectUrl("std", "path", "1.0.0", "/posix.ts") + * // "https://jsr.io/@std/path/1.0.0/posix.ts" + * + * // Can be used directly in Deno: + * // import { join } from "https://jsr.io/@std/path/1.0.0/mod.ts"; + * ``` + */ +export function getJSRDirectUrl( + scope: string, + name: string, + version: string, + path = "/mod.ts" +): string { + const cleanPath = path.startsWith("/") ? path : `/${path}`; + return `${JSR_REGISTRY}/@${scope}/${name}/${version}${cleanPath}`; +} + +/** + * Generate a JSR URL via esm.sh proxy. + * + * Useful for environments that can't use jsr.io directly. + * + * @param scope Package scope (without @) + * @param name Package name + * @param version Version string (optional) + * @param subpath Subpath (optional) + * @returns esm.sh proxy URL + * + * @example + * ```ts + * getJSRProxyUrl("std", "path", "1.0.0") + * // "https://esm.sh/jsr/@std/path@1.0.0" + * + * getJSRProxyUrl("std", "path", "1.0.0", "/posix") + * // "https://esm.sh/jsr/@std/path@1.0.0/posix" + * + * getJSRProxyUrl("std", "path") + * // "https://esm.sh/jsr/@std/path" + * ``` + */ +export function getJSRProxyUrl( + scope: string, + name: string, + version?: string, + subpath = "" +): string { + const versionPart = version ? `@${version}` : ""; + return `https://esm.sh/jsr/@${scope}/${name}${versionPart}${subpath}`; +} + +// ============================================================================= +// Utility Checks +// ============================================================================= + +/** + * Check if a string is a JSR specifier. + * + * @param str String to check + * @returns True if it's a jsr: specifier + * + * @example + * ```ts + * isJSRSpecifier("jsr:@std/path") // true + * isJSRSpecifier("npm:lodash") // false + * isJSRSpecifier("@std/path") // false + * ``` + */ +export function isJSRSpecifier(str: string): boolean { + return str.startsWith("jsr:"); +} + +/** + * Check if a string is an npm CDN URL or scheme. + * + * @param str String to check + * @returns True if it's an npm CDN URL/scheme + * + * @example + * ```ts + * isNpmCDN("esm:react") // true + * isNpmCDN("https://unpkg.com/react") // true + * isNpmCDN("https://esm.sh/lodash") // true + * isNpmCDN("jsr:@std/path") // false + * isNpmCDN("https://raw.githubusercontent.com") // false + * ``` + */ +export function isNpmCDN(str: string): boolean { + return getCDNStyle(str) === "npm"; +} + +/** + * Check if a string is a GitHub raw URL or scheme. + * + * @param str String to check + * @returns True if it's a GitHub URL/scheme + * + * @example + * ```ts + * isGitHubRaw("github:user/repo") // true + * isGitHubRaw("https://raw.githubusercontent.com") // true + * isGitHubRaw("https://github.com/user/repo") // false (not raw) + * ``` + */ +export function isGitHubRaw(str: string): boolean { + return getCDNStyle(str) === "github"; +} \ No newline at end of file diff --git a/core/utils/resolve-conditions.ts b/core/utils/resolve-conditions.ts deleted file mode 100644 index 797a00c..0000000 --- a/core/utils/resolve-conditions.ts +++ /dev/null @@ -1,460 +0,0 @@ -// core/utils/resolve-conditions.ts -import type { PackageJson } from "@bundle/utils/types"; -import type { ESBUILD } from "../types.ts"; - -/** - * A high-level “runtime profile” that influences resolution. - * - * Why this exists: - * - Many runtimes are *not* captured by esbuild’s `platform` alone. - * - Many packages use conditional exports like `"react-native"`, `"electron"`, - * `"workerd"`, `"edge-light"`, `"deno"`, etc. - * - * This type intentionally does NOT map 1:1 to esbuild `platform`: - * - `"edge-light"` might be “web-like” but still not the same as `"browser"`. - * - `"workerd"` is “worker-like” but often wants different fallbacks than `"browser"`. - * - * This overlay lets you express “what runtime am I building for?” without - * mutating `esbuildOpts.conditions` (which would change esbuild’s default `"module"` behavior). - */ -export type ResolveRuntime = - | "react-native" - | "electron-main" - | "electron-renderer" - | "deno" - | "workerd" - | "edge-light"; - -/** - * Inputs used to compute resolver conditions and legacy mainFields ordering. - * - * Design intent: - * - Keep the current esbuild-faithful behavior by default. - * - Add a separate “runtime overlay” that can inject additional conditions - * without changing how `esbuildOpts.conditions` behaves. - * - * Important nuance: - * - In the current resolver, *setting* `esbuildOpts.conditions` disables - * the “auto-add `"module"`” behavior. - * - This is desirable because it preserves esbuild semantics: - * “If the user sets conditions explicitly, we won’t add implicit ones.” - * - * The runtime overlay exists specifically so you can add runtime conditions - * (like `"react-native"` or `"workerd"`) without turning off auto `"module"`. - */ -export interface ResolverConditionInputs { - platform?: ESBUILD.BuildOptions["platform"]; - conditions?: ESBUILD.BuildOptions["conditions"]; - format?: ESBUILD.BuildOptions["format"]; - - /** - * Optional: used by legacy (main/module/browser field) fallback. - * If omitted, we emulate esbuild's defaults per-platform. - */ - mainFields?: ESBUILD.BuildOptions["mainFields"]; - - /** - * Runtime overlay: - * Adds common conditions and mainFields behavior for a known target runtime. - * - * This does NOT behave like `esbuildOpts.conditions`. - * In particular: - * - It does not disable auto `"module"`. - * - It is intended to be an additive “profile” layer. - * - * Example: - * - runtime: "react-native" will add the `"react-native"` condition and may - * choose to disable `browser` field mapping (because RN is not “the browser”). - */ - runtime?: ResolveRuntime; - - /** - * Additional overlay conditions to apply in addition to the runtime profile. - * - * Use this when: - * - A runtime is “close” but you want to add one more condition. - * - You want to A/B test a condition without adding a new runtime enum. - * - * Examples: - * - runtime: "workerd", runtimeConditions: ["edge-light"] - * - runtime: "deno", runtimeConditions: ["worker"] - * - * These are additive and do NOT disable auto `"module"`. - */ - runtimeConditions?: readonly string[]; -} - -/** - * The computed condition result used by your resolver. - * - * - `browser`: Whether the resolver should treat `package.json#browser` mappings as active. - * - `require`: Whether we’re in a “require-like” context (CJS), which influences - * both exports conditions and legacy main field ordering. - * - `conditions`: The final condition set used for conditional exports / imports resolution. - */ -export interface ResolverConditions { - /** - * Whether to respect the `browser` field and browser mappings. - * - * In esbuild, the browser field is interpreted when platform === "browser". - * It is not part of platform === "neutral" behavior. - */ - browser: boolean; - - /** - * Whether we are resolving for a CommonJS "require" context. - */ - require: boolean; - - /** - * Ordered, de-duped list of conditions to test against package.json "exports"/"imports". - * Includes "default" explicitly for downstream resolvers that want it. - * - * Notes: - * - `default` is always active in esbuild and is intended as a fallback. - * - `import` vs `require` is context-dependent (import statement/import() vs require()). - * - `browser` and `node` are platform-gated. - * - `module` is auto-included only for platform browser|node when no custom conditions exist. - */ - conditions: string[]; -} - -/** - * Runtime default policy output. - * - * - `conditions`: Conditions to add to exports/imports resolution. - * - `browserField`: Whether to enable `package.json#browser` field mapping. - * - `null` means “don’t override; let platform decide”. - */ -export interface RuntimeDefaults { - conditions: string[]; - browserField: boolean | null; -} - - -/** - * Get runtime-specific default overlays. - * - * This is intentionally a policy function: - * - You can tweak these defaults without touching core resolution logic. - * - * Notes about ecosystem reality: - * - Some condition names are “de facto” community standards, not official specs. - * - Many packages use them anyway, so having a predictable policy helps. - * - * If you disagree with any default: - * - Use `runtimeConditions` to add extra conditions. - * - Or create a new runtime profile in `ResolveRuntime`. - */ -export function getRuntimeDefaults(runtime: ResolveRuntime | undefined): RuntimeDefaults { - if (!runtime) { - return { conditions: [], browserField: null }; - } - - switch (runtime) { - case "react-native": - // React Native / Metro commonly uses `"react-native"` in conditional exports. - // It is NOT “browser”, so we default to disabling browser field mapping. - return { conditions: ["react-native"], browserField: false }; - - case "electron-main": - // Electron main process behaves like Node, but packages may branch for electron. - return { conditions: ["electron"], browserField: false }; - - case "electron-renderer": - // Renderer is web-like in many apps, so `browser` mappings are often correct. - return { conditions: ["electron"], browserField: true }; - - case "deno": - // Deno can satisfy `"deno"` and often supports `"node"` via compat mode. - // If you want to force “no node compat”, remove `"node"` here or override. - return { conditions: ["deno", "node"], browserField: false }; - - case "workerd": - // Cloudflare / workerd deployments may use `"workerd"` and/or `"worker"`. - // We default to disabling browser mappings because workers are not DOM runtimes. - return { conditions: ["workerd", "worker"], browserField: false }; - - case "edge-light": - // Edge runtimes are typically “web-ish”; `browser` mappings often help. - return { conditions: ["edge-light"], browserField: true }; - } -} - -/** - * Returns true when the import kind represents a Node-style `require()` usage. - * - * This is used to decide whether we should include `"require"` or `"import"` in the - * conditions list, and influences which legacy fields we prefer. - */ -export function isRequireKind(kind: ESBUILD.ImportKind): boolean { - return kind === "require-call" || kind === "require-resolve"; -} - -/** - * Returns true if the current resolution context should be treated as CommonJS. - * - * Plain English: - * - If the import was triggered by `require()` or `require.resolve()`, it’s require-context. - * - If it’s an entry point and the output format is `"cjs"`, also treat as require-context. - * - * Why entry-point is special: - * - esbuild’s `args.kind` for entry points is `"entry-point"`, which doesn’t encode - * CJS vs ESM. Your chosen output format does. - * - * Example: - * - args.kind === "entry-point" - * - esbuildOpts.format === "cjs" - * => we treat it as require-context so `"require"` is used. - */ -export function isRequireContext( - args: Pick, - esbuildOpts: Pick, -): boolean { - if (isRequireKind(args.kind)) return true; - if (args.kind === "entry-point") return esbuildOpts.format === "cjs"; - return false; -} - -/** - * Compute the set of conditions used to resolve package `"exports"` and `"imports"`. - * - * This function is a small “policy engine” that combines: - * 1) Require vs Import (`"require"` or `"import"`) - * 2) Platform (`"browser"` and/or `"node"`) - * 3) esbuild’s implicit `"module"` condition (only when user did NOT specify conditions) - * 4) Runtime overlay (`runtime` and `runtimeConditions`) - * 5) User conditions (`esbuildOpts.conditions`) - * 6) Always includes `"default"` - * - * The most important rule: - * - If the user explicitly sets `esbuildOpts.conditions`, we do not auto-add `"module"`. - * This preserves esbuild behavior and avoids surprising condition sets. - * - * Examples - * -------- - * - * Example 1: Default browser build (no explicit conditions) - * ```ts - * const c = getResolverConditions( - * { kind: "import-statement" }, - * { platform: "browser", format: "esm" }, - * ); - * // c.conditions includes: ["import", "browser", "module", "default"] - * // c.browser === true - * ``` - * - * Example 2: React Native overlay without disabling auto `"module"` - * ```ts - * const c = getResolverConditions( - * { kind: "import-statement" }, - * { platform: "browser", format: "esm", runtime: "react-native" }, - * ); - * // c.conditions includes: ["import", "browser", "module", "react-native", "default"] - * // c.browser === false (browser field mapping disabled by RN policy) - * ``` - * - * Example 3: User sets explicit esbuild conditions (auto `"module"` stops) - * ```ts - * const c = getResolverConditions( - * { kind: "import-statement" }, - * { platform: "browser", format: "esm", conditions: ["react-server"] }, - * ); - * // c.conditions includes: ["import", "browser", "react-server", "default"] - * // Notice: "module" is NOT auto-added because user provided `conditions`. - * ``` - */ -export function getResolverConditions( - args: Pick, - esbuildOpts: ResolverConditionInputs, -): ResolverConditions { - const platform = esbuildOpts.platform ?? "browser"; - - // `require` impacts both exports conditions and legacy mainFields ordering. - const require = isRequireContext(args, esbuildOpts); - - // Your existing behavior: Cache/Browser field mapping is enabled by default - // when platform is "browser". This is a boolean policy switch you pass onward. - let browserField = platform === "browser"; - - // This line preserves your esbuild-faithful behavior: - // if the user supplied conditions, we treat that as authoritative and stop - // auto-adding "module". - const userProvidedConditions = typeof esbuildOpts.conditions !== "undefined"; - - // Your existing rule: only auto-add "module" for browser/node platforms, - // and only if user did not supply conditions. - const mayAutoAddModule = - !userProvidedConditions && (platform === "browser" || platform === "node"); - - const computed: string[] = []; - - // 1) import-vs-require dimension - computed.push(require ? "require" : "import"); - - // 2) platform dimension - if (platform === "browser") computed.push("browser"); - if (platform === "node") computed.push("node"); - - // 3) esbuild-style implicit bundler condition - if (mayAutoAddModule) computed.push("module"); - - // 4) runtime overlay (additive, does NOT affect mayAutoAddModule) - const runtimeDefaults = getRuntimeDefaults(esbuildOpts.runtime); - if (runtimeDefaults.browserField !== null) { - browserField = runtimeDefaults.browserField; - } - for (const c of runtimeDefaults.conditions) computed.push(c); - - // 5) extra runtime overlay conditions (also additive) - if (esbuildOpts.runtimeConditions?.length) { - for (const c of esbuildOpts.runtimeConditions) computed.push(c); - } - - // 6) explicit user conditions (these DO imply “no implicit module”) - if (esbuildOpts.conditions?.length) { - for (const c of esbuildOpts.conditions) computed.push(c); - } - - // 7) default always active - computed.push("default"); - - return { - browser: browserField, - require, - conditions: dedupePreserveOrder(computed), - }; -} - -/** - * Compute the “legacy” main fields ordering (for packages that do not resolve via `"exports"`). - * - * Plain English: - * - If `"exports"` resolution fails (or is absent), you fall back to legacy fields: - * `"browser"`, `"module"`, `"main"`, etc. - * - Different runtimes tend to want different priorities. - * - This function centralizes that policy in one place. - * - * Rules: - * - If user provides `esbuildOpts.mainFields`, we return that as authoritative. - * - Otherwise, we compute defaults based on platform + require-context + runtime overlay. - * - * Examples - * -------- - * - * Example 1: Browser ESM default - * ```ts - * const fields = getLegacyMainFields( - * { main: "./cjs.js", module: "./esm.js", browser: "./browser.js" }, - * { kind: "import-statement" }, - * { platform: "browser", format: "esm" }, - * ); - * // ["browser", "module", "main"] - * ``` - * - * Example 2: React Native overlay prefers "react-native" field first - * ```ts - * const fields = getLegacyMainFields( - * { main: "./cjs.js", module: "./esm.js", "react-native": "./rn.js" }, - * { kind: "import-statement" }, - * { platform: "browser", format: "esm", runtime: "react-native" }, - * ); - * // ["react-native", "browser", "module", "main"] - * ``` - */ -export function getLegacyMainFields( - manifest: Pick & Record, - args: Pick, - esbuildOpts: Pick, -): string[] { - // User override takes precedence, consistent with esbuild. - if (esbuildOpts.mainFields) return Array.from(esbuildOpts.mainFields); - - const platform = esbuildOpts.platform ?? "browser"; - const require = isRequireContext(args, esbuildOpts); - - // Runtime overlays for legacy fields: - // This is the place you capture “ecosystem reality” for packages that haven't - // fully migrated to conditional exports. - - if (esbuildOpts.runtime === "react-native") { - // Many packages still expose a top-level "react-native" legacy field. - // Prefer it first to match Metro/RN expectations. - const hasReactNativeEntrypoint = typeof manifest["react-native"] === "string"; - - // If we're in require-context and there's no RN entrypoint, we prefer - // main before module to avoid accidentally selecting ESM in a CJS-only path. - if (require && !hasReactNativeEntrypoint) { - return ["react-native", "browser", "main", "module"]; - } - - // Otherwise ESM-first fallback (module before main). - return ["react-native", "browser", "module", "main"]; - } - - if (esbuildOpts.runtime === "electron-renderer") { - // Renderer: browser mappings tend to be desirable; ESM-first for imports. - if (require) return ["browser", "main", "module"]; - return ["browser", "module", "main"]; - } - - if (esbuildOpts.runtime === "electron-main") { - // Main: Node-like. Keep ordering conservative. - return ["main", "module"]; - } - - if (esbuildOpts.runtime === "edge-light") { - // Edge runtimes are commonly ESM-first and "web-ish". - return ["browser", "module", "main"]; - } - - if (esbuildOpts.runtime === "workerd") { - // Workerd / workers: typically ESM-first, and "browser" mappings can be risky - // because they may assume DOM APIs or bundle in browser-only shims. - return ["module", "main"]; - } - - if (esbuildOpts.runtime === "deno") { - // Deno: ESM-first fallback. - return ["module", "main"]; - } - - // Default behavior (no runtime overlay): - if (platform === "node") { - return ["main", "module"]; - } - - if (platform === "browser") { - const hasBrowserEntrypoint = typeof (manifest as { browser?: unknown }).browser === "string"; - - // This is your existing nuance: - // If we're in require-context AND browser is NOT a string entrypoint, - // prefer main over module to avoid forcing ESM onto a CJS path. - if (require && !hasBrowserEntrypoint) { - return ["browser", "main", "module"]; - } - - return ["browser", "module", "main"]; - } - - return []; -} - -/** - * Dedupe an array of strings while preserving order. - * - * Why: - * - Condition lists are effectively sets, but order matters for debugging and for - * deterministic tests. - * - We want stable output and no accidental duplicates. - */ -export function dedupePreserveOrder(values: readonly string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const v of values) { - if (seen.has(v)) continue; - seen.add(v); - out.push(v); - } - return out; -} \ No newline at end of file diff --git a/edge/bundle.ts b/edge/bundle.ts index 83583b6..bda5710 100644 --- a/edge/bundle.ts +++ b/edge/bundle.ts @@ -68,7 +68,7 @@ export async function bundle(url: URL, initialValue: string, configObj: Config, const result = await build(configObj, FileSystem); const end = performance.now(); - debounce(async () => await fs?.clear?.(), 1000 * 60); + await fs?.clear?.(); let resultValue: string = result.contents[0].text; const entryPointInputFile = Array.isArray(entryPoints) ? entryPoints[0] diff --git a/utils/deno.jsonc b/utils/deno.jsonc index 8ae3489..5c4413c 100644 --- a/utils/deno.jsonc +++ b/utils/deno.jsonc @@ -22,14 +22,18 @@ "./lru": "./lru.ts", "./archive-spec": "./archive-spec.ts", "./archive-detect": "./archive-detect.ts", - "./npm-deps-spec": "./npm-deps-spec.ts", + "./npm-spec": "./npm-spec.ts", + "./npm-search": "./npm-search.ts", + "./jsr-spec": "./jsr-spec.ts", "./lz-string": "./lz-string.ts", "./media-types": "./media-types.ts", - "./npm-search": "./npm-search.ts", "./outdent": "./outdent.ts", "./parse-package-name": "./parse-package-name.ts", - "./path": "./path.ts", "./resolve-exports-imports": "./resolve-exports-imports.ts", + "./resolve-conditions": "./resolve-conditions.ts", + "./resolve-import-map": "./resolve-import-map.ts", + "./runtime-builtins": "./runtime-builtins.ts", + "./path": "./path.ts", "./semver": "./semver.ts", "./types": "./types.ts", "./url": "./url.ts" diff --git a/utils/jsr-spec.ts b/utils/jsr-spec.ts new file mode 100644 index 0000000..ba9e824 --- /dev/null +++ b/utils/jsr-spec.ts @@ -0,0 +1,1029 @@ +/** + * JSR (jsr.io) Registry Utilities + * + * Complete utilities for working with the JSR (JavaScript Registry): + * - Package specifier parsing with validation + * - Direct registry API access (no esm.sh proxy needed) + * - Package metadata and version resolution + * - Search functionality + * - npm compatibility layer support + * + * JSR is a modern TypeScript-first registry that works with Deno, Node.js, + * Bun, browsers, and more. Unlike npm, JSR packages are always scoped. + * + * ## API Summary + * + * JSR has three distinct APIs: + * 1. **Registry API** (jsr.io) - For downloading modules and package metadata + * - Module: `https://jsr.io/@///` + * - Package meta: `https://jsr.io/@//meta.json` + * - Version meta: `https://jsr.io/@//_meta.json` + * + * 2. **npm Compatibility API** (npm.jsr.io) - For npm-compatible package managers + * - Package: `https://npm.jsr.io/@jsr/__` + * + * 3. **Management API** (api.jsr.io) - For publishing and search (NOT for registry operations) + * - Search: `https://api.jsr.io/packages?query=...` + * + * @module + * + * @example Basic usage + * ```ts + * import { + * parseJSRSpec, + * isJSRSpec, + * getJSRModuleUrl, + * resolveJSRVersion, + * } from "./jsr.ts"; + * + * // Parse a JSR specifier + * const spec = parseJSRSpec("jsr:@std/path@^1.0.0/posix"); + * // { kind: "jsr", scope: "std", name: "path", version: "^1.0.0", subpath: "/posix" } + * + * // Check if a string is a JSR specifier + * isJSRSpec("jsr:@std/path") // true + * isJSRSpec("npm:lodash") // false + * + * // Get direct module URL (no proxy needed!) + * getJSRModuleUrl("std", "path", "1.0.0", "/mod.ts") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * + * // Resolve version ranges + * await resolveJSRVersion({ scope: "std", name: "path", version: "^1.0.0" }) + * // "1.0.8" (or whatever the latest matching version is) + * ``` + * + * @see https://jsr.io/docs/api + * @see https://jsr.io/docs/packages + */ + +import { fetchWithCache } from "./fetch-and-cache.ts"; +import { maxSatisfying, parse, parseRange, format } from "./semver.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed JSR package specifier. + * + * @example + * ```ts + * const spec: JSRSpec = { + * kind: "jsr", + * raw: "jsr:@std/path@1.0.0/posix", + * scope: "std", // WITHOUT @ prefix (matches registry API) + * name: "path", + * fullName: "@std/path", // WITH @ prefix for display/import maps + * version: "1.0.0", + * subpath: "/posix" + * }; + * ``` + */ +export interface JSRSpec { + /** Always "jsr" for JSR specifiers */ + kind: "jsr"; + /** Original raw specifier string */ + raw: string; + /** Package scope WITHOUT @ prefix (e.g., "std" from "@std/path") */ + scope: string; + /** Package name without scope (e.g., "path" from "@std/path") */ + name: string; + /** Full package name with scope (e.g., "@std/path") */ + fullName: string; + /** Version or version range, null if not specified */ + version: string | null; + /** Subpath import (e.g., "/posix"), empty string if none */ + subpath: string; +} + +/** + * JSR package metadata from the registry API. + * + * Fetched from: `https://jsr.io/@//meta.json` + * + * @example API Response + * ```json + * { + * "scope": "std", + * "name": "path", + * "versions": { + * "1.0.0": {}, + * "1.0.1": { "yanked": true }, + * "1.0.2": {} + * } + * } + * ``` + */ +export interface JSRPackageMeta { + /** Scope name without @ */ + scope: string; + /** Package name */ + name: string; + /** Map of version strings to version metadata */ + versions: Record; +} + +/** + * Version summary in package metadata. + */ +export interface JSRVersionSummary { + /** Whether this version has been yanked */ + yanked?: boolean; +} + +/** + * JSR version metadata from the registry API. + * + * Fetched from: `https://jsr.io/@//_meta.json` + * + * @example API Response + * ```json + * { + * "manifest": { + * "/mod.ts": { + * "size": 2989, + * "checksum": "sha256-a41796ceb..." + * } + * }, + * "exports": { ".": "./mod.ts" }, + * "moduleGraph1": { "/mod.ts": {} } + * } + * ``` + */ +export interface JSRVersionMeta { + /** File manifest with sizes and checksums */ + manifest: Record; + /** Module graph for preloading (version 1 format) */ + moduleGraph1?: Record>; + /** Module graph for preloading (version 2 format) */ + moduleGraph2?: Record>; + /** Package exports mapping */ + exports: Record; +} + +/** + * File information in version manifest. + */ +export interface JSRFileInfo { + /** File size in bytes */ + size: number; + /** SHA-256 checksum with algorithm prefix */ + checksum: string; +} + +/** + * npm compatibility metadata from npm.jsr.io. + * + * Fetched from: `https://npm.jsr.io/@jsr/__` + */ +export interface JSRNpmMeta { + /** npm compatibility name (@jsr/scope__name) */ + name: string; + /** Package description */ + description?: string; + /** Distribution tags (e.g., latest) */ + "dist-tags": Record; + /** Version metadata map */ + versions: Record; + /** Publication timestamps */ + time: Record; +} + +/** + * npm compatibility version metadata. + */ +export interface JSRNpmVersionMeta { + /** npm compatibility name */ + name: string; + /** Version string */ + version: string; + /** Package description */ + description?: string; + /** Distribution info */ + dist: { + tarball: string; + integrity?: string; + shasum?: string; + }; + /** Dependencies */ + dependencies?: Record; +} + +/** + * Package name validation result. + */ +export interface JSRNameValidation { + /** Whether the name is valid */ + valid: boolean; + /** Validation errors (name cannot be used) */ + errors: string[]; + /** Validation warnings (name works but has issues) */ + warnings: string[]; +} + +/** + * Search result from JSR management API. + * + * Note: Search uses api.jsr.io, which is separate from the registry API. + */ +export interface JSRSearchResult { + /** Search result items */ + items: JSRPackageInfo[]; + /** Total number of matching packages */ + total: number; +} + +/** + * Package info from search results. + */ +export interface JSRPackageInfo { + /** Scope name (without @) */ + scope: string; + /** Package name */ + name: string; + /** Package description */ + description?: string; + /** Latest version */ + latestVersion?: string; + /** Runtime compatibility flags */ + runtimeCompat?: { + deno?: boolean; + node?: boolean; + browsers?: boolean; + bun?: boolean; + workerd?: boolean; + }; + /** JSR score (0-100) */ + score?: number; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** JSR registry base URL for direct module access */ +export const JSR_REGISTRY = "https://jsr.io"; + +/** JSR management API base URL (for search, NOT for registry operations) */ +export const JSR_API = "https://api.jsr.io"; + +/** JSR npm compatibility registry URL */ +export const JSR_NPM_REGISTRY = "https://npm.jsr.io"; + +/** + * JSR scope naming constraints. + * + * From https://jsr.io/docs/scopes: + * - 2-20 characters + * - Lowercase letters, numbers, hyphens + * - Cannot start with a hyphen + */ +export const JSR_SCOPE_RULES = { + minLength: 2, + maxLength: 20, + pattern: /^[a-z][a-z0-9-]*$/, +} as const; + +/** + * JSR package naming constraints. + * + * From https://jsr.io/docs/packages: + * - 2-58 characters + * - Lowercase letters, numbers, hyphens + * - Cannot start with a hyphen + */ +export const JSR_PACKAGE_RULES = { + minLength: 2, + maxLength: 58, + pattern: /^[a-z][a-z0-9-]*$/, +} as const; + +/** + * Pattern for parsing JSR specifiers. + * + * Matches: `jsr:@scope/name@version/subpath` + * - Scope is required (starts with @) + * - Version is optional + * - Subpath is optional + * + * Uses strict lowercase validation for scope and name. + */ +const JSR_SPEC_PATTERN = /^jsr:@([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)(?:@([^\/]+))?(\/.*)?$/; + +// ============================================================================= +// Parsing +// ============================================================================= + +/** + * Parse a JSR specifier string. + * + * JSR specifiers follow the format: `jsr:@scope/name@version/subpath` + * + * @param input The specifier string + * @returns Parsed spec or null if invalid + * + * @example Valid specifiers + * ```ts + * parseJSRSpec("jsr:@std/path") + * // { + * // kind: "jsr", + * // raw: "jsr:@std/path", + * // scope: "std", // Note: WITHOUT @ prefix + * // name: "path", + * // fullName: "@std/path", + * // version: null, + * // subpath: "" + * // } + * + * parseJSRSpec("jsr:@std/path@1.0.0") + * // { ..., version: "1.0.0", subpath: "" } + * + * parseJSRSpec("jsr:@std/path@^1.0.0/posix") + * // { ..., version: "^1.0.0", subpath: "/posix" } + * ``` + * + * @example Invalid specifiers + * ```ts + * parseJSRSpec("npm:lodash") // null (wrong protocol) + * parseJSRSpec("jsr:lodash") // null (missing scope) + * parseJSRSpec("@std/path") // null (missing jsr: prefix) + * parseJSRSpec("jsr:@STD/path") // null (uppercase not allowed) + * ``` + */ +export function parseJSRSpec(input: string): JSRSpec | null { + if (!input || typeof input !== "string") { + return null; + } + + const trimmed = input.trim(); + + // Must start with jsr: + if (!trimmed.startsWith("jsr:")) { + return null; + } + + const match = JSR_SPEC_PATTERN.exec(trimmed); + if (!match) { + return null; + } + + const [, scope, name, version, subpath] = match; + + return { + kind: "jsr", + raw: input, + scope, + name, + fullName: `@${scope}/${name}`, + version: version ?? null, + subpath: subpath ?? "", + }; +} + +/** + * Check if a string is a JSR specifier. + * + * @param input String to check + * @returns True if it's a valid jsr: specifier + * + * @example + * ```ts + * isJSRSpec("jsr:@std/path") // true + * isJSRSpec("npm:lodash") // false + * isJSRSpec("@std/path") // false (missing prefix) + * ``` + */ +export function isJSRSpec(input: string): boolean { + return parseJSRSpec(input) !== null; +} + +/** + * Check if a string looks like a JSR specifier (quick check). + * + * Faster than parseJSRSpec for filtering. + */ +export function looksLikeJSRSpec(input: string): boolean { + return typeof input === "string" && input.startsWith("jsr:"); +} + +// ============================================================================= +// Name Validation +// ============================================================================= + +/** + * Validate a JSR scope name. + * + * @param scope Scope name (with or without @ prefix) + * @returns Validation result + * + * @example + * ```ts + * validateJSRScope("std") + * // { valid: true, errors: [], warnings: [] } + * + * validateJSRScope("a") + * // { valid: false, errors: ["Scope must be 2-20 characters (got 1)"], warnings: [] } + * ``` + */ +export function validateJSRScope(scope: string): JSRNameValidation { + const errors: string[] = []; + const warnings: string[] = []; + const name = scope.startsWith("@") ? scope.slice(1) : scope; + + if (name.length < JSR_SCOPE_RULES.minLength || name.length > JSR_SCOPE_RULES.maxLength) { + errors.push(`Scope must be ${JSR_SCOPE_RULES.minLength}-${JSR_SCOPE_RULES.maxLength} characters (got ${name.length})`); + } + + if (name.startsWith("-")) { + errors.push("Scope cannot start with a hyphen"); + } + + if (!JSR_SCOPE_RULES.pattern.test(name)) { + errors.push("Scope must contain only lowercase letters, numbers, and hyphens"); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * Validate a JSR package name. + * + * @param name Package name (without scope) + * @returns Validation result + */ +export function validateJSRPackageName(name: string): JSRNameValidation { + const errors: string[] = []; + const warnings: string[] = []; + + if (name.length < JSR_PACKAGE_RULES.minLength || name.length > JSR_PACKAGE_RULES.maxLength) { + errors.push(`Package name must be ${JSR_PACKAGE_RULES.minLength}-${JSR_PACKAGE_RULES.maxLength} characters (got ${name.length})`); + } + + if (name.startsWith("-")) { + errors.push("Package name cannot start with a hyphen"); + } + + if (!JSR_PACKAGE_RULES.pattern.test(name)) { + errors.push("Package name must contain only lowercase letters, numbers, and hyphens"); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * Validate a full JSR package identifier. + * + * @param fullName Full package name (e.g., "@std/path") + * @returns Validation result combining scope and name checks + */ +export function validateJSRFullName(fullName: string): JSRNameValidation { + const errors: string[] = []; + const warnings: string[] = []; + + if (!fullName.startsWith("@")) { + errors.push("JSR package names must start with @ (all packages are scoped)"); + return { valid: false, errors, warnings }; + } + + const slashCount = (fullName.match(/\//g) || []).length; + if (slashCount !== 1) { + errors.push("JSR package names must be in format @scope/name"); + return { valid: false, errors, warnings }; + } + + const [scope, name] = fullName.slice(1).split("/"); + + const scopeResult = validateJSRScope(scope); + errors.push(...scopeResult.errors.map(e => `Scope: ${e}`)); + warnings.push(...scopeResult.warnings.map(w => `Scope: ${w}`)); + + const nameResult = validateJSRPackageName(name); + errors.push(...nameResult.errors.map(e => `Name: ${e}`)); + warnings.push(...nameResult.warnings.map(w => `Name: ${w}`)); + + return { valid: errors.length === 0, errors, warnings }; +} + +// ============================================================================= +// URL Generation +// ============================================================================= + +/** + * Get the package metadata URL. + * + * @param scope Package scope (with or without @) + * @param name Package name + * @returns Metadata URL + * + * @example + * ```ts + * getJSRPackageMetaUrl("std", "path") + * // "https://jsr.io/@std/path/meta.json" + * ``` + */ +export function getJSRPackageMetaUrl(scope: string, name: string): string { + const cleanScope = scope.startsWith("@") ? scope : `@${scope}`; + return `${JSR_REGISTRY}/${cleanScope}/${name}/meta.json`; +} + +/** + * Get the version metadata URL. + * + * @param scope Package scope + * @param name Package name + * @param version Version string + * @returns Version metadata URL + * + * @example + * ```ts + * getJSRVersionMetaUrl("std", "path", "1.0.0") + * // "https://jsr.io/@std/path/1.0.0_meta.json" + * ``` + */ +export function getJSRVersionMetaUrl(scope: string, name: string, version: string): string { + const cleanScope = scope.startsWith("@") ? scope : `@${scope}`; + return `${JSR_REGISTRY}/${cleanScope}/${name}/${version}_meta.json`; +} + +/** + * Get the direct module URL for ESM import. + * + * This URL can be used directly in import statements - no esm.sh needed! + * + * @param scope Package scope + * @param name Package name + * @param version Version string + * @param path File path within the package + * @returns Direct module URL + * + * @example + * ```ts + * getJSRModuleUrl("std", "path", "1.0.0", "/mod.ts") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * + * // Can be used directly in Deno: + * import { join } from "https://jsr.io/@std/path/1.0.0/mod.ts"; + * ``` + */ +export function getJSRModuleUrl( + scope: string, + name: string, + version: string, + path: string = "/mod.ts" +): string { + const cleanScope = scope.startsWith("@") ? scope : `@${scope}`; + const cleanPath = path.startsWith("/") ? path : `/${path}`; + return `${JSR_REGISTRY}/${cleanScope}/${name}/${version}${cleanPath}`; +} + +/** + * Get the npm compatibility package URL. + * + * @param scope Package scope + * @param name Package name + * @returns npm registry URL + * + * @example + * ```ts + * getJSRNpmUrl("std", "path") + * // "https://npm.jsr.io/@jsr/std__path" + * ``` + */ +export function getJSRNpmUrl(scope: string, name: string): string { + const cleanScope = scope.startsWith("@") ? scope.slice(1) : scope; + return `${JSR_NPM_REGISTRY}/@jsr/${cleanScope}__${name}`; +} + +/** + * Get the tarball URL for npm compatibility. + */ +export function getJSRTarballUrl(scope: string, name: string, version: string): string { + const cleanScope = scope.startsWith("@") ? scope.slice(1) : scope; + return `${JSR_NPM_REGISTRY}/@jsr/${cleanScope}__${name}/${version}.tgz`; +} + +/** + * Get URLs from a parsed JSR spec. + * + * @param spec Parsed JSR spec + * @returns Object with various URL formats + */ +export function getJSRUrls(spec: JSRSpec): { + module: string | null; + meta: string; + versionMeta: string | null; + npm: string; +} { + const { scope, name, version, subpath } = spec; + + return { + module: version + ? getJSRModuleUrl(scope, name, version, subpath || "/mod.ts") + : null, + meta: getJSRPackageMetaUrl(scope, name), + versionMeta: version + ? getJSRVersionMetaUrl(scope, name, version) + : null, + npm: getJSRNpmUrl(scope, name), + }; +} + +// ============================================================================= +// npm Compatibility +// ============================================================================= + +/** + * Convert JSR package name to npm compatibility name. + * + * JSR uses `@jsr/scope__name` format for npm compatibility. + * + * @example + * ```ts + * toNpmCompatName("std", "path") + * // "@jsr/std__path" + * ``` + */ +export function toNpmCompatName(scope: string, name: string): string { + const cleanScope = scope.startsWith("@") ? scope.slice(1) : scope; + return `@jsr/${cleanScope}__${name}`; +} + +/** + * Parse an npm compatibility name back to JSR components. + * + * @param npmName npm compatibility name (e.g., "@jsr/std__path") + * @returns Parsed components or null if invalid + * + * @example + * ```ts + * fromNpmCompatName("@jsr/std__path") + * // { scope: "std", name: "path", fullName: "@std/path" } + * + * fromNpmCompatName("lodash") + * // null (not a JSR package) + * ``` + */ +export function fromNpmCompatName(npmName: string): { scope: string; name: string; fullName: string } | null { + const match = /^@jsr\/([a-z][a-z0-9-]*)__([a-z][a-z0-9-]*)$/.exec(npmName); + if (!match) return null; + + const [, scope, name] = match; + return { scope, name, fullName: `@${scope}/${name}` }; +} + +// ============================================================================= +// CDN/Proxy URLs +// ============================================================================= + +/** + * Convert a JSR spec to an esm.sh proxy URL. + * + * Use this when you need npm-compatible URLs in bundlers or environments + * that don't support direct jsr.io imports. + * + * @example + * ```ts + * const spec = parseJSRSpec("jsr:@std/path@1.0.0"); + * jsrToEsmSh(spec) + * // "https://esm.sh/jsr/@std/path@1.0.0" + * ``` + */ +export function jsrToEsmSh(spec: JSRSpec): string { + const { scope, name, version, subpath } = spec; + const versionPart = version ? `@${version}` : ""; + return `https://esm.sh/jsr/@${scope}/${name}${versionPart}${subpath}`; +} + +/** + * Convert a raw JSR specifier to esm.sh URL. + * + * @param input Raw jsr: specifier + * @returns esm.sh URL or null if invalid + */ +export function jsrSpecToEsmSh(input: string): string | null { + const spec = parseJSRSpec(input); + return spec ? jsrToEsmSh(spec) : null; +} + +// ============================================================================= +// Registry API - Fetch Metadata +// ============================================================================= + +/** + * Fetch package metadata from the JSR registry. + * + * @param scope Package scope (without @) + * @param name Package name + * @returns Package metadata + * + * @example + * ```ts + * const meta = await getJSRPackage("std", "path"); + * console.log(Object.keys(meta.versions)); + * // ["1.0.0", "1.0.1", "1.0.2", ...] + * ``` + */ +export async function getJSRPackage(scope: string, name: string): Promise { + const url = getJSRPackageMetaUrl(scope, name); + + const { response } = await fetchWithCache(url, { + cacheMode: "reload", + init: { + headers: { + // Important: JSR returns HTML if Accept includes text/html + "Accept": "application/json", + }, + } + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`JSR package not found: @${scope}/${name}`); + } + throw new Error(`JSR registry error: ${response.status} ${response.statusText}`); + } + + return await response.json() as JSRPackageMeta; +} + +/** + * Fetch version metadata from the JSR registry. + * + * @param scope Package scope + * @param name Package name + * @param version Exact version string + * @returns Version metadata with file manifest and exports + */ +export async function getJSRVersionMeta( + scope: string, + name: string, + version: string +): Promise { + const url = getJSRVersionMetaUrl(scope, name, version); + + const { response } = await fetchWithCache(url, { + init: { headers: { "Accept": "application/json" }, } + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`JSR version not found: @${scope}/${name}@${version}`); + } + throw new Error(`JSR registry error: ${response.status} ${response.statusText}`); + } + + return await response.json() as JSRVersionMeta; +} + +/** + * Get list of available versions for a package. + * + * @param scope Package scope + * @param name Package name + * @param includeYanked Whether to include yanked versions + * @returns Array of version strings + */ +export async function getJSRVersions( + scope: string, + name: string, + includeYanked = false +): Promise { + const meta = await getJSRPackage(scope, name); + +return Object.entries(meta.versions) + .filter(([_, info]) => includeYanked || !info.yanked) + .map(([version]) => version); +} + +// ============================================================================= +// Version Resolution +// ============================================================================= + +/** + * Resolve a version range to an exact version. + * + * Resolution order: + * 1. If version is an exact match, return it + * 2. Find the maximum version satisfying the semver range + * + * Note: JSR doesn't have dist-tags like npm, so "latest" is resolved + * as the highest non-yanked version. + * + * @param spec Object with scope, name, and version + * @returns Resolved version or null if no match + * + * @example + * ```ts + * // Resolve a caret range + * await resolveJSRVersion({ scope: "std", name: "path", version: "^1.0.0" }) + * // "1.0.8" (highest 1.x.x) + * + * // Resolve exact version + * await resolveJSRVersion({ scope: "std", name: "path", version: "1.0.0" }) + * // "1.0.0" + * + * // Resolve "latest" (highest non-yanked) + * await resolveJSRVersion({ scope: "std", name: "path", version: "latest" }) + * // "1.0.8" + * ``` + */ +export async function resolveJSRVersion( + spec: { scope: string; name: string; version: string | null } +): Promise { + const { scope, name, version: range } = spec; + + // If no version specified, get latest + if (!range || range === "latest") { + const versions = await getJSRVersions(scope, name, false); + if (versions.length === 0) return null; + + // Find highest version + const parsed = versions + .map(v => { + try { + return { original: v, semver: parse(v) }; + } catch { + return null; + } + }) + .filter((v): v is { original: string; semver: NonNullable> } => + v !== null && v.semver !== null + ); + + if (parsed.length === 0) return versions[0] ?? null; + + parsed.sort((a, b) => { + const aStr = format(a.semver); + const bStr = format(b.semver); + return bStr.localeCompare(aStr, undefined, { numeric: true }); + }); + + return parsed[0]?.original ?? null; + } + + const versions = await getJSRVersions(scope, name, false); + + // Check if exact version exists + if (versions.includes(range)) { + return range; + } + + // Try parsing as semver range + try { + const parsedVersions = versions + .map(v => { + try { + return parse(v); + } catch { + return null; + } + }) + .filter((v): v is NonNullable => v !== null); + + const semverRange = parseRange(range); + const maxVersion = maxSatisfying(parsedVersions, semverRange); + + if (maxVersion) { + return format(maxVersion); + } + } catch { + // Not a valid semver range + } + + console.warn(`[jsr] No version matches @${scope}/${name}@${range}`); + return null; +} + +/** + * Resolve a JSR spec to one with an exact version. + * + * @param spec Parsed JSR spec (potentially with range) + * @returns Spec with resolved version, or original if resolution fails + */ +export async function resolveJSRSpec(spec: JSRSpec): Promise { + if (!spec.version) { + const resolved = await resolveJSRVersion({ + scope: spec.scope, + name: spec.name, + version: "latest", + }); + + return resolved + ? { ...spec, version: resolved } + : spec; + } + + const resolved = await resolveJSRVersion({ + scope: spec.scope, + name: spec.name, + version: spec.version, + }); + + return resolved + ? { ...spec, version: resolved } + : spec; +} + +// ============================================================================= +// Search API +// ============================================================================= + +/** + * Search for packages on JSR. + * + * Note: This uses the management API (api.jsr.io) which is designed for + * search operations, not registry operations. + * + * @param query Search query + * @param options Search options + * @returns Search results + * + * @example + * ```ts + * const results = await searchJSR("http server"); + * for (const pkg of results.items) { + * console.log(`@${pkg.scope}/${pkg.name}: ${pkg.description}`); + * } + * ``` + */ +export async function searchJSR( + query: string, + options: { limit?: number; page?: number } = {} +): Promise { + const { limit = 20, page = 1 } = options; + + const params = new URLSearchParams({ + query, + limit: String(limit), + page: String(page), + }); + + const url = `${JSR_API}/packages?${params}`; + + const { response } = await fetchWithCache(url, { + cacheMode: "reload", + init: { + headers: { "Accept": "application/json" }, + } + }); + + if (!response.ok) { + throw new Error(`JSR search error: ${response.status} ${response.statusText}`); + } + + return await response.json() as JSRSearchResult; +} + +// ============================================================================= +// Import Map Generation +// ============================================================================= + +/** + * Generate import map entries for JSR packages. + * + * @param specs Array of JSR specs + * @param options Generation options + * @returns Import map entries + * + * @example Direct URLs + * ```ts + * const specs = [ + * parseJSRSpec("jsr:@std/path@1.0.0"), + * parseJSRSpec("jsr:@std/fs@1.0.0"), + * ]; + * + * generateImportMapEntries(specs) + * // { + * // "@std/path": "https://jsr.io/@std/path/1.0.0/mod.ts", + * // "@std/fs": "https://jsr.io/@std/fs/1.0.0/mod.ts" + * // } + * ``` + * + * @example Using esm.sh proxy + * ```ts + * generateImportMapEntries(specs, { useEsmSh: true }) + * // { + * // "@std/path": "https://esm.sh/jsr/@std/path@1.0.0", + * // "@std/fs": "https://esm.sh/jsr/@std/fs@1.0.0" + * // } + * ``` + */ +export function generateImportMapEntries( + specs: JSRSpec[], + options: { useEsmSh?: boolean; defaultPath?: string } = {} +): Record { + const { useEsmSh = false, defaultPath = "/mod.ts" } = options; + const entries: Record = {}; + + for (const spec of specs) { + const key = spec.fullName; + if (useEsmSh) { + entries[key] = jsrToEsmSh(spec); + } else if (spec.version) { + entries[key] = getJSRModuleUrl(spec.scope, spec.name, spec.version, defaultPath); + } + } + + return entries; +} \ No newline at end of file diff --git a/utils/mod.ts b/utils/mod.ts index b19f0fa..15ce6f8 100644 --- a/utils/mod.ts +++ b/utils/mod.ts @@ -8,14 +8,18 @@ export * as lzstring from "./lz-string.ts"; export * from "./npm-search.ts"; export * from "./parse-package-name.ts"; -export * as path from "./path.ts"; -export * from "./fmt.ts"; export * from "./resolve-exports-imports.ts"; -export * from "./tar.ts"; - +export * from "./resolve-conditions.ts"; export * from "./archive-detect.ts"; export * from "./archive-spec.ts"; -export * from "./npm-deps-spec.ts"; +export * from "./npm-spec.ts"; +export * from "./jsr-spec.ts"; +export * from "./runtime-builtins.ts"; +export * from "./resolve-import-map.ts"; + +export * as path from "./path.ts"; +export * from "./fmt.ts"; +export * from "./tar.ts"; export * from "./json-streams.ts"; @@ -39,4 +43,5 @@ export * as mediaTypes from "./media-types.ts"; export * as archiveDetect from "./archive-detect.ts"; export * as archiveSpec from "./archive-spec.ts"; -export * as npmDepsSpec from "./npm-deps-spec.ts"; \ No newline at end of file +export * as npmDepsSpec from "./npm-spec.ts"; +export * as validatePackageName from "./validate-package-name.ts"; \ No newline at end of file diff --git a/utils/npm-deps-spec.ts b/utils/npm-deps-spec.ts deleted file mode 100644 index 8018932..0000000 --- a/utils/npm-deps-spec.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Parse npm dependency specifiers found in package.json dependencies. - * - * Supported classifications: - * - semver ranges / exact versions (e.g. "^1.2.3", "1.2.3", "~2") - * - dist-tags (e.g. "latest", "next") - * - npm alias (e.g. "npm:react@^18") - * - URLs (e.g. "https://pkg.pr.new/@tanstack/react-query@7988") - * - explicit unsupported placeholders (git/file/workspace/link) - * - * Why this exists: - * - package.json dependencies can have many formats beyond simple semver - * - The CDN plugin needs to route URL-based versions through TarballPlugin - * - npm aliases need to be unwrapped before resolution - * - git/file/workspace/link specs need clear error messages - * - * @example - * parseNpmDependencySpec("npm:react@^18") - * // => { kind: "alias", target: { name:"react", version:"^18", path:"" } } - * - * @example - * parseNpmDependencySpec("https://pkg.pr.new/@tanstack/react-query@7988") - * // => { kind: "url", url: "https://..." } - * - * @example - * parseNpmDependencySpec("^1.2.3") - * // => { kind: "semver", raw: "^1.2.3" } - */ - -import { parsePackageName } from "./parse-package-name.ts"; -import { parseRange } from "./semver.ts"; - -/** - * Classification of an npm dependency specifier. - * - * The union covers: - * - semver: Standard version ranges ("^1.0.0", "~2.3", "1.x", ">=1.0.0 <2.0.0") - * - tag: Dist tags ("latest", "next", "canary", "beta") - * - alias: npm protocol aliases ("npm:lodash@^4", "npm:@scope/pkg@1.0.0") - * - url: HTTP/HTTPS URLs to tarballs or package endpoints - * - git: Git protocol specs (git+https://, github:, etc.) - * - file: Local file references (file:../local-pkg) - * - workspace: Workspace protocol (workspace:*) - * - link: Link protocol (link:../shared) - * - unknown: Couldn't classify - */ -export type NpmDependencySpec = - | { kind: "semver"; raw: string } - | { kind: "tag"; raw: string } - | { kind: "alias"; raw: string; target: { name: string; version: string; path: string } } - | { kind: "url"; raw: string; url: string } - | { kind: "git"; raw: string } - | { kind: "file"; raw: string } - | { kind: "workspace"; raw: string } - | { kind: "link"; raw: string } - | { kind: "unknown"; raw: string }; - -/** - * Pattern for git-based dependency specs. - * - * Covers: - * - git+https://github.com/user/repo.git - * - git://github.com/user/repo.git - * - github:user/repo - * - gitlab:user/repo - * - bitbucket:user/repo - * - gist:hash - * - ssh://git@github.com/user/repo.git - * - git@github.com:user/repo.git - */ -const GIT_PREFIX_RE = /^(git\+|git:\/\/|github:|gitlab:|bitbucket:|gist:|ssh:\/\/|git@)/i; - -/** - * Pattern for valid dist-tag identifiers. - * - * Dist tags are simple identifiers that can contain: - * - alphanumeric characters - * - dots, dashes, underscores - * - must start with alphanumeric - * - * Examples: latest, next, canary, beta, rc-1, v2.0-preview - */ -const DIST_TAG_RE = /^[0-9A-Za-z][0-9A-Za-z._-]*$/; - -/** - * Parse an npm dependency specifier into its classification. - * - * @param raw The raw specifier string from package.json dependencies - * @returns Classified spec with kind and relevant data - * - * @example - * ```ts - * // Semver range - * parseNpmDependencySpec("^1.2.3") - * // { kind: "semver", raw: "^1.2.3" } - * - * // npm alias - * parseNpmDependencySpec("npm:react@^18") - * // { kind: "alias", raw: "npm:react@^18", target: { name: "react", version: "^18", path: "" } } - * - * // URL (tarball) - * parseNpmDependencySpec("https://pkg.pr.new/@tanstack/react-query@7988") - * // { kind: "url", raw: "...", url: "https://..." } - * - * // Git reference - * parseNpmDependencySpec("github:user/repo#branch") - * // { kind: "git", raw: "github:user/repo#branch" } - * ``` - */ -export function parseNpmDependencySpec(raw: string): NpmDependencySpec { - const spec = (raw ?? "").trim(); - if (!spec) return { kind: "unknown", raw }; - - // npm: alias - unwrap and parse inner spec - if (spec.startsWith("npm:")) { - const inner = spec.slice(4); - const parsed = parsePackageName(inner); - return { - kind: "alias", - raw, - target: { - name: parsed.name, - version: parsed.version ?? "latest", - path: parsed.path ?? "", - }, - }; - } - - // workspace: protocol - if (spec.startsWith("workspace:")) { - return { kind: "workspace", raw }; - } - - // link: protocol - if (spec.startsWith("link:")) { - return { kind: "link", raw }; - } - - // file: protocol - if (spec.startsWith("file:")) { - return { kind: "file", raw }; - } - - // Git-based specs (various protocols and shorthands) - if (GIT_PREFIX_RE.test(spec)) { - return { kind: "git", raw }; - } - - // HTTP/HTTPS URLs - if (/^https?:\/\//i.test(spec)) { - return { kind: "url", raw, url: spec }; - } - - // Try parsing as semver range - try { - parseRange(spec); - return { kind: "semver", raw }; - } catch { - // Not a valid semver range, check if it's a dist-tag - if (DIST_TAG_RE.test(spec)) { - return { kind: "tag", raw }; - } - return { kind: "unknown", raw }; - } -} - -/** - * Check if a dependency spec is a URL that should be handled by TarballPlugin. - */ -export function isUrlSpec(spec: NpmDependencySpec): spec is { kind: "url"; raw: string; url: string } { - return spec.kind === "url"; -} - -/** - * Check if a dependency spec is an npm alias that needs unwrapping. - */ -export function isAliasSpec(spec: NpmDependencySpec): spec is { - kind: "alias"; - raw: string; - target: { name: string; version: string; path: string }; -} { - return spec.kind === "alias"; -} - -/** - * Check if a dependency spec is unsupported (git/file/workspace/link). - */ -export function isUnsupportedSpec(spec: NpmDependencySpec): boolean { - return ( - spec.kind === "git" || - spec.kind === "file" || - spec.kind === "workspace" || - spec.kind === "link" - ); -} - -/** - * Check if a dependency spec resolves to a standard npm version (semver or tag). - */ -export function isNpmVersionSpec(spec: NpmDependencySpec): boolean { - return spec.kind === "semver" || spec.kind === "tag"; -} - -/** - * Join two subpaths, handling leading/trailing slashes. - * - * @example - * joinSubpath("/build", "modern") // "/build/modern" - * joinSubpath("", "/dist/index.js") // "/dist/index.js" - * joinSubpath("/", "") // "/" - */ -export function joinSubpath(a: string, b: string): string { - const left = (a ?? "").replace(/\/+$/, ""); - const right = (b ?? "").replace(/^\/+/, ""); - if (!left && !right) return ""; - if (!left) return "/" + right; - if (!right) return left.startsWith("/") ? left : "/" + left; - return (left.startsWith("/") ? left : "/" + left) + "/" + right; -} - -/** - * Append a subpath to a URL's pathname. - * - * @example - * appendUrlSubpath("https://pkg.pr.new/@tanstack/react-query@7988", "/build/modern") - * // "https://pkg.pr.new/@tanstack/react-query@7988/build/modern" - */ -export function appendUrlSubpath(baseUrl: string, subpath: string): string { - if (!subpath) return baseUrl; - const u = new URL(baseUrl); - u.pathname = joinSubpath(u.pathname, subpath); - return u.toString(); -} - -/** - * Generate a user-friendly error message for unsupported spec types. - */ -export function getUnsupportedSpecError(spec: NpmDependencySpec, packageName: string): string { - switch (spec.kind) { - case "git": - return ( - `Package "${packageName}" uses a git dependency: "${spec.raw}". ` + - `Git-based dependencies are not yet supported. ` + - `Consider using an npm-published version or a tarball URL instead.` - ); - case "file": - return ( - `Package "${packageName}" uses a file dependency: "${spec.raw}". ` + - `Local file dependencies cannot be resolved in a browser-based bundler. ` + - `Upload the package to npm or use a tarball URL instead.` - ); - case "workspace": - return ( - `Package "${packageName}" uses a workspace dependency: "${spec.raw}". ` + - `Workspace protocols require a monorepo context. ` + - `Use an explicit version or publish the package to npm.` - ); - case "link": - return ( - `Package "${packageName}" uses a link dependency: "${spec.raw}". ` + - `Link protocols require filesystem access. ` + - `Use an explicit version or publish the package to npm.` - ); - default: - return ( - `Package "${packageName}" uses an unsupported dependency format: "${spec.raw}". ` + - `Please use a semver version, dist-tag, npm alias, or tarball URL.` - ); - } -} \ No newline at end of file diff --git a/utils/npm-search.ts b/utils/npm-search.ts index 9879e52..5ca6aa1 100644 --- a/utils/npm-search.ts +++ b/utils/npm-search.ts @@ -1,338 +1,395 @@ -import type { FullPackage, FullPackageVersion, PackageInfo, PackageSearchResult, RegistryURLs, SearchInfo } from "./types.ts"; +/** + * npm Registry API utilities. + * + * Provides functions for searching, fetching metadata, and resolving versions + * from the npm registry. Handles scoped packages correctly by URL-encoding + * the `/` character as required by the registry. + * + * @module + * + * @example Basic usage + * ```ts + * // Get URLs for a scoped package (note the escaped /) + * const urls = getRegistryURL("@tanstack/react-query@5.0.0"); + * // urls.packageURL = "https://registry.npmjs.com/@tanstack%2freact-query" + * + * // Resolve a version range + * const version = await resolveVersion("@types/node@^20"); + * + * // Get full package metadata + * const pkg = await getResolvedPackage("react@^18"); + * ``` + */ + +import type { + FullPackage, + FullPackageVersion, + PackageInfo, + PackageSearchResult, + RegistryURLs, + SearchInfo, +} from "./types.ts"; import { fetchWithCache } from "./fetch-and-cache.ts"; import { parsePackageName } from "./parse-package-name.ts"; import { maxSatisfying, parse, parseRange, format } from "./semver.ts"; +// ============================================================================= +// Constants +// ============================================================================= + +/** Default npm registry URL. */ +export const DEFAULT_REGISTRY = "https://registry.npmjs.com"; + +// ============================================================================= +// URL Utilities +// ============================================================================= + +/** + * Escape a package name for use in registry URLs. + * + * The npm registry requires scoped package names to have `/` encoded as `%2f`. + * + * This follows npm-package-arg's escapedName: + * ```js + * this.escapedName = name.replace('/', '%2f') + * ``` + * + * See: https://github.com/npm/npm-package-arg/blob/main/lib/npa.js#L224 + * + * @param name Package name (potentially scoped) + * @returns URL-safe package name + * + * @example + * escapePackageName("react") // "react" + * escapePackageName("@types/node") // "@types%2fnode" + * escapePackageName("@tanstack/react-query") // "@tanstack%2freact-query" + */ +export function escapePackageName(name: string): string { + return name.replace("/", "%2f"); +} + +/** + * Check if a package name is scoped. + * + * @param name Package name + * @returns True if name starts with @ + */ +export function isScopedPackage(name: string): boolean { + return name.startsWith("@"); +} + +/** + * Extract scope from a scoped package name. + * + * @param name Package name + * @returns Scope including @ or undefined + * + * @example + * getPackageScope("@types/node") // "@types" + * getPackageScope("react") // undefined + */ +export function getPackageScope(name: string): string | undefined { + if (!isScopedPackage(name)) return undefined; + const slashIdx = name.indexOf("/"); + return slashIdx > 0 ? name.slice(0, slashIdx) : undefined; +} + /** - * Generates registry URLs for npm packages based on the input string. - * - * @param input - Package name, optionally including version (e.g., "@okikio/animate@1.0"). - * @returns An object containing URLs for searching, package details, and specific version. - * + * Generate registry URLs for an npm package. + * + * **Important**: Scoped package names have `/` encoded as `%2f` in URL paths. + * This is required by the npm registry. + * + * @param input Package name with optional version (e.g., "@okikio/animate@1.0") + * @param registry Custom registry URL (defaults to npm) + * @returns URLs and parsed package info + * * @example * const urls = getRegistryURL("@okikio/animate@1.0"); - * console.log(urls); - * // Output: * // { - * // searchURL: "https://registry.npmjs.com/-/v1/search?text=@okikio/animate&popularity=0.5&size=30", - * // packageURL: "https://registry.npmjs.com/@okikio/animate", - * // packageVersionURL: "https://registry.npmjs.com/@okikio/animate/1.0", + * // searchURL: "https://registry.npmjs.com/-/v1/search?text=@okikio/animate&...", + * // packageURL: "https://registry.npmjs.com/@okikio%2fanimate", + * // packageVersionURL: "https://registry.npmjs.com/@okikio%2fanimate/1.0", * // version: "1.0", * // name: "@okikio/animate", * // path: "" * // } */ -export function getRegistryURL(input: string): RegistryURLs { - const host = "https://registry.npmjs.com"; - +export function getRegistryURL( + input: string, + registry: string = DEFAULT_REGISTRY +): RegistryURLs { + const host = registry.replace(/\/+$/, ""); const { name, version, path } = parsePackageName(input); + + // CRITICAL: Scoped packages must have / encoded as %2f + // See: https://github.com/npm/npm-package-arg/blob/main/lib/npa.js#L224 + const escapedName = escapePackageName(name); + + // Search uses unescaped name in query param (encodeURIComponent handles it) const searchURL = `${host}/-/v1/search?text=${encodeURIComponent(name)}&popularity=0.5&size=30`; - const packageVersionURL = `${host}/${name}/${version}`; - const packageURL = `${host}/${name}`; - return { searchURL, packageURL, packageVersionURL, version, name, path }; -}; + // Package URLs use escaped name in path + const packageURL = `${host}/${escapedName}`; + const packageVersionURL = version + ? `${host}/${escapedName}/${version}` + : packageURL; + + return { + searchURL, + packageURL, + packageVersionURL, + version, + name, + path, + }; +} + +// ============================================================================= +// Search API +// ============================================================================= /** - * Searches the npm registry for packages with matching names. - * - * @param input - Package name, optionally including version (ignored) (e.g., "@okikio/animate@1.0"). - * @returns An object containing the search results. - * + * Search the npm registry for packages. + * + * @param input Package name to search for + * @param registry Custom registry URL + * @returns Search results with package metadata + * * @example * const result = await getPackages("@okikio/animate"); - * console.log(result.packages); - * // Output: - * // [ - * // { - * // name: "@okikio/animate", - * // scope: "okikio", - * // version: "2.3.1", - * // description: "An animation library for the modern web...", - * // keywords: ["animation", "web", "API"], - * // date: "2021-06-30T07:01:08.871Z", - * // links: { - * // npm: "https://www.npmjs.com/package/@okikio/animate", - * // homepage: "https://animate.okikio.dev", - * // repository: "https://github.com/okikio/animate", - * // bugs: "https://github.com/okikio/animate/issues" - * // }, - * // author: { name: "Okiki Imoesi" }, - * // publisher: { username: "okikio", email: "okiki@example.com" }, - * // maintainers: [{ username: "okikio", email: "okiki@example.com" }] - * // }, - * // // ...more packages - * // ] + * console.log(result.packages.map(p => p.package.name)); */ -export async function getPackages(input: string): Promise { - const { searchURL } = getRegistryURL(input); +export async function getPackages( + input: string, + registry?: string +): Promise { + const { searchURL } = getRegistryURL(input, registry); let result: SearchInfo; try { const { response } = await fetchWithCache(searchURL, { cacheMode: "reload" }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`); + } + result = await response.json(); } catch (e) { - console.warn(e); + console.warn(`[npm-search] Search failed for "${input}":`, e); throw e; } - const packages = result?.objects; + const packages = result?.objects ?? []; return { packages, info: result }; -}; +} + +// ============================================================================= +// Package Metadata API +// ============================================================================= /** - * Fetches the metadata of a specific package from the npm registry. - * - * @param input - Package name, optionally including version (ignored) (e.g., "@okikio/animate@1.0"). - * @returns The package metadata. - * + * Fetch full package metadata (packument) from registry. + * + * Returns the complete package document including all versions. + * For large packages, consider using getPackageOfVersion() instead. + * + * @param input Package name (version ignored) + * @param registry Custom registry URL + * @returns Full package metadata + * * @example - * const packageInfo = await getPackage("@okikio/animate"); - * console.log(packageInfo); - * // Output: - * // { - * // name: "@okikio/animate", - * // scope: "okikio", - * // version: "2.3.1", - * // description: "An animation library for the modern web...", - * // keywords: ["animation", "web", "API"], - * // date: "2021-06-30T07:01:08.871Z", - * // links: { - * // npm: "https://www.npmjs.com/package/@okikio/animate", - * // homepage: "https://animate.okikio.dev", - * // repository: "https://github.com/okikio/animate", - * // bugs: "https://github.com/okikio/animate/issues" - * // }, - * // author: { name: "Okiki Imoesi" }, - * // publisher: { username: "okikio", email: "okiki@example.com" }, - * // maintainers: [{ username: "okikio", email: "okiki@example.com" }] - * // } + * const pkg = await getPackage("@okikio/animate"); + * console.log(Object.keys(pkg.versions)); + * console.log(pkg["dist-tags"]); */ -export async function getPackage(input: string): Promise { - const { packageURL } = getRegistryURL(input); +export async function getPackage( + input: string, + registry?: string +): Promise { + const { packageURL, name } = getRegistryURL(input, registry); try { const { response } = await fetchWithCache(packageURL, { cacheMode: "reload" }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Package not found: ${name}`); + } + throw new Error(`Registry error: ${response.status} ${response.statusText}`); + } + return await response.json() as FullPackage; } catch (e) { - console.warn(e); + console.warn(`[npm-search] Failed to fetch "${name}":`, e); throw e; } -}; +} /** - * Fetches the metadata of a specific package version from the npm registry. - * - * @param input - Package name, including version (e.g., "@okikio/animate@1.0"). - * @returns The package version metadata. - * + * Fetch metadata for a specific package version. + * + * More efficient than getPackage() when you only need one version. + * + * @param input Package name with version (e.g., "@okikio/animate@1.0.0") + * @param registry Custom registry URL + * @returns Version-specific metadata + * * @example - * const packageVersionInfo = await getPackageOfVersion("@okikio/animate@1.0"); - * console.log(packageVersionInfo); - * // Output: - * // { - * // name: "@okikio/animate", - * // version: "1.0.0", - * // type: "module", - * // sideEffects: false, - * // description: "An animation library for the modern web...", - * // publishConfig: { - * // access: "public", - * // main: "lib/api.cjs.js", - * // types: "@types/api.d.ts", - * // browser: "lib/api.es.js", - * // module: "lib/api.es.js", - * // exports: { - * // ".": { - * // require: "./lib/api.cjs.js", - * // import: "./lib/api.es.js", - * // default: "./lib/api.es.js" - * // }, - * // "./lib/*": "./lib/*.js" - * // } - * // }, - * // main: "lib/api.cjs.js", - * // directories: { lib: "./lib" }, - * // repository: { type: "git", url: "git+https://github.com/okikio/native.git" }, - * // keywords: ["ts", "modern", "animation", "library", "web", "css", "smooth"], - * // author: { name: "Okiki Ojo", email: "hey@okikio.dev", url: "https://okikio.dev" }, - * // license: "MIT", - * // bugs: { url: "https://github.com/okikio/native/issues" }, - * // homepage: "https://github.com/okikio/native/tree/master/packages/animate#readme", - * // dependencies: { "@okikio/emitter": "2.1.7", "@okikio/manager": "2.1.7" }, - * // devDependencies: { - * // "del-cli": "^4.0.0", - * // esbuild: "^0.12.12", - * // "gzip-size": "^6.0.0", - * // pnpm: "^6.9.1", - * // "pretty-bytes": "^5.6.0", - * // typescript: "~4.3.4" - * // }, - * // gitHead: "ad422076...90f", - * // scripts: { - * // build: "del-cli lib/ && node ./build", - * // watch: "del-cli lib/ && node ./build --watch", - * // dts: "del-cli @types/ && tsc --project dts.tsconfig.json", - * // "pre-release": "pnpm build && pnpm dts" - * // }, - * // types: "@types/api.d.ts", - * // browser: "lib/api.es.js", - * // module: "lib/api.es.js", - * // exports: { - * // ".": { - * // require: "./lib/api.cjs.js", - * // import: "./lib/api.es.js", - * // default: "./lib/api.es.js" - * // }, - * // "./lib/*": "./lib/*.js" - * // }, - * // _id: "@okikio/animate@1.0.0", - * // _nodeVersion: "14.16.1", - * // _npmVersion: "6.14.12", - * // dist: { - * // integrity: "sha512-+asddaRga...IsiNRA==", - * // shasum: "b2c08e0...c8", - * // tarball: "https://registry.npmjs.org/@okikio/animate/-/animate-1.0.0.tgz", - * // fileCount: 29, - * // unpackedSize: 414434, - * // "npm-signature": "-----BEGIN PGP SIGNATURE-----\r\n" + - * // "Version: OpenPGP.js v3.0.13\r\n" + - * // "Comment: https://openpgpjs.org\r\n" + - * // "\r\n" + - * // "wsFcBA"... 768 more characters, - * // signatures: [ - * // { - * // keyid: "SHA256:jl3bwswu...kzA", - * // sig: "MEUCI...VFfo=" - * // } - * // ] - * // }, - * // _npmUser: { name: "okikio", email: "hey@okikio.dev" }, - * // maintainers: [{ name: "okikio", email: "hey@okikio.dev" }], - * // _npmOperationalInternal: { - * // host: "s3://npm-registry-packages", - * // tmp: "tmp/animate_1.0.0_1625036468730_0.7262493743034963" - * // }, - * // _hasShrinkwrap: false - * // } + * const pkg = await getPackageOfVersion("@okikio/animate@1.0.0"); + * console.log(pkg.dist.tarball); */ -export async function getPackageOfVersion(input: string): Promise { - const { packageVersionURL } = getRegistryURL(input); +export async function getPackageOfVersion( + input: string, + registry?: string +): Promise { + const { packageVersionURL, name, version } = getRegistryURL(input, registry); try { const { response } = await fetchWithCache(packageVersionURL); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Version not found: ${name}@${version}`); + } + throw new Error(`Registry error: ${response.status} ${response.statusText}`); + } + return await response.json() as FullPackageVersion; } catch (e) { - console.warn(e); + console.warn(`[npm-search] Failed to fetch "${name}@${version}":`, e); throw e; } -}; +} /** - * Fetches all versions and tags of a specific package from the npm registry. - * - * @param input - Package name, optionally including version (ignored) (e.g., "@okikio/animate@1.0"). - * @returns An object containing arrays of versions and tags. - * + * Fetch all versions and dist-tags for a package. + * + * @param input Package name (version ignored) + * @param registry Custom registry URL + * @returns Versions array and dist-tags object + * * @example - * const versionsAndTags = await getPackageVersions("@okikio/animate"); - * console.log(versionsAndTags); - * // Output: - * // { - * // versions: ["0.0.3", "0.0.4", "0.0.5", "1.0.0", "1.1.0", "2.0.0", "2.3.1"], - * // tags: { latest: "2.3.1", beta: "2.4.0" } - * // } + * const info = await getPackageVersions("@okikio/animate"); + * console.log(info.versions); // ["0.0.3", "0.0.4", ...] + * console.log(info.tags); // { latest: "2.3.1", beta: "2.4.0" } */ -export async function getPackageVersions(input: string): Promise { +export async function getPackageVersions( + input: string, + registry?: string +): Promise { try { - const pkg = await getPackage(input); + const pkg = await getPackage(input, registry); const versions = Object.keys(pkg.versions); const tags = pkg["dist-tags"]; return { versions, tags }; } catch (e) { - console.warn(e); + console.warn(`[npm-search] Failed to get versions for "${input}":`, e); throw e; } -}; +} + +// ============================================================================= +// Version Resolution +// ============================================================================= /** - * Resolves the appropriate version of a package based on a version range. - * - * @param input - Package name, including version range (e.g., "@okikio/animate@^1.0.0"). - * @returns The resolved version string. - * + * Resolve the best version matching a range. + * + * Resolution order: + * 1. If range matches a dist-tag, return that tag's version + * 2. If range is an exact version that exists, return it + * 3. Find the maximum version satisfying the range + * + * @param input Package with version range (e.g., "@okikio/animate@^1.0.0") + * @param registry Custom registry URL + * @returns Resolved version or null if no match + * * @example - * const version = await resolveVersion("@okikio/animate@^1.0.0"); - * console.log(version); - * // Output: - * // "2.3.1" + * const v = await resolveVersion("@okikio/animate@^1.0.0"); + * // Returns highest 1.x.x version + * + * const v2 = await resolveVersion("@okikio/animate@latest"); + * // Returns version that 'latest' tag points to */ -export async function resolveVersion(input: string): Promise { +export async function resolveVersion( + input: string, + registry?: string +): Promise { + const { version: range, name } = getRegistryURL(input, registry); + + if (!range) { + console.warn(`[npm-search] No version specified for "${name}"`); + return null; + } + try { - let { version: range } = getRegistryURL(input); - if (!range) return null; + const { versions, tags } = await getPackageVersions(input, registry); - const versionsAndTags = await getPackageVersions(input); - if (versionsAndTags) { - const { versions, tags } = versionsAndTags; - if (range in tags) - range = tags[range]; + // Check if range is a dist-tag + if (range in tags) { + return tags[range]; + } - if (versions.includes(range)) - return range; + // Check if exact version exists + if (versions.includes(range)) { + return range; + } - const versionArr = versions.map(v => parse(v)); - const semVerRange = parseRange(range); - const maxVersion = maxSatisfying(versionArr, semVerRange); + // Try parsing as semver range + try { + const parsedVersions = versions + .map(v => { + try { + return parse(v); + } catch { + return null; + } + }) + .filter((v): v is NonNullable => v !== null); - if (!maxVersion) return null; - return format(maxVersion); + const semverRange = parseRange(range); + const maxVersion = maxSatisfying(parsedVersions, semverRange); + + if (maxVersion) { + return format(maxVersion); + } + } catch { + // Not a valid semver range } + + console.warn(`[npm-search] No match for "${name}@${range}"`); + return null; } catch (e) { - console.warn(e); + console.warn(`[npm-search] Version resolution failed for "${input}":`, e); throw e; } - - return null; -}; +} /** - * Fetches the metadata of a package with the resolved version from the npm registry. - * - * @param input - Package name, including version (e.g., "@okikio/animate@1.0"). - * @returns The resolved package metadata. - * + * Fetch metadata for a package with resolved version. + * + * Combines resolveVersion() and getPackageOfVersion() for convenience. + * + * @param input Package with version range + * @param registry Custom registry URL + * @returns Metadata for the resolved version + * * @example - * const resolvedPackage = await getResolvedPackage("@okikio/animate@^1.0.0"); - * console.log(resolvedPackage); - * // Output: - * // { - * // name: "@okikio/animate", - * // scope: "okikio", - * // version: "2.3.1", - * // description: "An animation library for the modern web...", - * // keywords: ["animation", "web", "API"], - * // date: "2021-06-30T07:01:08.871Z", - * // links: { - * // npm: "https://www.npmjs.com/package/@okikio/animate", - * // homepage: "https://animate.okikio.dev", - * // repository: "https://github.com/okikio/animate", - * // bugs: "https://github.com/okikio/animate/issues" - * // }, - * // author: { name: "Okiki Imoesi" }, - * // publisher: { username: "okikio", email: "okiki@example.com" }, - * // maintainers: [{ username: "okikio", email: "okiki@example.com" }] - * // } + * const pkg = await getResolvedPackage("@okikio/animate@^1.0.0"); + * console.log(pkg.version); // Resolved from ^1.0.0 */ -export async function getResolvedPackage(input: string) { - try { - const { name } = getRegistryURL(input); - const version = await resolveVersion(input); +export async function getResolvedPackage( + input: string, + registry?: string +): Promise { + const { name } = getRegistryURL(input, registry); + const version = await resolveVersion(input, registry); - return await getPackageOfVersion(`${name}@${version}`); - } catch (e) { - console.warn(e); - throw e; + if (!version) { + throw new Error(`Could not resolve version for "${input}"`); } -}; \ No newline at end of file + + return getPackageOfVersion(`${name}@${version}`, registry); +} \ No newline at end of file diff --git a/utils/npm-spec.ts b/utils/npm-spec.ts new file mode 100644 index 0000000..8143b51 --- /dev/null +++ b/utils/npm-spec.ts @@ -0,0 +1,878 @@ +/** + * npm dependency specifier parser. + * + * Parses package.json dependency specifiers following npm-package-arg behavior. + * Used by the CDN plugin to classify and route dependencies correctly. + * + * Key features: + * - GitHub shorthand detection (user/repo → git) + * - Git fragment parsing (#semver:^1.0.0, #path:/packages/foo) + * - npm alias validation (no nested, registry-only targets) + * - Dist-tag validation using npm's encodeURIComponent rule + * - File vs directory classification by extension + * + * @module + * + * @example Basic usage + * ```ts + * import { parseNpmSpec, isRegistrySpec, isUrlSpec } from "./npm-spec.ts"; + * + * const spec = parseNpmSpec("^1.2.3"); + * if (isRegistrySpec(spec)) { + * // Resolve via npm registry + * } + * + * const urlSpec = parseNpmSpec("https://pkg.pr.new/@tanstack/react-query@7988"); + * if (isUrlSpec(urlSpec)) { + * // Route to TarballPlugin + * } + * ``` + */ + +import { parsePackageName } from "./parse-package-name.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed git fragment data. + * + * npm supports these fragment formats separated by `::`: + * - Plain committish: #v1.0.0, #main, #abc123 + * - Semver range: #semver:^1.0.0 + * - Subdir path: #path:/packages/foo + * - Combined: #main::semver:^1.0.0::path:/packages/foo + */ +export interface GitFragment { + /** Git ref (branch, tag, commit SHA) */ + committish: string | null; + /** Semver range to match against git tags */ + semverRange: string | null; + /** Subdirectory within the repo */ + subdir: string | null; +} + +/** + * Hosted git service info. + */ +export interface HostedGitInfo { + type: "github" | "gitlab" | "bitbucket" | "gist"; + user: string | null; + project: string | null; +} + +/** + * Base fields for all spec types. + */ +interface BaseSpec { + /** Original raw specifier string */ + raw: string; +} + +/** + * Registry-resolvable spec (semver range, exact version, or dist-tag). + */ +export interface RegistrySpec extends BaseSpec { + kind: "semver" | "version" | "tag"; + /** The spec to use for registry lookup */ + fetchSpec: string; +} + +/** + * npm alias spec (npm:package@version). + */ +export interface AliasSpec extends BaseSpec { + kind: "alias"; + /** The inner registry spec */ + subSpec: RegistrySpec; + /** Parsed target package info */ + target: { + name: string; + version: string; + path: string; + }; +} + +/** + * HTTP/HTTPS URL spec (remote tarball). + */ +export interface UrlSpec extends BaseSpec { + kind: "url"; + /** The URL to fetch */ + url: string; +} + +/** + * Git-based spec with parsed fragment. + */ +export interface GitSpec extends BaseSpec { + kind: "git"; + /** Git committish (branch, tag, SHA) */ + gitCommittish: string | null; + /** Semver range from fragment (#semver:^1.0.0) */ + gitRange: string | null; + /** Subdirectory from fragment (#path:/packages/foo) */ + gitSubdir: string | null; + /** Hosted service info if recognized */ + hosted: HostedGitInfo | null; +} + +/** + * Local file spec (tarball). + */ +export interface FileSpec extends BaseSpec { + kind: "file"; + /** Path to the file */ + fetchSpec: string; +} + +/** + * Local directory spec. + */ +export interface DirectorySpec extends BaseSpec { + kind: "directory"; + /** Path to the directory */ + fetchSpec: string; +} + +/** + * Workspace protocol spec (workspace:*). + */ +export interface WorkspaceSpec extends BaseSpec { + kind: "workspace"; + /** The workspace selector (*, ^, ~, or version) */ + workspaceSpec: string; +} + +/** + * Link protocol spec (link:../path). + */ +export interface LinkSpec extends BaseSpec { + kind: "link"; + /** The link target path */ + fetchSpec: string; +} + +/** + * Unknown or invalid spec. + */ +export interface UnknownSpec extends BaseSpec { + kind: "unknown"; + /** Error message describing why parsing failed */ + error?: string; +} + +/** + * Union of all spec types. + */ +export type NpmDependencySpec = + | RegistrySpec + | AliasSpec + | UrlSpec + | GitSpec + | FileSpec + | DirectorySpec + | WorkspaceSpec + | LinkSpec + | UnknownSpec; + +// ============================================================================= +// Detection Patterns +// ============================================================================= + +/** + * Explicit git protocol prefixes. + * + * Matches: + * - git+https://, git+ssh://, git+file://, git:// + * - github:, gitlab:, bitbucket:, gist: + * - ssh:// + * - git@ (SCP-style) + */ +const GIT_PREFIX_RE = /^(git\+|git:\/\/|github:|gitlab:|bitbucket:|gist:|ssh:\/\/|git@)/i; + +/** + * Tarball file extensions. + * Used to distinguish file: specs from directory: specs. + */ +const TARBALL_EXT_RE = /[.](?:tgz|tar\.gz|tar)$/i; + +/** + * Port number pattern (used to distinguish SCP from URL with port). + */ +const PORT_RE = /:[0-9]+(\/|$)/; + +/** + * File-like path pattern (POSIX). + */ +const FILE_PATH_RE = /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/; + +// ============================================================================= +// GitHub Shorthand Detection +// ============================================================================= + +/** + * Detect GitHub shorthand format: user/repo or user/repo#ref + * + * Based on hosted-git-info's isGitHubShorthand() function. + * See: https://github.com/npm/hosted-git-info/blob/main/lib/from-url.js + * + * Must NOT match: + * - Scoped packages (@scope/pkg) - @ before first / + * - Relative paths (./foo, ../foo) - starts with . + * - URLs (has ://) + * - SCP URLs (git@host:path) - @ before first / + * - Multi-level paths (a/b/c) - second / before # + * + * @param arg The string to test + * @returns True if this looks like user/repo shorthand + * + * @example + * isGitHubShorthand("facebook/react") // true + * isGitHubShorthand("facebook/react#main") // true + * isGitHubShorthand("@types/node") // false (scoped package) + * isGitHubShorthand("./local") // false (relative path) + * isGitHubShorthand("git@github.com:user/repo") // false (SCP) + */ +export function isGitHubShorthand(arg: string): boolean { + const firstHash = arg.indexOf("#"); + const firstSlash = arg.indexOf("/"); + const secondSlash = arg.indexOf("/", firstSlash + 1); + const firstColon = arg.indexOf(":"); + const firstSpace = arg.search(/\s/); + const firstAt = arg.indexOf("@"); + + // Must have exactly one slash (before #) + const hasSlash = firstSlash > 0; + const secondSlashOnlyAfterHash = + secondSlash === -1 || (firstHash > -1 && secondSlash > firstHash); + + // No colons before # (protocols, SCP) + const colonOnlyAfterHash = + firstColon === -1 || (firstHash > -1 && firstColon > firstHash); + + // No @ before first slash (scoped packages, SCP) + const atOnlyAfterHash = + firstAt === -1 || (firstHash > -1 && firstAt > firstHash); + + // No whitespace before # + const spaceOnlyAfterHash = + firstSpace === -1 || (firstHash > -1 && firstSpace > firstHash); + + // Doesn't start with . (relative path) + const doesNotStartWithDot = !arg.startsWith("."); + + // Doesn't end with / right before # (malformed) + const doesNotEndWithSlash = + firstHash > -1 ? arg[firstHash - 1] !== "/" : !arg.endsWith("/"); + + return ( + hasSlash && + secondSlashOnlyAfterHash && + colonOnlyAfterHash && + atOnlyAfterHash && + spaceOnlyAfterHash && + doesNotStartWithDot && + doesNotEndWithSlash + ); +} + +// ============================================================================= +// Git Fragment Parsing +// ============================================================================= + +/** + * Parse git fragment (part after #) into structured data. + * + * Based on npm-package-arg's setGitAttrs() function. + * See: https://github.com/npm/npm-package-arg/blob/main/lib/npa.js + * + * Fragment formats separated by `::`: + * - Plain committish: v1.0.0, main, abc123 + * - Semver range: semver:^1.0.0 + * - Subdir path: path:/packages/foo + * - Combined: main::path:/packages/foo + * + * @param fragment The fragment string (without leading #) + * @returns Parsed fragment data + * @throws Error if fragment has conflicting parts + * + * @example + * parseGitFragment("main") + * // => { committish: "main", semverRange: null, subdir: null } + * + * parseGitFragment("semver:^1.0.0") + * // => { committish: null, semverRange: "^1.0.0", subdir: null } + * + * parseGitFragment("v1.0.0::path:/packages/core") + * // => { committish: "v1.0.0", semverRange: null, subdir: "/packages/core" } + */ +export function parseGitFragment(fragment: string): GitFragment { + if (!fragment) { + return { committish: null, semverRange: null, subdir: null }; + } + + let committish: string | null = null; + let semverRange: string | null = null; + let subdir: string | null = null; + + // Split on :: (npm's separator) + for (const part of fragment.split("::")) { + const colonIdx = part.indexOf(":"); + + // No colon = plain committish + if (colonIdx === -1) { + if (!committish && !semverRange) { + committish = part; + } + continue; + } + + const key = part.slice(0, colonIdx); + const value = part.slice(colonIdx + 1); + + if (key === "semver") { + if (committish) { + throw new Error("Cannot use semver range with explicit committish"); + } + if (semverRange) { + throw new Error("Multiple semver ranges not allowed"); + } + semverRange = decodeURIComponent(value); + } else if (key === "path") { + if (subdir) { + throw new Error("Multiple path specifiers not allowed"); + } + subdir = value.startsWith("/") ? value : `/${value}`; + } + // Unknown keys silently ignored (matches npm behavior) + } + + return { committish, semverRange, subdir }; +} + +// ============================================================================= +// Hosted Git Parsing +// ============================================================================= + +/** + * Extract hosted git service info from a spec. + * + * @param spec The spec string + * @returns Hosted info or null if not a recognized host + */ +function parseHostedGit(spec: string): HostedGitInfo | null { + // Explicit shortcuts: github:user/repo, gitlab:user/repo, etc. + const shortcutMatch = spec.match(/^(github|gitlab|bitbucket|gist):/i); + if (shortcutMatch) { + const type = shortcutMatch[1].toLowerCase() as HostedGitInfo["type"]; + const rest = spec.slice(shortcutMatch[0].length); + const hashIdx = rest.indexOf("#"); + const pathPart = hashIdx > -1 ? rest.slice(0, hashIdx) : rest; + + const slashIdx = pathPart.indexOf("/"); + if (slashIdx > 0) { + const user = pathPart.slice(0, slashIdx); + let project = pathPart.slice(slashIdx + 1); + if (project.endsWith(".git")) project = project.slice(0, -4); + return { type, user, project }; + } + + // Gist can be just a hash + if (type === "gist" && pathPart) { + return { type, user: null, project: pathPart }; + } + } + + // GitHub shorthand: user/repo + if (isGitHubShorthand(spec)) { + const hashIdx = spec.indexOf("#"); + const pathPart = hashIdx > -1 ? spec.slice(0, hashIdx) : spec; + const slashIdx = pathPart.indexOf("/"); + + if (slashIdx > 0) { + const user = pathPart.slice(0, slashIdx); + let project = pathPart.slice(slashIdx + 1); + if (project.endsWith(".git")) project = project.slice(0, -4); + return { type: "github", user, project }; + } + } + + // Full URLs to known hosts + try { + const url = new URL(spec.replace(/^git\+/, "")); + const hostname = url.hostname.replace(/^www\./, ""); + + const hostMap: Record = { + "github.com": "github", + "gitlab.com": "gitlab", + "bitbucket.org": "bitbucket", + "gist.github.com": "gist", + }; + + const type = hostMap[hostname]; + if (type) { + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length >= 2) { + const user = parts[0]; + let project = parts[1]; + if (project.endsWith(".git")) project = project.slice(0, -4); + return { type, user, project }; + } + } + } catch { + // Not a valid URL + } + + return null; +} + +// ============================================================================= +// Spec Type Detection +// ============================================================================= + +/** + * Check if spec looks like a file path. + */ +function isFilePath(spec: string): boolean { + if (spec.toLowerCase().startsWith("file:")) return true; + return FILE_PATH_RE.test(spec); +} + +/** + * Check if spec is a git URL or shorthand. + */ +function isGitUrl(spec: string): boolean { + // Explicit git prefixes + if (GIT_PREFIX_RE.test(spec)) return true; + + // SCP-style URLs, but not URLs with port numbers + // git@github.com:user/repo vs ssh://git@host:1234/repo + if (/^[^@]+@[^:]+:.+/.test(spec) && !PORT_RE.test(spec)) return true; + + // GitHub shorthand + if (isGitHubShorthand(spec)) return true; + + return false; +} + +/** + * Validate dist-tag name using npm's rule. + * + * npm uses: encodeURIComponent(tag) !== tag + * This catches any character that needs URL encoding. + */ +function isValidDistTag(tag: string): boolean { + return encodeURIComponent(tag) === tag; +} + +/** + * Check if a string is a valid semver version. + * + * Simplified check - full validation happens in semver module. + */ +function looksLikeSemver(spec: string): boolean { + // Simple heuristic: contains digits and common semver chars + // The parseRange call in main parser does the real validation + return /^[0-9vV*xX~^<>=|&\s.-]+$/.test(spec) || spec === "*" || spec === ""; +} + +// ============================================================================= +// Main Parser +// ============================================================================= + +/** + * Parse an npm dependency specifier into a classified spec. + * + * Follows npm-package-arg's parsing order: + * 1. npm: alias prefix + * 2. workspace:/link: protocols + * 3. file: or path-like specs + * 4. Git URLs and shorthands + * 5. HTTP/HTTPS URLs + * 6. Semver ranges + * 7. Dist-tags + * + * @param raw The raw specifier from package.json dependencies + * @returns Classified spec with kind and relevant data + * + * @example Registry specs + * ```ts + * parseNpmSpec("^1.2.3") + * // => { kind: "semver", raw: "^1.2.3", fetchSpec: "^1.2.3" } + * + * parseNpmSpec("1.2.3") + * // => { kind: "version", raw: "1.2.3", fetchSpec: "1.2.3" } + * + * parseNpmSpec("latest") + * // => { kind: "tag", raw: "latest", fetchSpec: "latest" } + * ``` + * + * @example Alias specs + * ```ts + * parseNpmSpec("npm:react@^18") + * // => { kind: "alias", subSpec: { kind: "semver", ... }, + * // target: { name: "react", version: "^18", path: "" } } + * ``` + * + * @example Git specs + * ```ts + * parseNpmSpec("github:user/repo#semver:^1.0.0") + * // => { kind: "git", gitRange: "^1.0.0", hosted: { type: "github", ... } } + * + * parseNpmSpec("user/repo") + * // => { kind: "git", hosted: { type: "github", user: "user", project: "repo" } } + * ``` + */ +export function parseNpmSpec(raw: string): NpmDependencySpec { + const spec = (raw ?? "").trim(); + if (!spec) { + return { kind: "unknown", raw, error: "Empty specifier" }; + } + + // ========================================================================= + // npm: alias + // ========================================================================= + if (spec.toLowerCase().startsWith("npm:")) { + const inner = spec.slice(4); + + // No nested aliases + if (inner.toLowerCase().startsWith("npm:")) { + return { + kind: "unknown", + raw, + error: "Nested aliases not supported", + }; + } + + // Parse inner as package name + const parsed = parsePackageName(inner, { ignoreError: true }); + + // Must have a name + if (!parsed.name) { + return { + kind: "unknown", + raw, + error: "Alias must specify package name", + }; + } + + // Target must be registry-resolvable (not path or URL) + if ( + parsed.name.startsWith(".") || + parsed.name.startsWith("/") || + parsed.name.includes("://") || + (parsed.name.includes(":") && !parsed.name.startsWith("@")) + ) { + return { + kind: "unknown", + raw, + error: "Alias targets must be registry packages", + }; + } + + // Parse version as registry spec + // npm defaults to * not latest when no version specified + const versionSpec = parsed.version ?? "*"; + const subSpec = parseRegistrySpec(versionSpec); + + if (subSpec.kind === "unknown") { + return { + kind: "unknown", + raw, + error: `Invalid alias version: ${versionSpec}`, + }; + } + + return { + kind: "alias", + raw, + subSpec: subSpec as RegistrySpec, + target: { + name: parsed.name, + version: versionSpec, + path: parsed.path ?? "", + }, + }; + } + + // ========================================================================= + // workspace: protocol + // ========================================================================= + if (spec.toLowerCase().startsWith("workspace:")) { + return { + kind: "workspace", + raw, + workspaceSpec: spec.slice(10) || "*", + }; + } + + // ========================================================================= + // link: protocol + // ========================================================================= + if (spec.toLowerCase().startsWith("link:")) { + return { + kind: "link", + raw, + fetchSpec: spec.slice(5), + }; + } + + // ========================================================================= + // file: or path-like + // ========================================================================= + if (isFilePath(spec)) { + const path = spec.toLowerCase().startsWith("file:") ? spec.slice(5) : spec; + + // Distinguish file (tarball) from directory by extension + if (TARBALL_EXT_RE.test(path)) { + return { kind: "file", raw, fetchSpec: path }; + } + return { kind: "directory", raw, fetchSpec: path }; + } + + // ========================================================================= + // Git specs + // ========================================================================= + if (isGitUrl(spec)) { + const hashIdx = spec.indexOf("#"); + const fragment = hashIdx > -1 ? spec.slice(hashIdx + 1) : ""; + + let fragmentData: GitFragment; + try { + fragmentData = parseGitFragment(fragment); + } catch (e) { + return { + kind: "unknown", + raw, + error: e instanceof Error ? e.message : "Invalid git fragment", + }; + } + + const hosted = parseHostedGit(spec); + + return { + kind: "git", + raw, + gitCommittish: fragmentData.committish, + gitRange: fragmentData.semverRange, + gitSubdir: fragmentData.subdir, + hosted, + }; + } + + // ========================================================================= + // HTTP/HTTPS URLs + // ========================================================================= + if (/^https?:\/\//i.test(spec)) { + return { kind: "url", raw, url: spec }; + } + + // ========================================================================= + // Registry specs (semver, version, tag) + // ========================================================================= + return parseRegistrySpec(spec); +} + +/** + * Parse a registry spec (semver range, exact version, or tag). + */ +function parseRegistrySpec(spec: string): RegistrySpec | UnknownSpec { + const trimmed = spec.trim(); + + // Empty or wildcard = valid semver range + if (trimmed === "" || trimmed === "*") { + return { kind: "semver", raw: spec, fetchSpec: trimmed || "*" }; + } + + // Check for semver range characters + if (looksLikeSemver(trimmed)) { + // Further validation would use parseRange() but that throws + // For now, if it looks like semver, treat it as semver + // The exact vs range distinction is less important for routing + if (/^\d+\.\d+\.\d+/.test(trimmed) && !/[~^<>=|]/.test(trimmed)) { + return { kind: "version", raw: spec, fetchSpec: trimmed }; + } + return { kind: "semver", raw: spec, fetchSpec: trimmed }; + } + + // Check if valid dist-tag + if (isValidDistTag(trimmed)) { + return { kind: "tag", raw: spec, fetchSpec: trimmed }; + } + + return { + kind: "unknown", + raw: spec, + error: "Not a valid semver range or dist-tag", + }; +} + +// ============================================================================= +// Backwards Compatibility +// ============================================================================= + +/** + * @deprecated Use parseNpmSpec instead + */ +export const parseNpmDependencySpec = parseNpmSpec; + +// ============================================================================= +// Type Guards +// ============================================================================= + +/** + * Check if spec is a URL to be handled by TarballPlugin. + */ +export function isUrlSpec(spec: NpmDependencySpec): spec is UrlSpec { + return spec.kind === "url"; +} + +/** + * Check if spec is an npm alias. + */ +export function isAliasSpec( + spec: NpmDependencySpec +): spec is AliasSpec { + return spec.kind === "alias"; +} + +/** + * Check if spec is a git reference. + */ +export function isGitSpec(spec: NpmDependencySpec): spec is GitSpec { + return spec.kind === "git"; +} + +/** + * Check if spec resolves via npm registry. + */ +export function isRegistrySpec( + spec: NpmDependencySpec +): spec is RegistrySpec | AliasSpec { + return ( + spec.kind === "semver" || + spec.kind === "version" || + spec.kind === "tag" || + spec.kind === "alias" + ); +} + +/** + * Check if spec is unsupported in browser context. + */ +export function isUnsupportedSpec(spec: NpmDependencySpec): boolean { + return ( + spec.kind === "git" || + spec.kind === "file" || + spec.kind === "directory" || + spec.kind === "workspace" || + spec.kind === "link" + ); +} + +/** + * Check if spec is a standard npm version (semver or tag). + * @deprecated Use isRegistrySpec instead + */ +export function isNpmVersionSpec(spec: NpmDependencySpec): boolean { + return spec.kind === "semver" || spec.kind === "version" || spec.kind === "tag"; +} + +// ============================================================================= +// Path Utilities +// ============================================================================= + +/** + * Join two subpaths, handling slashes. + * + * @example + * joinSubpath("/build", "modern") // "/build/modern" + * joinSubpath("", "/dist/index.js") // "/dist/index.js" + */ +export function joinSubpath(a: string, b: string): string { + const left = (a ?? "").replace(/\/+$/, ""); + const right = (b ?? "").replace(/^\/+/, ""); + if (!left && !right) return ""; + if (!left) return "/" + right; + if (!right) return left.startsWith("/") ? left : "/" + left; + return (left.startsWith("/") ? left : "/" + left) + "/" + right; +} + +/** + * Append a subpath to a URL's pathname. + * + * @example + * appendUrlSubpath("https://pkg.pr.new/@tanstack/react-query@7988", "/build/modern") + * // "https://pkg.pr.new/@tanstack/react-query@7988/build/modern" + */ +export function appendUrlSubpath(baseUrl: string, subpath: string): string { + if (!subpath) return baseUrl; + const u = new URL(baseUrl); + u.pathname = joinSubpath(u.pathname, subpath); + return u.toString(); +} + +// ============================================================================= +// Error Messages +// ============================================================================= + +/** + * Generate user-friendly error for unsupported spec types. + */ +export function getUnsupportedSpecError( + spec: NpmDependencySpec, + packageName: string +): string { + switch (spec.kind) { + case "git": { + const gitSpec = spec as GitSpec; + const hostedInfo = gitSpec.hosted + ? ` (${gitSpec.hosted.type}: ${gitSpec.hosted.user}/${gitSpec.hosted.project})` + : ""; + return ( + `Package "${packageName}" uses a git dependency${hostedInfo}: "${spec.raw}". ` + + `Git dependencies are not supported in browser bundlers. ` + + `Consider using an npm-published version or a tarball URL instead.` + ); + } + case "file": + return ( + `Package "${packageName}" uses a file dependency: "${spec.raw}". ` + + `Local file dependencies cannot be resolved in a browser bundler. ` + + `Upload the package to npm or use a tarball URL instead.` + ); + case "directory": + return ( + `Package "${packageName}" uses a directory dependency: "${spec.raw}". ` + + `Local directory dependencies cannot be resolved in a browser bundler. ` + + `Upload the package to npm or use a tarball URL instead.` + ); + case "workspace": + return ( + `Package "${packageName}" uses a workspace dependency: "${spec.raw}". ` + + `Workspace protocols require a monorepo context. ` + + `Use an explicit version or publish the package to npm.` + ); + case "link": + return ( + `Package "${packageName}" uses a link dependency: "${spec.raw}". ` + + `Link protocols require filesystem access. ` + + `Use an explicit version or publish the package to npm.` + ); + case "unknown": { + const error = (spec as UnknownSpec).error; + return ( + `Package "${packageName}" has an invalid dependency: "${spec.raw}". ` + + (error ? `${error}. ` : "") + + `Use a semver version, dist-tag, npm alias, or tarball URL.` + ); + } + default: + return ( + `Package "${packageName}" uses an unsupported format: "${spec.raw}". ` + + `Use a semver version, dist-tag, npm alias, or tarball URL.` + ); + } +} \ No newline at end of file diff --git a/utils/parse-package-name.ts b/utils/parse-package-name.ts index c1358f7..6048ff1 100644 --- a/utils/parse-package-name.ts +++ b/utils/parse-package-name.ts @@ -1,30 +1,378 @@ -/** - * Based on `parse-package-name` (https://npmjs.com/parse-package-name) by @egoist (https://github.com/egoist) +/** + * Enhanced npm package name parser. + * + * Parses package specifiers in various formats: + * - Simple: react, lodash + * - Versioned: react@18.2.0, lodash@^4.17.0 + * - Scoped: @types/node, @tanstack/react-query@5.0.0 + * - With subpath: lodash/get, @emotion/react@11/jsx-runtime + * + * Enhancements over basic parser: + * - Integrates package name validation + * - Provides URL-escaped name for registry calls + * - Extracts scope separately + * - Includes structured validation result + * + * @module + * + * @example Basic usage + * ```ts + * import { parsePackageName, parsePackageSpec } from "./parse-package-name.ts"; + * + * const result = parsePackageName("@tanstack/react-query@5.0.0/build"); + * // { + * // name: "@tanstack/react-query", + * // version: "5.0.0", + * // path: "/build", + * // scope: "@tanstack", + * // escapedName: "@tanstack%2freact-query", + * // isScoped: true + * // } + * ``` */ -/** Parsed a scoped package name into name, version, and path. */ -export const RE_SCOPED = /^(@[^\/]+\/[^@\/]+)(?:@([^\/]+))?(\/.*)?$/; +import { validatePackageName, type ValidateResult } from "./validate-package-name.ts"; -/** Parsed a non-scoped package name into name, version, path */ -export const RE_NON_SCOPED = /^([^@\/]+)(?:@([^\/]+))?(\/.*)?$/; +// ============================================================================= +// Types +// ============================================================================= +/** + * Options for parsing package names. + */ export interface ParsePackageOptions { + /** + * If true, return partial result instead of throwing on parse errors. + * @default false + */ ignoreError?: boolean; - defaultVersion?: string | null + + /** + * Default version when none is specified. + * Set to null to indicate no version. + * @default "latest" + */ + defaultVersion?: string | null; + + /** + * Whether to validate the package name. + * @default false + */ + validate?: boolean; +} + +/** + * Result of parsing a package specifier. + */ +export interface ParsedPackage { + /** Full package name (e.g., "@types/node") */ + name: string; + /** Version or version range (e.g., "^18.0.0") */ + version: string | null; + /** Subpath within package (e.g., "/get") */ + path: string; + /** Package scope if scoped (e.g., "@types") */ + scope: string | null; + /** URL-escaped name for registry API calls */ + escapedName: string; + /** Whether this is a scoped package */ + isScoped: boolean; + /** Validation result if validate=true was passed */ + validation?: ValidateResult; +} + +/** + * Extended result including the original input. + */ +export interface ParsedPackageSpec extends ParsedPackage { + /** Original input string */ + raw: string; + /** Whether the input contained an explicit version */ + hasExplicitVersion: boolean; } -export function parsePackageName(input: string, { ignoreError = false, defaultVersion = "latest" }: ParsePackageOptions = { }) { - const m = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); +// ============================================================================= +// Regex Patterns +// ============================================================================= + +/** + * Pattern for scoped packages: @scope/name@version/path + * + * Groups: + * 1. Full scoped name (@scope/name) + * 2. Version (optional) + * 3. Path (optional) + */ +export const RE_SCOPED = /^(@[^/]+\/[^@/]+)(?:@([^/]+))?(\/.*)?$/; + +/** + * Pattern for non-scoped packages: name@version/path + * + * Groups: + * 1. Package name + * 2. Version (optional) + * 3. Path (optional) + */ +export const RE_NON_SCOPED = /^([^@/]+)(?:@([^/]+))?(\/.*)?$/; + +// ============================================================================= +// Core Parsing +// ============================================================================= + +/** + * Parse a package name string into components. + * + * @param input Package specifier string + * @param options Parsing options + * @returns Parsed components + * @throws Error if parse fails and ignoreError is false + * + * @example Scoped package + * ```ts + * parsePackageName("@tanstack/react-query@5.0.0/build") + * // { + * // name: "@tanstack/react-query", + * // version: "5.0.0", + * // path: "/build", + * // scope: "@tanstack", + * // escapedName: "@tanstack%2freact-query", + * // isScoped: true + * // } + * ``` + * + * @example Non-scoped package + * ```ts + * parsePackageName("lodash@^4.17.0/get") + * // { + * // name: "lodash", + * // version: "^4.17.0", + * // path: "/get", + * // scope: null, + * // escapedName: "lodash", + * // isScoped: false + * // } + * ``` + */ +export function parsePackageName( + input: string, + options: ParsePackageOptions = {} +): ParsedPackage { + const { + ignoreError = false, + defaultVersion = "latest", + validate = false, + } = options; + + // Try scoped pattern first, then non-scoped + const match = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); - if (!m && !ignoreError) { - throw new Error(`[parse-package-name] invalid package name: ${input}`); + if (!match) { + if (!ignoreError) { + throw new Error(`[parse-package-name] invalid package name: ${input}`); + } + return { + name: "", + version: defaultVersion, + path: "", + scope: null, + escapedName: "", + isScoped: false, + ...(validate ? { validation: { valid: false, errors: ["Invalid format"], warnings: [] } } : {}), + }; } + const name = match[1] || ""; + const version = match[2] ?? defaultVersion; + const path = match[3] || ""; + + // Extract scope from scoped packages + const isScoped = name.startsWith("@"); + const scope = isScoped ? name.slice(0, name.indexOf("/")) : null; + + // Escape name for registry URLs (/ -> %2f) + const escapedName = name.replace("/", "%2f"); + + // Optionally validate + const validation = validate ? validatePackageName(name) : undefined; + + return { + name, + version, + path, + scope, + escapedName, + isScoped, + ...(validation ? { validation } : {}), + }; +} + +/** + * Parse a package specifier with additional metadata. + * + * @param input Package specifier string + * @param options Parsing options + * @returns Extended parsed result + */ +export function parsePackageSpec( + input: string, + options: ParsePackageOptions = {} +): ParsedPackageSpec { + const parsed = parsePackageName(input, options); + return { - name: m?.[1] || "", - version: m?.[2] || defaultVersion, - path: m?.[3] || "", + ...parsed, + raw: input, + hasExplicitVersion: Boolean( + RE_SCOPED.exec(input)?.[2] || RE_NON_SCOPED.exec(input)?.[2] + ), }; } +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Check if a string looks like a package name (not a path, URL, etc). + * + * @param input String to check + * @returns True if it looks like a package name + * + * @example + * ```ts + * isPackageName("react") // true + * isPackageName("@types/node") // true + * isPackageName("./local") // false + * isPackageName("https://...") // false + * ``` + */ +export function isPackageName(input: string): boolean { + if (!input || typeof input !== "string") return false; + + // Not a path + if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) { + return false; + } + + // Not a URL + if (/^[a-z][a-z0-9+.-]*:/i.test(input)) { + return false; + } + + // Try to parse + const match = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); + return match !== null; +} + +/** + * Build a package specifier string from components. + * + * @param name Package name + * @param version Optional version + * @param path Optional subpath + * @returns Package specifier string + * + * @example + * ```ts + * buildPackageSpec("react", "18.2.0") + * // "react@18.2.0" + * + * buildPackageSpec("@types/node", "^20", "/fs") + * // "@types/node@^20/fs" + * ``` + */ +export function buildPackageSpec( + name: string, + version?: string | null, + path?: string +): string { + let result = name; + + if (version) { + result += `@${version}`; + } + + if (path) { + // Ensure path starts with / + result += path.startsWith("/") ? path : `/${path}`; + } + + return result; +} + +/** + * Normalize a subpath (ensure leading slash, remove trailing slash). + * + * @param path Subpath to normalize + * @returns Normalized path + */ +export function normalizeSubpath(path: string): string { + if (!path) return ""; + + let normalized = path; + + // Ensure leading slash + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + + // Remove trailing slash (unless it's just "/") + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + + return normalized; +} + +/** + * Join multiple subpaths. + * + * @param paths Paths to join + * @returns Joined path + */ +export function joinSubpaths(...paths: (string | undefined | null)[]): string { + const parts = paths + .filter((p): p is string => Boolean(p)) + .flatMap((p) => p.split("/").filter(Boolean)); + + if (parts.length === 0) return ""; + return "/" + parts.join("/"); +} + +// ============================================================================= +// Scope Utilities +// ============================================================================= + +/** + * Extract scope from a package name. + * + * @param name Package name + * @returns Scope with @ prefix, or null + */ +export function getScope(name: string): string | null { + if (!name.startsWith("@")) return null; + const slashIdx = name.indexOf("/"); + return slashIdx > 0 ? name.slice(0, slashIdx) : null; +} + +/** + * Get the unscoped name from a package name. + * + * @param name Package name + * @returns Name without scope + * + * @example + * getUnscopedName("@types/node") // "node" + * getUnscopedName("react") // "react" + */ +export function getUnscopedName(name: string): string { + if (!name.startsWith("@")) return name; + const slashIdx = name.indexOf("/"); + return slashIdx > 0 ? name.slice(slashIdx + 1) : name; +} + +// ============================================================================= +// Default Export (for backwards compatibility) +// ============================================================================= + export default parsePackageName; \ No newline at end of file diff --git a/utils/resolve-conditions.ts b/utils/resolve-conditions.ts new file mode 100644 index 0000000..5b4e1dd --- /dev/null +++ b/utils/resolve-conditions.ts @@ -0,0 +1,540 @@ +/** + * Package exports/imports condition resolver. + * + * Computes the appropriate export conditions for resolving Node.js conditional exports + * based on the target platform, runtime, and build configuration. + * + * Supports multiple runtimes: + * - Node.js (default when platform="node") + * - Browsers (default when platform="browser") + * - Deno + * - Bun + * - Electron (main and renderer) + * - React Native + * - Edge runtimes (Cloudflare Workers, Vercel Edge, etc.) + * + * @module + * + * @example Basic usage + * ```ts + * import { getResolverConditions, detectRuntime } from "./resolve-conditions.ts"; + * + * // Browser build + * const browserConditions = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "browser" } + * ); + * // conditions: ["import", "browser", "module", "default"] + * + * // Deno build + * const denoConditions = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "neutral", runtime: "deno" } + * ); + * // conditions: ["import", "deno", "node", "module", "default"] + * ``` + * + * @see https://nodejs.org/api/packages.html#conditional-exports + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * esbuild platform option. + */ +export type Platform = "browser" | "node" | "neutral"; + +/** + * esbuild output format. + */ +export type Format = "iife" | "cjs" | "esm"; + +/** + * esbuild import kinds. + */ +export type ImportKind = + | "entry-point" + | "import-statement" + | "require-call" + | "dynamic-import" + | "require-resolve" + | "import-rule" + | "composes-from" + | "url-token"; + +/** + * Target runtime for resolution. + * + * These are distinct from esbuild's `platform` because many runtimes + * don't map cleanly to browser/node/neutral. + */ +export type ResolveRuntime = + | "react-native" + | "electron-main" + | "electron-renderer" + | "electron-preload" + | "deno" + | "bun" + | "workerd" + | "edge-light" + | "netlify" + | "vercel"; + +/** + * Input options for computing resolver conditions. + */ +export interface ResolverConditionInputs { + platform?: Platform; + conditions?: string[]; + format?: Format; + mainFields?: string[]; + runtime?: ResolveRuntime; + runtimeConditions?: readonly string[]; +} + +/** + * Computed conditions result. + */ +export interface ResolverConditions { + /** Whether to respect package.json browser field */ + browser: boolean; + /** Whether we're in CommonJS require context */ + require: boolean; + /** Ordered list of conditions for exports resolution */ + conditions: string[]; +} + +/** + * Runtime policy defaults. + */ +export interface RuntimeDefaults { + conditions: string[]; + browserField: boolean | null; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Well-known export conditions organized by category. + */ +export const KNOWN_CONDITIONS = { + // Module system + import: "import", + require: "require", + module: "module", + default: "default", + + // Platform + browser: "browser", + node: "node", + deno: "deno", + bun: "bun", + + // Environment + development: "development", + production: "production", + + // Runtimes + "react-native": "react-native", + electron: "electron", + workerd: "workerd", + worker: "worker", + "edge-light": "edge-light", + + // Bundlers + bundler: "bundler", + types: "types", +} as const; + +/** + * Default legacy main fields by platform. + */ +export const DEFAULT_MAIN_FIELDS = { + browser: ["browser", "module", "main"], + node: ["module", "main"], + neutral: ["module", "main"], +} as const; + +// ============================================================================= +// Runtime Detection +// ============================================================================= + +/** + * Detect the current runtime environment. + * + * Useful for auto-configuring build settings. + * + * @returns Detected runtime or null if unknown + * + * @example + * ```ts + * const runtime = detectRuntime(); + * if (runtime === "deno") { + * // Configure for Deno + * } + * ``` + */ +export function detectRuntime(): ResolveRuntime | null { + // Check for Deno global + if (typeof globalThis !== "undefined" && "Deno" in globalThis) { + return "deno"; + } + + // Check for Bun global + if (typeof globalThis !== "undefined" && "Bun" in globalThis) { + return "bun"; + } + + // Check for Cloudflare Workers (workerd) + if ( + typeof globalThis !== "undefined" && + "caches" in globalThis && + typeof (globalThis as any).caches?.default !== "undefined" + ) { + return "workerd"; + } + + // Check for Node.js with Electron + if (typeof globalThis?.process !== "undefined" && globalThis?.process?.versions) { + if (globalThis?.process?.versions?.electron) { + // Check if we're in main or renderer + if (typeof window !== "undefined") { + return "electron-renderer"; + } + return "electron-main"; + } + } + + // Note: React Native detection requires different approach (not reliable from globals) + + return null; +} + +/** + * Get the recommended platform for a runtime. + * + * @param runtime Target runtime + * @returns Recommended esbuild platform + */ +export function getPlatformForRuntime(runtime: ResolveRuntime): Platform { + switch (runtime) { + case "react-native": + case "deno": + case "bun": + case "electron-main": + case "electron-preload": + return "node"; // Node-compatible APIs + + case "electron-renderer": + case "workerd": + case "edge-light": + case "netlify": + case "vercel": + return "browser"; // Web-like APIs + + default: + return "neutral"; + } +} + +// ============================================================================= +// Runtime Defaults +// ============================================================================= + +/** + * Get runtime-specific default conditions and browser field behavior. + * + * @param runtime Target runtime + * @returns Default conditions and browser field setting + */ +export function getRuntimeDefaults( + runtime: ResolveRuntime | undefined +): RuntimeDefaults { + if (!runtime) { + return { conditions: [], browserField: null }; + } + + switch (runtime) { + case "react-native": + // Metro bundler uses "react-native" condition + // Not browser, so disable browser field mapping + return { conditions: ["react-native"], browserField: false }; + + case "electron-main": + // Main process is Node-like + return { conditions: ["electron", "node"], browserField: false }; + + case "electron-renderer": + // Renderer is web-like (Chromium) + return { conditions: ["electron", "browser"], browserField: true }; + + case "electron-preload": + // Preload scripts are special (Node + limited DOM) + return { conditions: ["electron", "node"], browserField: false }; + + case "deno": + // Deno has Node compatibility layer + return { conditions: ["deno", "node"], browserField: false }; + + case "bun": + // Bun is Node-compatible with its own condition + return { conditions: ["bun", "node"], browserField: false }; + + case "workerd": + // Cloudflare Workers / workerd + return { conditions: ["workerd", "worker", "browser"], browserField: false }; + + case "edge-light": + // Vercel Edge, other edge runtimes + return { conditions: ["edge-light", "worker", "browser"], browserField: true }; + + case "netlify": + // Netlify Edge Functions + return { conditions: ["netlify", "edge-light", "worker"], browserField: false }; + + case "vercel": + // Vercel Edge Functions + return { conditions: ["vercel", "edge-light", "worker"], browserField: false }; + + default: + return { conditions: [], browserField: null }; + } +} + +// ============================================================================= +// Condition Computation +// ============================================================================= + +/** + * Check if an import kind represents require() usage. + */ +export function isRequireKind(kind: ImportKind): boolean { + return kind === "require-call" || kind === "require-resolve"; +} + +/** + * Check if the resolution context is CommonJS. + */ +export function isRequireContext( + args: { kind: ImportKind }, + esbuildOpts: { format?: Format } +): boolean { + if (isRequireKind(args.kind)) return true; + if (args.kind === "entry-point") return esbuildOpts.format === "cjs"; + return false; +} + +/** + * Compute the set of conditions for package exports resolution. + * + * Combines: + * 1. Import/require context + * 2. Platform conditions (browser, node) + * 3. esbuild's implicit "module" condition + * 4. Runtime overlay + * 5. User-specified conditions + * 6. Always includes "default" + * + * @param args Resolution args (needs kind) + * @param esbuildOpts Build options + * @returns Computed conditions + * + * @example Default browser build + * ```ts + * const c = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "browser", format: "esm" } + * ); + * // c.conditions: ["import", "browser", "module", "default"] + * // c.browser: true + * ``` + * + * @example React Native build + * ```ts + * const c = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "neutral", runtime: "react-native" } + * ); + * // c.conditions: ["import", "module", "react-native", "default"] + * // c.browser: false + * ``` + */ +export function getResolverConditions( + args: { kind: ImportKind }, + esbuildOpts: ResolverConditionInputs +): ResolverConditions { + const platform = esbuildOpts.platform ?? "browser"; + + // Determine require vs import context + const require = isRequireContext(args, esbuildOpts); + + // Browser field is enabled by default for browser platform + let browserField = platform === "browser"; + + // Check if user provided explicit conditions + const userProvidedConditions = esbuildOpts.conditions !== undefined; + + // Only auto-add "module" for browser/node when no explicit conditions + const mayAutoAddModule = + !userProvidedConditions && (platform === "browser" || platform === "node"); + + const computed: string[] = []; + + // 1. Import/require dimension + computed.push(require ? "require" : "import"); + + // 2. Platform dimension + if (platform === "browser") computed.push("browser"); + if (platform === "node") computed.push("node"); + + // 3. Auto-add "module" for bundlers (esbuild convention) + if (mayAutoAddModule) computed.push("module"); + + // 4. Runtime overlay (additive, doesn't affect mayAutoAddModule) + const runtimeDefaults = getRuntimeDefaults(esbuildOpts.runtime); + if (runtimeDefaults.browserField !== null) { + browserField = runtimeDefaults.browserField; + } + for (const c of runtimeDefaults.conditions) { + if (!computed.includes(c)) computed.push(c); + } + + // 5. Extra runtime conditions + if (esbuildOpts.runtimeConditions?.length) { + for (const c of esbuildOpts.runtimeConditions) { + if (!computed.includes(c)) computed.push(c); + } + } + + // 6. User-specified conditions + if (esbuildOpts.conditions?.length) { + for (const c of esbuildOpts.conditions) { + if (!computed.includes(c)) computed.push(c); + } + } + + // 7. "default" is always active + if (!computed.includes("default")) computed.push("default"); + + return { + browser: browserField, + require, + conditions: computed, + }; +} + +// ============================================================================= +// Legacy Main Fields +// ============================================================================= + +/** + * Get the legacy main field resolution order. + * + * Used when package.json doesn't have "exports" field. + * + * @param manifest Package.json contents + * @param args Resolution args + * @param esbuildOpts Build options + * @returns Ordered list of fields to check + */ +export function getLegacyMainFields( + manifest: { type?: string }, + args: { kind: ImportKind }, + esbuildOpts: ResolverConditionInputs +): string[] { + // User-specified mainFields take precedence + if (esbuildOpts.mainFields?.length) { + return esbuildOpts.mainFields; + } + + const platform = esbuildOpts.platform ?? "browser"; + const require = isRequireContext(args, esbuildOpts); + const isModulePackage = manifest.type === "module"; + + // Base fields by platform + const baseFields = DEFAULT_MAIN_FIELDS[platform] ?? DEFAULT_MAIN_FIELDS.neutral; + const fields = [...baseFields]; + + // For require context in non-module packages, prefer main + if (require && !isModulePackage) { + // Move "main" to front + const mainIdx = fields.indexOf("main"); + if (mainIdx > 0) { + fields.splice(mainIdx, 1); + fields.unshift("main"); + } + } + + return fields; +} + +// ============================================================================= +// Condition Utilities +// ============================================================================= + +/** + * Check if a condition is satisfied. + * + * @param condition Condition to check + * @param activeConditions List of active conditions + * @returns True if condition is active + */ +export function conditionMatches( + condition: string, + activeConditions: string[] +): boolean { + return activeConditions.includes(condition); +} + +/** + * Merge condition sets, preserving order and removing duplicates. + * + * @param base Base conditions + * @param additions Conditions to add + * @returns Merged conditions + */ +export function mergeConditions( + base: string[], + additions: string[] +): string[] { + const result = [...base]; + for (const c of additions) { + if (!result.includes(c)) { + result.push(c); + } + } + return result; +} + +/** + * Get a description of what a condition is for. + * + * @param condition Condition name + * @returns Human-readable description + */ +export function describeCondition(condition: string): string { + const descriptions: Record = { + import: "ESM import statements", + require: "CommonJS require() calls", + module: "Bundler-aware ESM entry", + default: "Fallback entry", + browser: "Browser environments", + node: "Node.js environments", + deno: "Deno runtime", + bun: "Bun runtime", + "react-native": "React Native apps", + electron: "Electron apps", + workerd: "Cloudflare Workers", + worker: "Web Workers", + "edge-light": "Edge runtimes", + development: "Development builds", + production: "Production builds", + types: "TypeScript type definitions", + }; + + return descriptions[condition] ?? `Custom condition: ${condition}`; +} \ No newline at end of file diff --git a/utils/resolve-import-map.ts b/utils/resolve-import-map.ts new file mode 100644 index 0000000..ca8d4b5 --- /dev/null +++ b/utils/resolve-import-map.ts @@ -0,0 +1,725 @@ +/** + * Import Map Utilities (HTML Spec Compliant) + * + * Utilities for working with import maps as defined in the HTML Standard. + * Supports browser native import maps and Deno/Bun configurations. + * + * This implementation follows the WHATWG HTML specification for import maps: + * https://html.spec.whatwg.org/multipage/webappapis.html#import-maps + * + * @module + * + * @example Basic usage + * ```ts + * import { + * createImportMap, + * resolveImportMap, + * validateImportMap, + * } from "./import-map.ts"; + * + * const map = createImportMap({ + * imports: { + * "react": "https://esm.sh/react@18", + * "lodash/": "https://esm.sh/lodash-es/" + * } + * }); + * + * resolveImportMap(map, "react") // "https://esm.sh/react@18" + * resolveImportMap(map, "lodash/get") // "https://esm.sh/lodash-es/get" + * ``` + * + * @see https://html.spec.whatwg.org/multipage/webappapis.html#import-maps + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap + * @see https://docs.deno.com/runtime/fundamentals/modules/#import-maps + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Import map structure as defined by the HTML spec. + * + * @example Basic import map + * ```json + * { + * "imports": { + * "react": "https://esm.sh/react@18", + * "lodash/": "https://esm.sh/lodash-es/" + * } + * } + * ``` + * + * @example With scopes + * ```json + * { + * "imports": { + * "react": "https://esm.sh/react@18" + * }, + * "scopes": { + * "/vendor/": { + * "react": "https://esm.sh/react@17" + * } + * } + * } + * ``` + * + * @example With integrity (browser extension) + * ```json + * { + * "imports": { + * "lodash": "https://esm.sh/lodash-es@4" + * }, + * "integrity": { + * "https://esm.sh/lodash-es@4": "sha384-..." + * } + * } + * ``` + */ +export interface ImportMap { + /** Top-level import mappings (specifier → URL/path) */ + imports?: Record; + /** + * Scoped import mappings. + * Keys are URL prefixes, values are mappings that apply when + * importing from scripts whose URL starts with that prefix. + */ + scopes?: Record>; + /** + * Integrity metadata (optional browser extension). + * Maps URLs to subresource integrity hashes. + */ + integrity?: Record; +} + +/** + * Import map generation options. + */ +export interface GenerateImportMapOptions { + /** Base URL for relative resolutions */ + baseUrl?: string; + /** CDN to use for npm packages */ + cdn?: "esm.sh" | "unpkg" | "jsdelivr" | "skypack" | "jsr"; + /** Include dev dependencies */ + includeDevDeps?: boolean; + /** Include peer dependencies */ + includePeerDeps?: boolean; + /** Custom URL generator */ + urlGenerator?: (name: string, version: string) => string; +} + +/** + * Package.json dependencies subset. + */ +interface PackageDeps { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +/** + * Validation result for import maps. + */ +export interface ImportMapValidation { + /** Whether the import map is valid (no errors) */ + valid: boolean; + /** Errors that prevent the import map from working */ + errors: string[]; + /** Warnings about potential issues */ + warnings: string[]; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * CDN URL templates for package resolution. + * + * @example + * ```ts + * CDN_TEMPLATES["esm.sh"]("react", "^18.2.0") + * // "https://esm.sh/react@^18.2.0" + * + * CDN_TEMPLATES["jsr"]("@std/path", "1.0.0") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * ``` + */ +export const CDN_TEMPLATES: Record string> = { + "esm.sh": (name, version) => `https://esm.sh/${name}@${version}`, + unpkg: (name, version) => `https://unpkg.com/${name}@${version}`, + jsdelivr: (name, version) => `https://cdn.jsdelivr.net/npm/${name}@${version}`, + skypack: (name, version) => `https://cdn.skypack.dev/${name}@${version}`, + jsr: (name, version) => { + // JSR packages are always scoped + if (name.startsWith("@")) { + const [scope, pkg] = name.slice(1).split("/"); + return `https://jsr.io/@${scope}/${pkg}/${version}/mod.ts`; + } + // Non-scoped name can't be a JSR package + return `https://esm.sh/${name}@${version}`; + }, +}; + +// ============================================================================= +// Import Map Creation +// ============================================================================= + +/** + * Create an import map from configuration. + * + * Creates a copy to avoid mutations to the original. + * + * @param config Import map configuration + * @returns Validated import map + * + * @example + * ```ts + * const map = createImportMap({ + * imports: { + * "react": "https://esm.sh/react@18", + * "react-dom": "https://esm.sh/react-dom@18" + * }, + * scopes: { + * "/vendor/": { + * "react": "https://esm.sh/react@17" + * } + * } + * }); + * ``` + */ +export function createImportMap(config: ImportMap): ImportMap { + const map: ImportMap = {}; + + if (config.imports) { + map.imports = { ...config.imports }; + } + + if (config.scopes) { + map.scopes = {}; + for (const [scope, mappings] of Object.entries(config.scopes)) { + map.scopes[scope] = { ...mappings }; + } + } + + if (config.integrity) { + map.integrity = { ...config.integrity }; + } + + return map; +} + +/** + * Generate an import map from package.json dependencies. + * + * @param pkg Package.json contents + * @param options Generation options + * @returns Generated import map + * + * @example Basic usage + * ```ts + * const pkg = { + * dependencies: { + * "react": "^18.2.0", + * "lodash": "^4.17.0" + * } + * }; + * + * generateImportMap(pkg, { cdn: "esm.sh" }); + * // { + * // imports: { + * // "react": "https://esm.sh/react@^18.2.0", + * // "lodash": "https://esm.sh/lodash@^4.17.0" + * // } + * // } + * ``` + */ +export function generateImportMap( + pkg: PackageDeps, + options: GenerateImportMapOptions = {} +): ImportMap { + const { + cdn = "esm.sh", + includeDevDeps = false, + includePeerDeps = false, + urlGenerator = CDN_TEMPLATES[cdn], + } = options; + + const imports: Record = {}; + + // Add dependencies + if (pkg.dependencies) { + for (const [name, version] of Object.entries(pkg.dependencies)) { + imports[name] = urlGenerator(name, cleanVersion(version)); + } + } + + // Add dev dependencies (don't override regular deps) + if (includeDevDeps && pkg.devDependencies) { + for (const [name, version] of Object.entries(pkg.devDependencies)) { + if (!(name in imports)) { + imports[name] = urlGenerator(name, cleanVersion(version)); + } + } + } + + // Add peer dependencies (don't override) + if (includePeerDeps && pkg.peerDependencies) { + for (const [name, version] of Object.entries(pkg.peerDependencies)) { + if (!(name in imports)) { + imports[name] = urlGenerator(name, cleanVersion(version)); + } + } + } + + return { imports }; +} + +/** + * Clean version string for URL usage. + */ +function cleanVersion(version: string): string { + // Remove npm: alias prefix + if (version.startsWith("npm:")) { + version = version.slice(4); + } + + // Remove workspace: prefix + if (version.startsWith("workspace:")) { + return "latest"; + } + + // Remove jsr: prefix + if (version.startsWith("jsr:")) { + version = version.slice(4); + } + + return version; +} + +// ============================================================================= +// Import Map Merging +// ============================================================================= + +/** + * Merge multiple import maps. + * + * Per the HTML spec, later maps take precedence over earlier ones. + * + * @param maps Import maps to merge (later takes precedence) + * @returns Merged import map + * + * @example + * ```ts + * const base = { + * imports: { "react": "https://esm.sh/react@17" } + * }; + * const override = { + * imports: { "react": "https://esm.sh/react@18" } + * }; + * + * mergeImportMaps(base, override); + * // { imports: { "react": "https://esm.sh/react@18" } } + * ``` + */ +export function mergeImportMaps(...maps: ImportMap[]): ImportMap { + const result: ImportMap = {}; + + for (const map of maps) { + // Merge imports + if (map.imports) { + result.imports = { ...result.imports, ...map.imports }; + } + + // Merge scopes (deep merge) + if (map.scopes) { + result.scopes = result.scopes || {}; + for (const [scope, mappings] of Object.entries(map.scopes)) { + result.scopes[scope] = { ...result.scopes[scope], ...mappings }; + } + } + + // Merge integrity + if (map.integrity) { + result.integrity = { ...result.integrity, ...map.integrity }; + } + } + + return result; +} + +/** + * Add entries to an import map. + * + * @param map Base import map + * @param imports Entries to add + * @param scope Optional scope for scoped imports + * @returns New import map with additions + */ +export function addToImportMap( + map: ImportMap, + imports: Record, + scope?: string +): ImportMap { + const result: ImportMap = { + imports: map.imports ? { ...map.imports } : undefined, + scopes: map.scopes ? { ...map.scopes } : undefined, + integrity: map.integrity ? { ...map.integrity } : undefined, + }; + + if (scope) { + result.scopes = result.scopes || {}; + result.scopes[scope] = { ...result.scopes[scope], ...imports }; + } else { + result.imports = { ...result.imports, ...imports }; + } + + return result; +} + +// ============================================================================= +// Import Map Resolution (HTML Spec Compliant) +// ============================================================================= + +/** + * Resolve a specifier against mappings. + * + * Implements the resolution algorithm from the HTML spec: + * 1. Check for exact match + * 2. Check for package prefix match (keys ending with /) + */ +function resolveFromMappings( + mappings: Record, + specifier: string +): string | null { + // 1. Exact match + if (specifier in mappings) { + return mappings[specifier]; + } + + // 2. Package prefix match - sorted by length (longest first) per spec + const prefixKeys = Object.keys(mappings) + .filter((k) => k.endsWith("/")) + .sort((a, b) => b.length - a.length); + + for (const key of prefixKeys) { + if (specifier.startsWith(key)) { + const suffix = specifier.slice(key.length); + const value = mappings[key]; + // Value should also end with / for proper subpath handling + return value.endsWith("/") ? value + suffix : value + "/" + suffix; + } + } + + return null; +} + +/** + * Resolve a specifier using an import map. + * + * Implements the WHATWG HTML import map resolution algorithm: + * 1. If referrer provided, check scopes (longest match first) + * 2. Check top-level imports + * 3. Return null if not mapped + * + * @param map Import map + * @param specifier Module specifier to resolve + * @param referrer Optional referrer URL for scoped resolution + * @returns Resolved URL or null if not mapped + * + * @example Basic resolution + * ```ts + * const map = { + * imports: { + * "react": "https://esm.sh/react@18", + * "lodash/": "https://esm.sh/lodash-es/" + * } + * }; + * + * resolveImportMap(map, "react"); + * // "https://esm.sh/react@18" + * + * resolveImportMap(map, "lodash/get"); + * // "https://esm.sh/lodash-es/get" + * + * resolveImportMap(map, "unknown"); + * // null + * ``` + * + * @example Scoped resolution + * ```ts + * const map = { + * imports: { "react": "https://esm.sh/react@18" }, + * scopes: { + * "/vendor/legacy/": { "react": "https://esm.sh/react@16" }, + * "/vendor/": { "react": "https://esm.sh/react@17" } + * } + * }; + * + * // From a script at /vendor/legacy/old.js + * resolveImportMap(map, "react", "/vendor/legacy/old.js"); + * // "https://esm.sh/react@16" (longest scope match) + * ``` + */ +export function resolveImportMap( + map: ImportMap, + specifier: string, + referrer?: string +): string | null { + // Check scoped mappings first (if referrer provided) + if (referrer && map.scopes) { + // Sort scopes by length (longest first) per spec + const sortedScopes = Object.keys(map.scopes).sort( + (a, b) => b.length - a.length + ); + + for (const scope of sortedScopes) { + if (referrer.startsWith(scope)) { + const resolved = resolveFromMappings(map.scopes[scope], specifier); + if (resolved !== null) return resolved; + } + } + } + + // Check top-level imports + if (map.imports) { + return resolveFromMappings(map.imports, specifier); + } + + return null; +} + +/** + * Get the integrity hash for a URL if specified. + * + * @param map Import map + * @param url URL to look up + * @returns Integrity hash or null + */ +export function getIntegrity(map: ImportMap, url: string): string | null { + return map.integrity?.[url] ?? null; +} + +// ============================================================================= +// Import Map Validation +// ============================================================================= + +/** + * Validate an import map. + * + * Checks for common errors and issues per the HTML spec. + * + * @param map Import map to validate + * @returns Validation result + * + * @example + * ```ts + * validateImportMap({ + * imports: { + * "": "https://example.com", // Empty key - error + * "foo": "not-a-valid-url", // Invalid URL - error + * "bar/": "https://example.com" // Missing trailing / - warning + * } + * }); + * // { + * // valid: false, + * // errors: ["Empty key in imports", "Invalid mapping value..."], + * // warnings: ["Key \"bar/\" ends with /..."] + * // } + * ``` + */ +export function validateImportMap(map: ImportMap): ImportMapValidation { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate imports + if (map.imports) { + for (const [key, value] of Object.entries(map.imports)) { + if (!key) { + errors.push("Empty key in imports"); + continue; + } + + if (!isValidMappingValue(value)) { + errors.push(`Invalid mapping value for "${key}": ${value}`); + } + + if (key.endsWith("/") && !value.endsWith("/")) { + warnings.push( + `Key "${key}" ends with / but value doesn't - subpath imports may fail` + ); + } + } + } + + // Validate scopes + if (map.scopes) { + for (const [scope, mappings] of Object.entries(map.scopes)) { + if (!isValidScopeKey(scope)) { + warnings.push(`Scope "${scope}" should start with / or be a URL`); + } + + for (const [key, value] of Object.entries(mappings)) { + if (!key) { + errors.push(`Empty key in scope "${scope}"`); + continue; + } + + if (!isValidMappingValue(value)) { + errors.push( + `Invalid mapping value for "${key}" in scope "${scope}": ${value}` + ); + } + + if (key.endsWith("/") && !value.endsWith("/")) { + warnings.push( + `Key "${key}" in scope "${scope}" ends with / but value doesn't` + ); + } + } + } + } + + // Validate integrity + if (map.integrity) { + for (const [url, hash] of Object.entries(map.integrity)) { + if (!isValidIntegrityHash(hash)) { + warnings.push(`Invalid integrity hash for "${url}": ${hash}`); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +function isValidMappingValue(value: string): boolean { + return ( + value.startsWith("http://") || + value.startsWith("https://") || + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("data:") || + value.startsWith("blob:") + ); +} + +function isValidScopeKey(scope: string): boolean { + return ( + scope.startsWith("/") || + scope.startsWith("http://") || + scope.startsWith("https://") || + scope.startsWith("./") || + scope.startsWith("../") + ); +} + +function isValidIntegrityHash(hash: string): boolean { + return /^(sha256|sha384|sha512)-[A-Za-z0-9+/]+=*$/.test(hash); +} + +// ============================================================================= +// Serialization +// ============================================================================= + +/** + * Serialize an import map to JSON. + * + * @param map Import map + * @param pretty Whether to pretty-print (default: false) + * @returns JSON string + */ +export function serializeImportMap(map: ImportMap, pretty = false): string { + const output: ImportMap = {}; + if (map.imports && Object.keys(map.imports).length > 0) { + output.imports = map.imports; + } + if (map.scopes && Object.keys(map.scopes).length > 0) { + output.scopes = map.scopes; + } + if (map.integrity && Object.keys(map.integrity).length > 0) { + output.integrity = map.integrity; + } + + return JSON.stringify(output, null, pretty ? 2 : undefined); +} + +/** + * Parse an import map from JSON. + * + * @param json JSON string + * @returns Parsed import map + * @throws Error if JSON is invalid or doesn't match import map structure + */ +export function parseImportMap(json: string): ImportMap { + const parsed = JSON.parse(json); + + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Import map must be an object"); + } + + if (parsed.imports !== undefined && typeof parsed.imports !== "object") { + throw new Error("imports must be an object"); + } + + if (parsed.scopes !== undefined && typeof parsed.scopes !== "object") { + throw new Error("scopes must be an object"); + } + + if (parsed.integrity !== undefined && typeof parsed.integrity !== "object") { + throw new Error("integrity must be an object"); + } + + return parsed as ImportMap; +} + +/** + * Generate an HTML script tag for an import map. + * + * @param map Import map + * @returns HTML script element string + */ +export function toScriptTag(map: ImportMap): string { + const json = serializeImportMap(map, true); + return ``; +} + +// ============================================================================= +// Deno/Bun Integration +// ============================================================================= + +/** + * Convert an import map to Deno's deno.json format. + * + * @param map Import map + * @returns deno.json compatible object + */ +export function toDenoConfig(map: ImportMap): { + imports?: Record; + scopes?: Record>; +} { + return { + imports: map.imports, + scopes: map.scopes, + }; +} + +/** + * Create an import map from Deno's deno.json format. + * + * @param config deno.json contents + * @returns Import map + */ +export function fromDenoConfig(config: { + imports?: Record; + scopes?: Record>; +}): ImportMap { + return { + imports: config.imports, + scopes: config.scopes, + }; +} \ No newline at end of file diff --git a/utils/validate-package-name.ts b/utils/validate-package-name.ts new file mode 100644 index 0000000..c786668 --- /dev/null +++ b/utils/validate-package-name.ts @@ -0,0 +1,324 @@ +/** + * Package name validation following npm's validate-npm-package-name rules. + * + * npm package names must: + * - Be 214 characters or less (including scope) + * - Not start with . or _ + * - Not have uppercase letters + * - Not contain URL-unsafe characters + * - Not be a Node.js core module name + * + * @module + * + * @example + * ```ts + * import { validatePackageName, isValidPackageName } from "./validate-package-name.ts"; + * + * const result = validatePackageName("my-package"); + * // { valid: true, errors: [], warnings: [] } + * + * const invalid = validatePackageName("My-Package"); + * // { valid: false, errors: ["name must be lowercase"], warnings: [] } + * + * if (isValidPackageName("lodash")) { + * // Safe to use + * } + * ``` + * + * @see https://github.com/npm/validate-npm-package-name + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Result of package name validation. + */ +export interface ValidateResult { + /** Whether the name is valid for npm */ + valid: boolean; + /** Errors that make the name invalid */ + errors: string[]; + /** Warnings that don't invalidate but are discouraged */ + warnings: string[]; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Node.js built-in module names. + * These cannot be used as package names. + */ +const NODE_BUILTINS = new Set([ + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "events", + "fs", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "string_decoder", + "sys", + "timers", + "tls", + "trace_events", + "tty", + "url", + "util", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]); + +/** + * "Blacklisted" names that npm reserves. + */ +const BLACKLIST = new Set([ + "node_modules", + "favicon.ico", +]); + +/** + * Characters that must not appear in package names. + * These make URLs problematic. + */ +const SPECIAL_CHARS = /[~'!()*]/; + +/** + * URL-unsafe characters that would need encoding. + * We check this separately from SPECIAL_CHARS for more specific errors. + */ +const URL_UNSAFE = /[^a-z0-9\-._@/]/; + +// ============================================================================= +// Main Validation +// ============================================================================= + +/** + * Validate an npm package name. + * + * @param name Package name to validate + * @returns Validation result with errors and warnings + * + * @example Valid names + * ```ts + * validatePackageName("lodash") + * // { valid: true, errors: [], warnings: [] } + * + * validatePackageName("@scope/package") + * // { valid: true, errors: [], warnings: [] } + * + * validatePackageName("my-package-123") + * // { valid: true, errors: [], warnings: [] } + * ``` + * + * @example Invalid names + * ```ts + * validatePackageName("My-Package") + * // { valid: false, errors: ["name must be lowercase"], ... } + * + * validatePackageName(".hidden") + * // { valid: false, errors: ["name cannot start with a period"], ... } + * + * validatePackageName("http") + * // { valid: false, errors: ["http is a core Node.js module"], ... } + * ``` + */ +export function validatePackageName(name: string): ValidateResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Null/undefined check + if (name == null) { + errors.push("name cannot be null or undefined"); + return { valid: false, errors, warnings }; + } + + // Must be a string + if (typeof name !== "string") { + errors.push("name must be a string"); + return { valid: false, errors, warnings }; + } + + // Cannot be empty + if (name.length === 0) { + errors.push("name cannot be empty"); + return { valid: false, errors, warnings }; + } + + // Length limit (214 characters including scope) + if (name.length > 214) { + errors.push("name cannot be longer than 214 characters"); + } + + // Cannot start with . or _ + if (name.startsWith(".")) { + errors.push("name cannot start with a period"); + } + if (name.startsWith("_")) { + errors.push("name cannot start with an underscore"); + } + + // Cannot have leading or trailing spaces + if (name !== name.trim()) { + errors.push("name cannot have leading or trailing spaces"); + } + + // Must be lowercase + if (name !== name.toLowerCase()) { + errors.push("name must be lowercase"); + } + + // Cannot contain special characters + if (SPECIAL_CHARS.test(name)) { + errors.push("name cannot contain special characters: ~ ' ! ( ) *"); + } + + // Check for URL-unsafe characters (scoped packages handled separately) + const nameWithoutScope = name.startsWith("@") + ? name.slice(name.indexOf("/") + 1) + : name; + + if (URL_UNSAFE.test(nameWithoutScope)) { + // More specific error for common issues + if (/\s/.test(nameWithoutScope)) { + errors.push("name cannot contain spaces"); + } else { + errors.push("name contains URL-unsafe characters"); + } + } + + // Scoped package validation + if (name.startsWith("@")) { + const slashIndex = name.indexOf("/"); + if (slashIndex === -1) { + errors.push("scoped package name must include a slash"); + } else { + const scope = name.slice(1, slashIndex); + const pkg = name.slice(slashIndex + 1); + + if (scope.length === 0) { + errors.push("scope cannot be empty"); + } + if (pkg.length === 0) { + errors.push("package name cannot be empty after scope"); + } + + // Validate scope characters + if (URL_UNSAFE.test(scope)) { + errors.push("scope contains URL-unsafe characters"); + } + } + } + + // Cannot be a Node.js core module + const baseName = name.startsWith("@") + ? name.slice(name.indexOf("/") + 1) + : name; + + if (NODE_BUILTINS.has(baseName)) { + errors.push(`"${baseName}" is a Node.js core module name`); + } + + // Cannot be blacklisted + if (BLACKLIST.has(baseName)) { + errors.push(`"${baseName}" is a reserved name`); + } + + // Warnings (not errors but discouraged) + if (/^node-/.test(baseName) || /^nodejs-/.test(baseName)) { + warnings.push("name starting with 'node-' or 'nodejs-' is discouraged"); + } + if (/-js$/.test(baseName) || /\.js$/.test(baseName)) { + warnings.push("name ending with '-js' or '.js' is discouraged"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Quick check if a package name is valid. + * + * @param name Package name to check + * @returns True if valid + * + * @example + * ```ts + * if (isValidPackageName(userInput)) { + * // Safe to use + * } + * ``` + */ +export function isValidPackageName(name: string): boolean { + return validatePackageName(name).valid; +} + +/** + * Check if a name is a Node.js built-in module. + * + * @param name Module name to check + * @returns True if it's a built-in + * + * @example + * ```ts + * isNodeBuiltin("fs") // true + * isNodeBuiltin("lodash") // false + * ``` + */ +export function isNodeBuiltin(name: string): boolean { + // Handle node: prefix + const baseName = name.startsWith("node:") ? name.slice(5) : name; + // Handle subpaths like "fs/promises" + const moduleName = baseName.split("/")[0]; + return NODE_BUILTINS.has(moduleName); +} + +/** + * Get a list of all Node.js built-in module names. + * + * @returns Array of built-in module names + */ +export function getNodeBuiltins(): string[] { + return Array.from(NODE_BUILTINS); +} + +// ============================================================================= +// Exports for testing +// ============================================================================= + +export { NODE_BUILTINS, BLACKLIST }; \ No newline at end of file -- 2.51.2