Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions lib/npa.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ function isAliasSpec (spec) {
return spec.toLowerCase().startsWith('npm:')
}

function isJsrSpec (spec) {
if (!spec) {
return false
}
return spec.toLowerCase().startsWith('jsr:')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it intentional that JsR and JSR and jSR etc are all supported? It seems better to only allow the lowercase form.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was probably copied from the alias code, and yes it is best to just not support it at the outset. That is, provided it is not supported already in jsr et al.

}

function resolve (name, spec, where, arg) {
const res = new Result({
raw: arg,
Expand All @@ -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, where)
}

const hosted = HostedGit.fromUrl(spec, {
Expand Down Expand Up @@ -453,6 +462,46 @@ function fromAlias (res, where) {
return res
}

function fromJsr (res, where) {
// Remove 'jsr:' prefix
const jsrSpec = res.rawSpec.substr(4)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const jsrSpec = res.rawSpec.substr(4)
const jsrSpec = res.rawSpec.slice(4)

substr is legacy


// Parse the JSR specifier to extract name and version
// JSR format: @scope/name or @scope/name@version
const versionIndex = jsrSpec.indexOf('@', 1)
const packagePart = versionIndex > 0 ? jsrSpec.slice(0, versionIndex) : jsrSpec

// Validate that JSR package is scoped
if (!packagePart.startsWith('@') || !packagePart.includes('/')) {
throw new Error(`JSR packages must be scoped (e.g., jsr:@scope/name): ${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 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 and copy properties from subSpec
res.setName(transformedName)
res.registry = true
res.type = subSpec.type
res.fetchSpec = subSpec.fetchSpec
res.rawSpec = subSpec.rawSpec

// Preserve original JSR spec for saving
res.saveSpec = res.raw

return res
}

function fromRegistry (res) {
res.registry = true
const spec = res.rawSpec.trim()
Expand Down
183 changes: 183 additions & 0 deletions test/jsr.js
Original file line number Diff line number Diff line change
@@ -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'),
/JSR packages must be registry dependencies/,
'throws error when package is parsed as non-registry (e.g., directory)'
)
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()
})