Skip to content
Merged
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
25 changes: 25 additions & 0 deletions app/components/JsrBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
/** JSR package URL (e.g., "https://jsr.io/@std/fs") */
url: string
/** Whether to show as compact (icon only) or full (with text) */
compact?: boolean
}>()
</script>

<template>
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs font-mono text-fg-muted hover:text-fg transition-colors duration-200"
title="also available on JSR"
>
<span
class="i-simple-icons-jsr shrink-0"
:class="compact ? 'w-3.5 h-3.5' : 'w-4 h-4'"
aria-hidden="true"
/>
<span v-if="!compact" class="sr-only sm:not-sr-only">jsr</span>
</a>
</template>
79 changes: 43 additions & 36 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { joinURL } from 'ufo'
import type { PackumentVersion, NpmVersionDist } from '#shared/types'
import type { JsrPackageInfo } from '#shared/types/jsr'

definePageMeta({
name: 'package',
Expand Down Expand Up @@ -59,6 +60,13 @@ const { data: readmeData } = useLazyFetch<{ html: string }>(
{ default: () => ({ html: '' }) },
)

// Check if package exists on JSR (only for scoped packages)
const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, {
default: () => ({ exists: false }),
// Only fetch for scoped packages (JSR requirement)
immediate: computed(() => packageName.value.startsWith('@')).value,
})

// Get the version to display (requested or latest)
const displayVersion = computed(() => {
if (!pkg.value) return null
Expand Down Expand Up @@ -143,18 +151,7 @@ function hasProvenance(version: PackumentVersion | null): boolean {
return !!dist.attestations
}

// Package manager install commands
const packageManagers = [
{ id: 'npm', label: 'npm', action: 'install' },
{ id: 'pnpm', label: 'pnpm', action: 'add' },
{ id: 'yarn', label: 'yarn', action: 'add' },
{ id: 'bun', label: 'bun', action: 'add' },
{ id: 'deno', label: 'deno', action: 'add npm:' },
] as const

type PackageManagerId = (typeof packageManagers)[number]['id']

// Persist preference in localStorage
// Persist package manager preference in localStorage
const selectedPM = ref<PackageManagerId>('npm')

onMounted(() => {
Expand All @@ -168,24 +165,24 @@ watch(selectedPM, value => {
localStorage.setItem('npmx-pm', value)
})

const currentPM = computed(
() => packageManagers.find(p => p.id === selectedPM.value) || packageManagers[0],
)
const selectedPMLabel = computed(() => currentPM.value.label)
const selectedPMAction = computed(() => currentPM.value.action)
const installCommandParts = computed(() => {
if (!pkg.value) return []
return getInstallCommandParts({
packageName: pkg.value.name,
packageManager: selectedPM.value,
version: requestedVersion.value,
jsrInfo: jsrInfo.value,
})
})

const installCommand = computed(() => {
if (!pkg.value) return ''
const pm = currentPM.value
let command = `${pm.label} ${pm.action} ${pkg.value.name}`
// deno uses "add npm:package" format
if (pm.id === 'deno') {
command = `${pm.label} ${pm.action}${pkg.value.name}`
}
if (requestedVersion.value) {
command += `@${requestedVersion.value}`
}
return command
return getInstallCommand({
packageName: pkg.value.name,
packageManager: selectedPM.value,
version: requestedVersion.value,
jsrInfo: jsrInfo.value,
})
})

// Copy install command
Expand Down Expand Up @@ -415,6 +412,18 @@ defineOgImageComponent('Package', {
npm
</a>
</li>
<li v-if="jsrInfo?.exists && jsrInfo.url">
<a
:href="jsrInfo.url"
target="_blank"
rel="noopener noreferrer"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
title="Also available on JSR"
>
<span class="i-simple-icons-jsr w-4 h-4" />
jsr
</a>
</li>
<li>
<a
:href="`https://socket.dev/npm/package/${pkg.name}/overview/${displayVersion?.version ?? 'latest'}`"
Expand Down Expand Up @@ -517,16 +526,14 @@ defineOgImageComponent('Package', {
<span class="text-fg-subtle font-mono text-sm select-none">$</span>
<code class="font-mono text-sm"
><ClientOnly
><span class="text-fg">{{ selectedPMLabel }}</span
>&nbsp;<span class="text-fg-muted">{{ selectedPMAction }}</span
><span v-if="selectedPM !== 'deno'" class="text-fg-muted"
>&nbsp;{{ pkg.name }}</span
><span v-else class="text-fg-muted">{{ pkg.name }}</span
><span v-if="requestedVersion" class="text-fg-muted">@{{ requestedVersion }}</span
><span
v-for="(part, i) in installCommandParts"
:key="i"
:class="i === 0 ? 'text-fg' : 'text-fg-muted'"
>{{ i > 0 ? ' ' : '' }}{{ part }}</span
><template #fallback
><span class="text-fg">npm</span>&nbsp;<span class="text-fg-muted"
>install&nbsp;{{ pkg.name }}</span
></template
><span class="text-fg">npm</span
><span class="text-fg-muted"> install {{ pkg.name }}</span></template
></ClientOnly
></code
>
Expand Down
66 changes: 66 additions & 0 deletions app/utils/install-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { JsrPackageInfo } from '#shared/types/jsr'

export const packageManagers = [
{ id: 'npm', label: 'npm', action: 'install' },
{ id: 'pnpm', label: 'pnpm', action: 'add' },
{ id: 'yarn', label: 'yarn', action: 'add' },
{ id: 'bun', label: 'bun', action: 'add' },
{ id: 'deno', label: 'deno', action: 'add' },
{ id: 'jsr', label: 'jsr', action: 'add' },
] as const

export type PackageManagerId = (typeof packageManagers)[number]['id']

export interface InstallCommandOptions {
packageName: string
packageManager: PackageManagerId
version?: string | null
jsrInfo?: JsrPackageInfo | null
}

/**
* Get the package specifier for a given package manager.
* Handles npm: prefix for deno and jsr (when not native).
*/
export function getPackageSpecifier(options: InstallCommandOptions): string {
const { packageName, packageManager, jsrInfo } = options

if (packageManager === 'deno') {
// deno add npm:package
return `npm:${packageName}`
}

if (packageManager === 'jsr') {
if (jsrInfo?.exists && jsrInfo.scope && jsrInfo.name) {
// Native JSR package: @scope/name
return `@${jsrInfo.scope}/${jsrInfo.name}`
}
// npm compatibility: npm:package
return `npm:${packageName}`
}

// Standard package managers (npm, pnpm, yarn, bun)
return packageName
}

/**
* Generate the full install command for a package.
*/
export function getInstallCommand(options: InstallCommandOptions): string {
return getInstallCommandParts(options).join(' ')
}

/**
* Generate install command as an array of parts.
* First element is the command (e.g., "npm"), rest are arguments.
* Useful for rendering with different styling for command vs args.
*/
export function getInstallCommandParts(options: InstallCommandOptions): string[] {
const pm = packageManagers.find(p => p.id === options.packageManager)
if (!pm) return []

const spec = getPackageSpecifier(options)
const version = options.version ? `@${options.version}` : ''

return [pm.label, pm.action, `${spec}${version}`]
}
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"citty": "^0.2.0",
"consola": "^3.4.2",
"defu": "^6.1.4",
"h3": "^2.0.1-rc.11",
"h3-next": "npm:h3@^2.0.1-rc.11",
"ofetch": "^1.5.1",
"picocolors": "^1.1.1",
"srvx": "^0.10.1",
Expand Down
4 changes: 2 additions & 2 deletions cli/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import crypto from 'node:crypto'
import { H3, HTTPError, handleCors, type H3Event } from 'h3'
import type { CorsOptions } from 'h3'
import { H3, HTTPError, handleCors, type H3Event } from 'h3-next'
import type { CorsOptions } from 'h3-next'

import type { ConnectorState, PendingOperation, OperationType, ApiResponse } from './types.ts'
import {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"test:unit": "vitest --project unit"
},
"dependencies": {
"@iconify-json/simple-icons": "^1.2.67",
"@iconify-json/vscode-icons": "^1.2.40",
"@nuxt/fonts": "^0.13.0",
"@nuxt/scripts": "^0.13.2",
Expand Down
16 changes: 13 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions server/api/jsr/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { JsrPackageInfo } from '#shared/types/jsr'

/**
* Check if an npm package exists on JSR.
*
* GET /api/jsr/:pkg
*
* @example GET /api/jsr/@std/fs → { exists: true, scope: "std", name: "fs", ... }
* @example GET /api/jsr/lodash → { exists: false }
*/
export default defineCachedEventHandler<Promise<JsrPackageInfo>>(
async event => {
const pkgPath = getRouterParam(event, 'pkg')
if (!pkgPath) {
throw createError({ statusCode: 400, message: 'Package name is required' })
}

return await fetchJsrPackageInfo(pkgPath)
},
{
maxAge: 60 * 60, // 1 hour
name: 'api-jsr-package',
getKey: event => getRouterParam(event, 'pkg') ?? '',
},
)
66 changes: 66 additions & 0 deletions server/utils/jsr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { JsrPackageMeta, JsrPackageInfo } from '#shared/types/jsr'

const JSR_REGISTRY = 'https://jsr.io'

/**
* Check if a scoped npm package exists on JSR with the same name.
*
* This only works for scoped packages (@scope/name) since:
* 1. JSR only has scoped packages
* 2. We can only authoritatively match when names are identical
*
* Unscoped npm packages (e.g., "hono") may exist on JSR under a different
* name (e.g., "@hono/hono"), but we don't attempt to guess these mappings.
*
* @param npmPackageName - The npm package name (e.g., "@hono/hono")
* @returns JsrPackageInfo with existence status and metadata
*/
export const fetchJsrPackageInfo = defineCachedFunction(
async (npmPackageName: string): Promise<JsrPackageInfo> => {
// Only check scoped packages - we can't authoritatively map unscoped names
if (!npmPackageName.startsWith('@')) {
return { exists: false }
}

// Parse scope and name from @scope/name format
const match = npmPackageName.match(/^@([^/]+)\/(.+)$/)
if (!match) {
return { exists: false }
}

const [, scope, name] = match

try {
// Fetch JSR package metadata
const meta = await $fetch<JsrPackageMeta>(`${JSR_REGISTRY}/@${scope}/${name}/meta.json`, {
// Short timeout since this is a nice-to-have feature
timeout: 3000,
})

// Find latest non-yanked version
const versions = Object.entries(meta.versions)
.filter(([, v]) => !v.yanked)
.map(([version]) => version)

versions.sort()
const latestVersion = versions[versions.length - 1]

return {
exists: true,
scope: meta.scope,
name: meta.name,
url: `${JSR_REGISTRY}/@${meta.scope}/${meta.name}`,
latestVersion,
}
} catch {
// Package doesn't exist on JSR or API error
return { exists: false }
}
},
{
// Cache for 1 hour - JSR info doesn't change often
maxAge: 60 * 60,
name: 'jsr-package-info',
getKey: (name: string) => name,
},
)
1 change: 1 addition & 0 deletions shared/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './npm-registry'
export * from './jsr'
Loading