From 23b64fa5bf0368550cb7e64ecc71a83aecf3a312 Mon Sep 17 00:00:00 2001 From: Eser Ozvataf Date: Wed, 22 Oct 2025 04:42:41 +0300 Subject: [PATCH 1/4] feat: add JSR protocol support for package specifiers --- lib/npa.js | 65 +++++++++++++++++++ test/jsr.js | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 test/jsr.js diff --git a/lib/npa.js b/lib/npa.js index 50121b9..97c82e1 100644 --- a/lib/npa.js +++ b/lib/npa.js @@ -78,6 +78,13 @@ function isAliasSpec (spec) { return spec.toLowerCase().startsWith('npm:') } +function isJsrSpec (spec) { + if (!spec) { + return false + } + return spec.toLowerCase().startsWith('jsr:') +} + function resolve (name, spec, where, arg) { const res = new Result({ raw: arg, @@ -98,6 +105,8 @@ function resolve (name, spec, where, arg) { return fromFile(res, where) } else if (isAliasSpec(spec)) { return fromAlias(res, where) + } else if (isJsrSpec(spec)) { + return fromJsr(res) } const hosted = HostedGit.fromUrl(spec, { @@ -453,6 +462,62 @@ function fromAlias (res, where) { return res } +function fromJsr (res) { + // Remove 'jsr:' prefix + const jsrSpec = res.rawSpec.substr(4) + + // Parse the JSR specifier to extract name and version + // JSR format: @scope/name or @scope/name@version + const nameEnd = jsrSpec.indexOf('@', 1) // Skip the leading @ in @scope + const jsrName = nameEnd > 0 ? jsrSpec.slice(0, nameEnd) : jsrSpec + const versionSpec = nameEnd > 0 ? jsrSpec.slice(nameEnd + 1) : '' + + // Validate that JSR package is scoped + if (!jsrName.startsWith('@') || !jsrName.includes('/')) { + throw new Error(`JSR packages must be scoped (e.g., jsr:@scope/name): ${res.raw}`) + } + + // Validate the package name + const valid = validatePackageName(jsrName) + if (!valid.validForOldPackages) { + throw invalidPackageName(jsrName, valid, res.raw) + } + + // Transform @scope/name to @jsr/scope__name + // Extract scope and package name + const scopeEnd = jsrName.indexOf('/') + const scope = jsrName.slice(1, scopeEnd) // Remove leading @ from scope + const packageName = jsrName.slice(scopeEnd + 1) + const transformedName = `@jsr/${scope}__${packageName}` + + // Set the transformed name + res.setName(transformedName) + res.registry = true + + // Preserve the original JSR spec for saving + res.saveSpec = `jsr:${jsrName}${versionSpec ? '@' + versionSpec : ''}` + + // Determine the type based on version specifier + const spec = versionSpec || '*' + res.rawSpec = spec + res.fetchSpec = spec + + const version = semver.valid(spec, true) + const range = semver.validRange(spec, true) + if (version) { + res.type = 'version' + } else if (range) { + res.type = 'range' + } else { + if (encodeURIComponent(spec) !== spec) { + throw invalidTagName(spec, res.raw) + } + res.type = 'tag' + } + + return res +} + function fromRegistry (res) { res.registry = true const spec = res.rawSpec.trim() diff --git a/test/jsr.js b/test/jsr.js new file mode 100644 index 0000000..73700c4 --- /dev/null +++ b/test/jsr.js @@ -0,0 +1,183 @@ +const t = require('tap') +const npa = require('..') + +t.test('JSR specifiers', t => { + const tests = { + 'jsr:@std/testing': { + name: '@jsr/std__testing', + escapedName: '@jsr%2fstd__testing', + scope: '@jsr', + type: 'range', + registry: true, + saveSpec: 'jsr:@std/testing', + fetchSpec: '*', + raw: 'jsr:@std/testing', + rawSpec: '*', + }, + + 'jsr:@std/testing@1.0.0': { + name: '@jsr/std__testing', + escapedName: '@jsr%2fstd__testing', + scope: '@jsr', + type: 'version', + registry: true, + saveSpec: 'jsr:@std/testing@1.0.0', + fetchSpec: '1.0.0', + raw: 'jsr:@std/testing@1.0.0', + rawSpec: '1.0.0', + }, + + 'jsr:@std/testing@^1.0.0': { + name: '@jsr/std__testing', + escapedName: '@jsr%2fstd__testing', + scope: '@jsr', + type: 'range', + registry: true, + saveSpec: 'jsr:@std/testing@^1.0.0', + fetchSpec: '^1.0.0', + raw: 'jsr:@std/testing@^1.0.0', + rawSpec: '^1.0.0', + }, + + 'jsr:@std/testing@~1.2.3': { + name: '@jsr/std__testing', + escapedName: '@jsr%2fstd__testing', + scope: '@jsr', + type: 'range', + registry: true, + saveSpec: 'jsr:@std/testing@~1.2.3', + fetchSpec: '~1.2.3', + raw: 'jsr:@std/testing@~1.2.3', + rawSpec: '~1.2.3', + }, + + 'jsr:@std/testing@latest': { + name: '@jsr/std__testing', + escapedName: '@jsr%2fstd__testing', + scope: '@jsr', + type: 'tag', + registry: true, + saveSpec: 'jsr:@std/testing@latest', + fetchSpec: 'latest', + raw: 'jsr:@std/testing@latest', + rawSpec: 'latest', + }, + + 'jsr:@sxzz/tsdown': { + name: '@jsr/sxzz__tsdown', + escapedName: '@jsr%2fsxzz__tsdown', + scope: '@jsr', + type: 'range', + registry: true, + saveSpec: 'jsr:@sxzz/tsdown', + fetchSpec: '*', + raw: 'jsr:@sxzz/tsdown', + rawSpec: '*', + }, + + 'jsr:@sxzz/tsdown@2.0.0': { + name: '@jsr/sxzz__tsdown', + escapedName: '@jsr%2fsxzz__tsdown', + scope: '@jsr', + type: 'version', + registry: true, + saveSpec: 'jsr:@sxzz/tsdown@2.0.0', + fetchSpec: '2.0.0', + raw: 'jsr:@sxzz/tsdown@2.0.0', + rawSpec: '2.0.0', + }, + + 'jsr:@oak/oak@>=12.0.0 <13.0.0': { + name: '@jsr/oak__oak', + escapedName: '@jsr%2foak__oak', + scope: '@jsr', + type: 'range', + registry: true, + saveSpec: 'jsr:@oak/oak@>=12.0.0 <13.0.0', + fetchSpec: '>=12.0.0 <13.0.0', + raw: 'jsr:@oak/oak@>=12.0.0 <13.0.0', + rawSpec: '>=12.0.0 <13.0.0', + }, + } + + Object.keys(tests).forEach(arg => { + t.test(arg, t => { + const res = npa(arg) + t.ok(res instanceof npa.Result, `${arg} is a result`) + Object.keys(tests[arg]).forEach(key => { + t.match(res[key], tests[arg][key], `${arg} [${key}]`) + }) + t.end() + }) + }) + + t.end() +}) + +t.test('JSR validation errors', t => { + t.test('unscoped package name', t => { + t.throws( + () => npa('jsr:unscoped'), + /JSR packages must be scoped/, + 'throws error for unscoped JSR package' + ) + t.end() + }) + + t.test('scope only, no package name', t => { + t.throws( + () => npa('jsr:@scopeonly'), + /JSR packages must be scoped/, + 'throws error for scope without package name' + ) + t.end() + }) + + t.test('invalid package name characters', t => { + t.throws( + () => npa('jsr:@scope/in valid'), + /Invalid package name/, + 'throws error for invalid package name with spaces' + ) + t.end() + }) + + t.test('invalid tag name with special characters', t => { + t.throws( + () => npa('jsr:@std/testing@tag with spaces'), + /Invalid tag name/, + 'throws error for tag with invalid characters' + ) + t.end() + }) + + t.end() +}) + +t.test('JSR with Result.toString()', t => { + const res = npa('jsr:@std/testing@1.0.0') + t.equal( + res.toString(), + '@jsr/std__testing@jsr:@std/testing@1.0.0', + 'toString includes saveSpec' + ) + t.end() +}) + +t.test('JSR Result object passthrough', t => { + const res = npa('jsr:@std/testing') + const res2 = npa(res) + t.equal(res, res2, 'passing Result object returns same Result') + t.end() +}) + +t.test('JSR case insensitivity', t => { + const res1 = npa('jsr:@std/testing') + const res2 = npa('JSR:@std/testing') + const res3 = npa('JsR:@std/testing') + + t.equal(res1.name, '@jsr/std__testing', 'lowercase jsr: works') + t.equal(res2.name, '@jsr/std__testing', 'uppercase JSR: works') + t.equal(res3.name, '@jsr/std__testing', 'mixed case JsR: works') + t.end() +}) From 2cd09dd41558d53a190b2f40d0581b351eaf8532 Mon Sep 17 00:00:00 2001 From: Eser Ozvataf Date: Wed, 22 Oct 2025 14:03:28 +0300 Subject: [PATCH 2/4] chore: reusing existing package parser routines --- lib/npa.js | 54 +++++++++++++++++++---------------------------------- test/jsr.js | 4 ++-- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/lib/npa.js b/lib/npa.js index 97c82e1..76641b4 100644 --- a/lib/npa.js +++ b/lib/npa.js @@ -106,7 +106,7 @@ function resolve (name, spec, where, arg) { } else if (isAliasSpec(spec)) { return fromAlias(res, where) } else if (isJsrSpec(spec)) { - return fromJsr(res) + return fromJsr(res, where) } const hosted = HostedGit.fromUrl(spec, { @@ -462,58 +462,42 @@ function fromAlias (res, where) { return res } -function fromJsr (res) { +function fromJsr (res, where) { // Remove 'jsr:' prefix const jsrSpec = res.rawSpec.substr(4) // Parse the JSR specifier to extract name and version // JSR format: @scope/name or @scope/name@version - const nameEnd = jsrSpec.indexOf('@', 1) // Skip the leading @ in @scope - const jsrName = nameEnd > 0 ? jsrSpec.slice(0, nameEnd) : jsrSpec - const versionSpec = nameEnd > 0 ? jsrSpec.slice(nameEnd + 1) : '' + const versionIndex = jsrSpec.indexOf('@', 1) + const packagePart = versionIndex > 0 ? jsrSpec.slice(0, versionIndex) : jsrSpec // Validate that JSR package is scoped - if (!jsrName.startsWith('@') || !jsrName.includes('/')) { + if (!packagePart.startsWith('@') || !packagePart.includes('/')) { throw new Error(`JSR packages must be scoped (e.g., jsr:@scope/name): ${res.raw}`) } - // Validate the package name - const valid = validatePackageName(jsrName) - if (!valid.validForOldPackages) { - throw invalidPackageName(jsrName, valid, res.raw) + const subSpec = npa(jsrSpec, where) + + // Validate that it was parsed as a registry dependency + if (!subSpec.registry) { + throw new Error('JSR packages must be registry dependencies') } // Transform @scope/name to @jsr/scope__name // Extract scope and package name - const scopeEnd = jsrName.indexOf('/') - const scope = jsrName.slice(1, scopeEnd) // Remove leading @ from scope - const packageName = jsrName.slice(scopeEnd + 1) - const transformedName = `@jsr/${scope}__${packageName}` + const originalScope = subSpec.scope.slice(1) // Remove leading @ from scope + const packageName = subSpec.name.slice(subSpec.scope.length + 1) + const transformedName = `@jsr/${originalScope}__${packageName}` - // Set the transformed name + // Set the transformed name and copy properties from subSpec res.setName(transformedName) res.registry = true + res.type = subSpec.type + res.fetchSpec = subSpec.fetchSpec + res.rawSpec = subSpec.rawSpec - // Preserve the original JSR spec for saving - res.saveSpec = `jsr:${jsrName}${versionSpec ? '@' + versionSpec : ''}` - - // Determine the type based on version specifier - const spec = versionSpec || '*' - res.rawSpec = spec - res.fetchSpec = spec - - const version = semver.valid(spec, true) - const range = semver.validRange(spec, true) - if (version) { - res.type = 'version' - } else if (range) { - res.type = 'range' - } else { - if (encodeURIComponent(spec) !== spec) { - throw invalidTagName(spec, res.raw) - } - res.type = 'tag' - } + // Preserve original JSR spec for saving + res.saveSpec = res.raw return res } diff --git a/test/jsr.js b/test/jsr.js index 73700c4..b8e128d 100644 --- a/test/jsr.js +++ b/test/jsr.js @@ -136,8 +136,8 @@ t.test('JSR validation errors', t => { t.test('invalid package name characters', t => { t.throws( () => npa('jsr:@scope/in valid'), - /Invalid package name/, - 'throws error for invalid package name with spaces' + /JSR packages must be registry dependencies/, + 'throws error when package is parsed as non-registry (e.g., directory)' ) t.end() }) From 51a9ce59a53294c4002760f745cccc31db9b5975 Mon Sep 17 00:00:00 2001 From: Eser Ozvataf Date: Sat, 1 Nov 2025 12:51:49 +0300 Subject: [PATCH 3/4] fix: enforce 'jsr:' prefix for import specifiers Also replaces String.prototype.substr with #slice as the API is considered legacy. --- lib/npa.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/npa.js b/lib/npa.js index 76641b4..316c1b0 100644 --- a/lib/npa.js +++ b/lib/npa.js @@ -82,7 +82,7 @@ function isJsrSpec (spec) { if (!spec) { return false } - return spec.toLowerCase().startsWith('jsr:') + return spec.startsWith('jsr:') } function resolve (name, spec, where, arg) { @@ -464,7 +464,7 @@ function fromAlias (res, where) { function fromJsr (res, where) { // Remove 'jsr:' prefix - const jsrSpec = res.rawSpec.substr(4) + const jsrSpec = res.rawSpec.slice(4) // Parse the JSR specifier to extract name and version // JSR format: @scope/name or @scope/name@version From b411c56e7383821b3c1e91f906ecf928b57d16aa Mon Sep 17 00:00:00 2001 From: Eser Ozvataf Date: Thu, 13 Nov 2025 20:31:45 +0300 Subject: [PATCH 4/4] test: updated tests to cover JSR case sensitivity criteria. --- test/jsr.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/test/jsr.js b/test/jsr.js index b8e128d..c59f0d1 100644 --- a/test/jsr.js +++ b/test/jsr.js @@ -171,13 +171,22 @@ t.test('JSR Result object passthrough', t => { t.end() }) -t.test('JSR case insensitivity', t => { - const res1 = npa('jsr:@std/testing') - const res2 = npa('JSR:@std/testing') - const res3 = npa('JsR:@std/testing') - - t.equal(res1.name, '@jsr/std__testing', 'lowercase jsr: works') - t.equal(res2.name, '@jsr/std__testing', 'uppercase JSR: works') - t.equal(res3.name, '@jsr/std__testing', 'mixed case JsR: works') +t.test('JSR case sensitivity', t => { + const res = npa('jsr:@std/testing') + + t.equal(res.name, '@jsr/std__testing', 'lowercase jsr: works') + + t.throws( + () => npa('JSR:@std/testing'), + /Unsupported URL Type/, + 'throws error when package is parsed as unsupported URL type' + ) + + t.throws( + () => npa('JsR:@std/testing'), + /Unsupported URL Type/, + 'throws error when package is parsed as unsupported URL type' + ) + t.end() })