Skip to content

Commit 23b64fa

Browse files
committed
feat: add JSR protocol support for package specifiers
1 parent 5b5edc1 commit 23b64fa

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed

lib/npa.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ function isAliasSpec (spec) {
7878
return spec.toLowerCase().startsWith('npm:')
7979
}
8080

81+
function isJsrSpec (spec) {
82+
if (!spec) {
83+
return false
84+
}
85+
return spec.toLowerCase().startsWith('jsr:')
86+
}
87+
8188
function resolve (name, spec, where, arg) {
8289
const res = new Result({
8390
raw: arg,
@@ -98,6 +105,8 @@ function resolve (name, spec, where, arg) {
98105
return fromFile(res, where)
99106
} else if (isAliasSpec(spec)) {
100107
return fromAlias(res, where)
108+
} else if (isJsrSpec(spec)) {
109+
return fromJsr(res)
101110
}
102111

103112
const hosted = HostedGit.fromUrl(spec, {
@@ -453,6 +462,62 @@ function fromAlias (res, where) {
453462
return res
454463
}
455464

465+
function fromJsr (res) {
466+
// Remove 'jsr:' prefix
467+
const jsrSpec = res.rawSpec.substr(4)
468+
469+
// Parse the JSR specifier to extract name and version
470+
// JSR format: @scope/name or @scope/name@version
471+
const nameEnd = jsrSpec.indexOf('@', 1) // Skip the leading @ in @scope
472+
const jsrName = nameEnd > 0 ? jsrSpec.slice(0, nameEnd) : jsrSpec
473+
const versionSpec = nameEnd > 0 ? jsrSpec.slice(nameEnd + 1) : ''
474+
475+
// Validate that JSR package is scoped
476+
if (!jsrName.startsWith('@') || !jsrName.includes('/')) {
477+
throw new Error(`JSR packages must be scoped (e.g., jsr:@scope/name): ${res.raw}`)
478+
}
479+
480+
// Validate the package name
481+
const valid = validatePackageName(jsrName)
482+
if (!valid.validForOldPackages) {
483+
throw invalidPackageName(jsrName, valid, res.raw)
484+
}
485+
486+
// Transform @scope/name to @jsr/scope__name
487+
// Extract scope and package name
488+
const scopeEnd = jsrName.indexOf('/')
489+
const scope = jsrName.slice(1, scopeEnd) // Remove leading @ from scope
490+
const packageName = jsrName.slice(scopeEnd + 1)
491+
const transformedName = `@jsr/${scope}__${packageName}`
492+
493+
// Set the transformed name
494+
res.setName(transformedName)
495+
res.registry = true
496+
497+
// Preserve the original JSR spec for saving
498+
res.saveSpec = `jsr:${jsrName}${versionSpec ? '@' + versionSpec : ''}`
499+
500+
// Determine the type based on version specifier
501+
const spec = versionSpec || '*'
502+
res.rawSpec = spec
503+
res.fetchSpec = spec
504+
505+
const version = semver.valid(spec, true)
506+
const range = semver.validRange(spec, true)
507+
if (version) {
508+
res.type = 'version'
509+
} else if (range) {
510+
res.type = 'range'
511+
} else {
512+
if (encodeURIComponent(spec) !== spec) {
513+
throw invalidTagName(spec, res.raw)
514+
}
515+
res.type = 'tag'
516+
}
517+
518+
return res
519+
}
520+
456521
function fromRegistry (res) {
457522
res.registry = true
458523
const spec = res.rawSpec.trim()

test/jsr.js

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
const t = require('tap')
2+
const npa = require('..')
3+
4+
t.test('JSR specifiers', t => {
5+
const tests = {
6+
'jsr:@std/testing': {
7+
name: '@jsr/std__testing',
8+
escapedName: '@jsr%2fstd__testing',
9+
scope: '@jsr',
10+
type: 'range',
11+
registry: true,
12+
saveSpec: 'jsr:@std/testing',
13+
fetchSpec: '*',
14+
raw: 'jsr:@std/testing',
15+
rawSpec: '*',
16+
},
17+
18+
'jsr:@std/[email protected]': {
19+
name: '@jsr/std__testing',
20+
escapedName: '@jsr%2fstd__testing',
21+
scope: '@jsr',
22+
type: 'version',
23+
registry: true,
24+
saveSpec: 'jsr:@std/[email protected]',
25+
fetchSpec: '1.0.0',
26+
raw: 'jsr:@std/[email protected]',
27+
rawSpec: '1.0.0',
28+
},
29+
30+
'jsr:@std/testing@^1.0.0': {
31+
name: '@jsr/std__testing',
32+
escapedName: '@jsr%2fstd__testing',
33+
scope: '@jsr',
34+
type: 'range',
35+
registry: true,
36+
saveSpec: 'jsr:@std/testing@^1.0.0',
37+
fetchSpec: '^1.0.0',
38+
raw: 'jsr:@std/testing@^1.0.0',
39+
rawSpec: '^1.0.0',
40+
},
41+
42+
'jsr:@std/testing@~1.2.3': {
43+
name: '@jsr/std__testing',
44+
escapedName: '@jsr%2fstd__testing',
45+
scope: '@jsr',
46+
type: 'range',
47+
registry: true,
48+
saveSpec: 'jsr:@std/testing@~1.2.3',
49+
fetchSpec: '~1.2.3',
50+
raw: 'jsr:@std/testing@~1.2.3',
51+
rawSpec: '~1.2.3',
52+
},
53+
54+
'jsr:@std/testing@latest': {
55+
name: '@jsr/std__testing',
56+
escapedName: '@jsr%2fstd__testing',
57+
scope: '@jsr',
58+
type: 'tag',
59+
registry: true,
60+
saveSpec: 'jsr:@std/testing@latest',
61+
fetchSpec: 'latest',
62+
raw: 'jsr:@std/testing@latest',
63+
rawSpec: 'latest',
64+
},
65+
66+
'jsr:@sxzz/tsdown': {
67+
name: '@jsr/sxzz__tsdown',
68+
escapedName: '@jsr%2fsxzz__tsdown',
69+
scope: '@jsr',
70+
type: 'range',
71+
registry: true,
72+
saveSpec: 'jsr:@sxzz/tsdown',
73+
fetchSpec: '*',
74+
raw: 'jsr:@sxzz/tsdown',
75+
rawSpec: '*',
76+
},
77+
78+
'jsr:@sxzz/[email protected]': {
79+
name: '@jsr/sxzz__tsdown',
80+
escapedName: '@jsr%2fsxzz__tsdown',
81+
scope: '@jsr',
82+
type: 'version',
83+
registry: true,
84+
saveSpec: 'jsr:@sxzz/[email protected]',
85+
fetchSpec: '2.0.0',
86+
raw: 'jsr:@sxzz/[email protected]',
87+
rawSpec: '2.0.0',
88+
},
89+
90+
'jsr:@oak/oak@>=12.0.0 <13.0.0': {
91+
name: '@jsr/oak__oak',
92+
escapedName: '@jsr%2foak__oak',
93+
scope: '@jsr',
94+
type: 'range',
95+
registry: true,
96+
saveSpec: 'jsr:@oak/oak@>=12.0.0 <13.0.0',
97+
fetchSpec: '>=12.0.0 <13.0.0',
98+
raw: 'jsr:@oak/oak@>=12.0.0 <13.0.0',
99+
rawSpec: '>=12.0.0 <13.0.0',
100+
},
101+
}
102+
103+
Object.keys(tests).forEach(arg => {
104+
t.test(arg, t => {
105+
const res = npa(arg)
106+
t.ok(res instanceof npa.Result, `${arg} is a result`)
107+
Object.keys(tests[arg]).forEach(key => {
108+
t.match(res[key], tests[arg][key], `${arg} [${key}]`)
109+
})
110+
t.end()
111+
})
112+
})
113+
114+
t.end()
115+
})
116+
117+
t.test('JSR validation errors', t => {
118+
t.test('unscoped package name', t => {
119+
t.throws(
120+
() => npa('jsr:unscoped'),
121+
/JSR packages must be scoped/,
122+
'throws error for unscoped JSR package'
123+
)
124+
t.end()
125+
})
126+
127+
t.test('scope only, no package name', t => {
128+
t.throws(
129+
() => npa('jsr:@scopeonly'),
130+
/JSR packages must be scoped/,
131+
'throws error for scope without package name'
132+
)
133+
t.end()
134+
})
135+
136+
t.test('invalid package name characters', t => {
137+
t.throws(
138+
() => npa('jsr:@scope/in valid'),
139+
/Invalid package name/,
140+
'throws error for invalid package name with spaces'
141+
)
142+
t.end()
143+
})
144+
145+
t.test('invalid tag name with special characters', t => {
146+
t.throws(
147+
() => npa('jsr:@std/testing@tag with spaces'),
148+
/Invalid tag name/,
149+
'throws error for tag with invalid characters'
150+
)
151+
t.end()
152+
})
153+
154+
t.end()
155+
})
156+
157+
t.test('JSR with Result.toString()', t => {
158+
const res = npa('jsr:@std/[email protected]')
159+
t.equal(
160+
res.toString(),
161+
'@jsr/std__testing@jsr:@std/[email protected]',
162+
'toString includes saveSpec'
163+
)
164+
t.end()
165+
})
166+
167+
t.test('JSR Result object passthrough', t => {
168+
const res = npa('jsr:@std/testing')
169+
const res2 = npa(res)
170+
t.equal(res, res2, 'passing Result object returns same Result')
171+
t.end()
172+
})
173+
174+
t.test('JSR case insensitivity', t => {
175+
const res1 = npa('jsr:@std/testing')
176+
const res2 = npa('JSR:@std/testing')
177+
const res3 = npa('JsR:@std/testing')
178+
179+
t.equal(res1.name, '@jsr/std__testing', 'lowercase jsr: works')
180+
t.equal(res2.name, '@jsr/std__testing', 'uppercase JSR: works')
181+
t.equal(res3.name, '@jsr/std__testing', 'mixed case JsR: works')
182+
t.end()
183+
})

0 commit comments

Comments
 (0)