Skip to content

Commit 0086606

Browse files
authored
feat: add jsr badge when available on jsr (+ add to pm) (#22)
1 parent 53a7581 commit 0086606

12 files changed

Lines changed: 573 additions & 42 deletions

File tree

app/components/JsrBadge.vue

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
/** JSR package URL (e.g., "https://jsr.io/@std/fs") */
4+
url: string
5+
/** Whether to show as compact (icon only) or full (with text) */
6+
compact?: boolean
7+
}>()
8+
</script>
9+
10+
<template>
11+
<a
12+
:href="url"
13+
target="_blank"
14+
rel="noopener noreferrer"
15+
class="inline-flex items-center gap-1 text-xs font-mono text-fg-muted hover:text-fg transition-colors duration-200"
16+
title="also available on JSR"
17+
>
18+
<span
19+
class="i-simple-icons-jsr shrink-0"
20+
:class="compact ? 'w-3.5 h-3.5' : 'w-4 h-4'"
21+
aria-hidden="true"
22+
/>
23+
<span v-if="!compact" class="sr-only sm:not-sr-only">jsr</span>
24+
</a>
25+
</template>

app/pages/[...package].vue

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import { joinURL } from 'ufo'
33
import type { PackumentVersion, NpmVersionDist } from '#shared/types'
4+
import type { JsrPackageInfo } from '#shared/types/jsr'
45
56
definePageMeta({
67
name: 'package',
@@ -59,6 +60,13 @@ const { data: readmeData } = useLazyFetch<{ html: string }>(
5960
{ default: () => ({ html: '' }) },
6061
)
6162
63+
// Check if package exists on JSR (only for scoped packages)
64+
const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, {
65+
default: () => ({ exists: false }),
66+
// Only fetch for scoped packages (JSR requirement)
67+
immediate: computed(() => packageName.value.startsWith('@')).value,
68+
})
69+
6270
// Get the version to display (requested or latest)
6371
const displayVersion = computed(() => {
6472
if (!pkg.value) return null
@@ -143,18 +151,7 @@ function hasProvenance(version: PackumentVersion | null): boolean {
143151
return !!dist.attestations
144152
}
145153
146-
// Package manager install commands
147-
const packageManagers = [
148-
{ id: 'npm', label: 'npm', action: 'install' },
149-
{ id: 'pnpm', label: 'pnpm', action: 'add' },
150-
{ id: 'yarn', label: 'yarn', action: 'add' },
151-
{ id: 'bun', label: 'bun', action: 'add' },
152-
{ id: 'deno', label: 'deno', action: 'add npm:' },
153-
] as const
154-
155-
type PackageManagerId = (typeof packageManagers)[number]['id']
156-
157-
// Persist preference in localStorage
154+
// Persist package manager preference in localStorage
158155
const selectedPM = ref<PackageManagerId>('npm')
159156
160157
onMounted(() => {
@@ -168,24 +165,24 @@ watch(selectedPM, value => {
168165
localStorage.setItem('npmx-pm', value)
169166
})
170167
171-
const currentPM = computed(
172-
() => packageManagers.find(p => p.id === selectedPM.value) || packageManagers[0],
173-
)
174-
const selectedPMLabel = computed(() => currentPM.value.label)
175-
const selectedPMAction = computed(() => currentPM.value.action)
168+
const installCommandParts = computed(() => {
169+
if (!pkg.value) return []
170+
return getInstallCommandParts({
171+
packageName: pkg.value.name,
172+
packageManager: selectedPM.value,
173+
version: requestedVersion.value,
174+
jsrInfo: jsrInfo.value,
175+
})
176+
})
176177
177178
const installCommand = computed(() => {
178179
if (!pkg.value) return ''
179-
const pm = currentPM.value
180-
let command = `${pm.label} ${pm.action} ${pkg.value.name}`
181-
// deno uses "add npm:package" format
182-
if (pm.id === 'deno') {
183-
command = `${pm.label} ${pm.action}${pkg.value.name}`
184-
}
185-
if (requestedVersion.value) {
186-
command += `@${requestedVersion.value}`
187-
}
188-
return command
180+
return getInstallCommand({
181+
packageName: pkg.value.name,
182+
packageManager: selectedPM.value,
183+
version: requestedVersion.value,
184+
jsrInfo: jsrInfo.value,
185+
})
189186
})
190187
191188
// Copy install command
@@ -415,6 +412,18 @@ defineOgImageComponent('Package', {
415412
npm
416413
</a>
417414
</li>
415+
<li v-if="jsrInfo?.exists && jsrInfo.url">
416+
<a
417+
:href="jsrInfo.url"
418+
target="_blank"
419+
rel="noopener noreferrer"
420+
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
421+
title="Also available on JSR"
422+
>
423+
<span class="i-simple-icons-jsr w-4 h-4" />
424+
jsr
425+
</a>
426+
</li>
418427
<li>
419428
<a
420429
:href="`https://socket.dev/npm/package/${pkg.name}/overview/${displayVersion?.version ?? 'latest'}`"
@@ -517,16 +526,14 @@ defineOgImageComponent('Package', {
517526
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
518527
<code class="font-mono text-sm"
519528
><ClientOnly
520-
><span class="text-fg">{{ selectedPMLabel }}</span
521-
>&nbsp;<span class="text-fg-muted">{{ selectedPMAction }}</span
522-
><span v-if="selectedPM !== 'deno'" class="text-fg-muted"
523-
>&nbsp;{{ pkg.name }}</span
524-
><span v-else class="text-fg-muted">{{ pkg.name }}</span
525-
><span v-if="requestedVersion" class="text-fg-muted">@{{ requestedVersion }}</span
529+
><span
530+
v-for="(part, i) in installCommandParts"
531+
:key="i"
532+
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
533+
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
526534
><template #fallback
527-
><span class="text-fg">npm</span>&nbsp;<span class="text-fg-muted"
528-
>install&nbsp;{{ pkg.name }}</span
529-
></template
535+
><span class="text-fg">npm</span
536+
><span class="text-fg-muted"> install {{ pkg.name }}</span></template
530537
></ClientOnly
531538
></code
532539
>

app/utils/install-command.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { JsrPackageInfo } from '#shared/types/jsr'
2+
3+
export const packageManagers = [
4+
{ id: 'npm', label: 'npm', action: 'install' },
5+
{ id: 'pnpm', label: 'pnpm', action: 'add' },
6+
{ id: 'yarn', label: 'yarn', action: 'add' },
7+
{ id: 'bun', label: 'bun', action: 'add' },
8+
{ id: 'deno', label: 'deno', action: 'add' },
9+
{ id: 'jsr', label: 'jsr', action: 'add' },
10+
] as const
11+
12+
export type PackageManagerId = (typeof packageManagers)[number]['id']
13+
14+
export interface InstallCommandOptions {
15+
packageName: string
16+
packageManager: PackageManagerId
17+
version?: string | null
18+
jsrInfo?: JsrPackageInfo | null
19+
}
20+
21+
/**
22+
* Get the package specifier for a given package manager.
23+
* Handles npm: prefix for deno and jsr (when not native).
24+
*/
25+
export function getPackageSpecifier(options: InstallCommandOptions): string {
26+
const { packageName, packageManager, jsrInfo } = options
27+
28+
if (packageManager === 'deno') {
29+
// deno add npm:package
30+
return `npm:${packageName}`
31+
}
32+
33+
if (packageManager === 'jsr') {
34+
if (jsrInfo?.exists && jsrInfo.scope && jsrInfo.name) {
35+
// Native JSR package: @scope/name
36+
return `@${jsrInfo.scope}/${jsrInfo.name}`
37+
}
38+
// npm compatibility: npm:package
39+
return `npm:${packageName}`
40+
}
41+
42+
// Standard package managers (npm, pnpm, yarn, bun)
43+
return packageName
44+
}
45+
46+
/**
47+
* Generate the full install command for a package.
48+
*/
49+
export function getInstallCommand(options: InstallCommandOptions): string {
50+
return getInstallCommandParts(options).join(' ')
51+
}
52+
53+
/**
54+
* Generate install command as an array of parts.
55+
* First element is the command (e.g., "npm"), rest are arguments.
56+
* Useful for rendering with different styling for command vs args.
57+
*/
58+
export function getInstallCommandParts(options: InstallCommandOptions): string[] {
59+
const pm = packageManagers.find(p => p.id === options.packageManager)
60+
if (!pm) return []
61+
62+
const spec = getPackageSpecifier(options)
63+
const version = options.version ? `@${options.version}` : ''
64+
65+
return [pm.label, pm.action, `${spec}${version}`]
66+
}

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"citty": "^0.2.0",
2727
"consola": "^3.4.2",
2828
"defu": "^6.1.4",
29-
"h3": "^2.0.1-rc.11",
29+
"h3-next": "npm:h3@^2.0.1-rc.11",
3030
"ofetch": "^1.5.1",
3131
"picocolors": "^1.1.1",
3232
"srvx": "^0.10.1",

cli/src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import crypto from 'node:crypto'
2-
import { H3, HTTPError, handleCors, type H3Event } from 'h3'
3-
import type { CorsOptions } from 'h3'
2+
import { H3, HTTPError, handleCors, type H3Event } from 'h3-next'
3+
import type { CorsOptions } from 'h3-next'
44

55
import type { ConnectorState, PendingOperation, OperationType, ApiResponse } from './types.ts'
66
import {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test:unit": "vitest --project unit"
2727
},
2828
"dependencies": {
29+
"@iconify-json/simple-icons": "^1.2.67",
2930
"@iconify-json/vscode-icons": "^1.2.40",
3031
"@nuxt/fonts": "^0.13.0",
3132
"@nuxt/scripts": "^0.13.2",

pnpm-lock.yaml

Lines changed: 13 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/api/jsr/[...pkg].get.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { JsrPackageInfo } from '#shared/types/jsr'
2+
3+
/**
4+
* Check if an npm package exists on JSR.
5+
*
6+
* GET /api/jsr/:pkg
7+
*
8+
* @example GET /api/jsr/@std/fs → { exists: true, scope: "std", name: "fs", ... }
9+
* @example GET /api/jsr/lodash → { exists: false }
10+
*/
11+
export default defineCachedEventHandler<Promise<JsrPackageInfo>>(
12+
async event => {
13+
const pkgPath = getRouterParam(event, 'pkg')
14+
if (!pkgPath) {
15+
throw createError({ statusCode: 400, message: 'Package name is required' })
16+
}
17+
18+
return await fetchJsrPackageInfo(pkgPath)
19+
},
20+
{
21+
maxAge: 60 * 60, // 1 hour
22+
name: 'api-jsr-package',
23+
getKey: event => getRouterParam(event, 'pkg') ?? '',
24+
},
25+
)

server/utils/jsr.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { JsrPackageMeta, JsrPackageInfo } from '#shared/types/jsr'
2+
3+
const JSR_REGISTRY = 'https://jsr.io'
4+
5+
/**
6+
* Check if a scoped npm package exists on JSR with the same name.
7+
*
8+
* This only works for scoped packages (@scope/name) since:
9+
* 1. JSR only has scoped packages
10+
* 2. We can only authoritatively match when names are identical
11+
*
12+
* Unscoped npm packages (e.g., "hono") may exist on JSR under a different
13+
* name (e.g., "@hono/hono"), but we don't attempt to guess these mappings.
14+
*
15+
* @param npmPackageName - The npm package name (e.g., "@hono/hono")
16+
* @returns JsrPackageInfo with existence status and metadata
17+
*/
18+
export const fetchJsrPackageInfo = defineCachedFunction(
19+
async (npmPackageName: string): Promise<JsrPackageInfo> => {
20+
// Only check scoped packages - we can't authoritatively map unscoped names
21+
if (!npmPackageName.startsWith('@')) {
22+
return { exists: false }
23+
}
24+
25+
// Parse scope and name from @scope/name format
26+
const match = npmPackageName.match(/^@([^/]+)\/(.+)$/)
27+
if (!match) {
28+
return { exists: false }
29+
}
30+
31+
const [, scope, name] = match
32+
33+
try {
34+
// Fetch JSR package metadata
35+
const meta = await $fetch<JsrPackageMeta>(`${JSR_REGISTRY}/@${scope}/${name}/meta.json`, {
36+
// Short timeout since this is a nice-to-have feature
37+
timeout: 3000,
38+
})
39+
40+
// Find latest non-yanked version
41+
const versions = Object.entries(meta.versions)
42+
.filter(([, v]) => !v.yanked)
43+
.map(([version]) => version)
44+
45+
versions.sort()
46+
const latestVersion = versions[versions.length - 1]
47+
48+
return {
49+
exists: true,
50+
scope: meta.scope,
51+
name: meta.name,
52+
url: `${JSR_REGISTRY}/@${meta.scope}/${meta.name}`,
53+
latestVersion,
54+
}
55+
} catch {
56+
// Package doesn't exist on JSR or API error
57+
return { exists: false }
58+
}
59+
},
60+
{
61+
// Cache for 1 hour - JSR info doesn't change often
62+
maxAge: 60 * 60,
63+
name: 'jsr-package-info',
64+
getKey: (name: string) => name,
65+
},
66+
)

shared/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './npm-registry'
2+
export * from './jsr'

0 commit comments

Comments
 (0)