Skip to content
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io
5 changes: 4 additions & 1 deletion app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ const { isConnected, npmUser } = useConnector()
</script>

<template>
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
<header
aria-label="Site header"
class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"
>
<nav aria-label="Main navigation" class="container h-14 flex items-center">
<!-- Left: Logo -->
<div class="flex-shrink-0">
Expand Down
141 changes: 141 additions & 0 deletions app/components/DocsVersionSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { compareVersions } from '~/utils/versions'

Check failure on line 3 in app/components/DocsVersionSelector.vue

View workflow job for this annotation

GitHub Actions / test

'"~/utils/versions"' has no exported member named 'compareVersions'. Did you mean 'parseVersion'?

const props = defineProps<{
packageName: string
currentVersion: string
versions: Record<string, unknown>
distTags: Record<string, string>
}>()

const isOpen = ref(false)
const dropdownRef = ref<HTMLElement>()

onClickOutside(dropdownRef, () => {
isOpen.value = false
})

/** Maximum number of versions to show in dropdown */
const MAX_VERSIONS = 10

/** Get sorted list of recent versions with their tags */
const recentVersions = computed(() => {
const versionList = Object.keys(props.versions)
.sort((a, b) => compareVersions(b, a))
.slice(0, MAX_VERSIONS)

// Create a map of version -> tags
const versionTags = new Map<string, string[]>()
for (const [tag, version] of Object.entries(props.distTags)) {
const existing = versionTags.get(version)
if (existing) {
existing.push(tag)
} else {
versionTags.set(version, [tag])
}
}

return versionList.map(version => ({
version,
tags: versionTags.get(version) ?? [],
isCurrent: version === props.currentVersion,
}))
})

const latestVersion = computed(() => props.distTags.latest)

function getDocsUrl(version: string): string {
return `/docs/${props.packageName}/v/${version}`
}

function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
isOpen.value = false
}
}
</script>

<template>
<div ref="dropdownRef" class="relative">
<button
type="button"
aria-haspopup="listbox"
:aria-expanded="isOpen"
class="flex items-center gap-1.5 text-fg-subtle font-mono text-sm hover:text-fg transition-colors"
@click="isOpen = !isOpen"
@keydown="handleKeydown"
>
<span>{{ currentVersion }}</span>
<span
v-if="currentVersion === latestVersion"
class="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 font-sans font-medium"
>
latest
</span>
<span
class="i-carbon-chevron-down w-3.5 h-3.5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
aria-hidden="true"
/>
</button>

<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isOpen"
role="listbox"
:aria-activedescendant="`version-${currentVersion}`"
class="absolute top-full left-0 mt-2 min-w-[180px] bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 max-h-[300px] overflow-y-auto"
@keydown="handleKeydown"
>
<NuxtLink
v-for="{ version, tags, isCurrent } in recentVersions"
:id="`version-${version}`"
:key="version"
:to="getDocsUrl(version)"
role="option"
:aria-selected="isCurrent"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm font-mono hover:bg-bg-muted transition-colors"
:class="isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted'"
@click="isOpen = false"
>
<span class="truncate">{{ version }}</span>
<span v-if="tags.length > 0" class="flex items-center gap-1 shrink-0">
<span
v-for="tag in tags"
:key="tag"
class="text-[10px] px-1.5 py-0.5 rounded font-sans font-medium"
:class="
tag === 'latest'
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-bg-muted text-fg-subtle'
"
>
{{ tag }}
</span>
</span>
</NuxtLink>

<div
v-if="Object.keys(versions).length > MAX_VERSIONS"
class="border-t border-border mt-1 pt-1 px-3 py-2"
>
<NuxtLink
:to="`/${packageName}`"
class="text-xs text-fg-subtle hover:text-fg transition-colors"
@click="isOpen = false"
>
View all {{ Object.keys(versions).length }} versions
</NuxtLink>
</div>
</div>
</Transition>
</div>
</template>
57 changes: 46 additions & 11 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,30 @@ const homepageUrl = computed(() => {
return homepage
})

// Docs URL: prefer package's homepage (often their docs site), fall back to our API docs
const docsLink = computed(() => {
const homepage = displayVersion.value?.homepage
if (homepage) {
return {
href: homepage,
isExternal: true,
}
}

// Fall back to our generated API docs
if (displayVersion.value) {
return {
to: {
name: 'docs' as const,
params: { path: [...pkg.value!.name.split('/'), 'v', displayVersion.value.version] },
},
isExternal: false,
}
}

return null
})

function normalizeGitUrl(url: string): string {
return url
.replace(/^git\+/, '')
Expand Down Expand Up @@ -665,17 +689,7 @@ defineOgImageComponent('Package', {
{{ formatCompactNumber(stars, { decimals: 1 }) }}
</a>
</li>
<li v-if="homepageUrl">
<a
:href="homepageUrl"
target="_blank"
rel="noopener noreferrer"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon-link w-4 h-4" aria-hidden="true" />
homepage
</a>
</li>

<li v-if="displayVersion?.bugs?.url">
<a
:href="displayVersion.bugs.url"
Expand Down Expand Up @@ -727,6 +741,26 @@ defineOgImageComponent('Package', {
</a>
</li>

<li v-if="docsLink">
<a
v-if="docsLink.isExternal"
:href="docsLink.href"
target="_blank"
rel="noopener noreferrer"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon-document w-4 h-4" aria-hidden="true" />
docs
</a>
<NuxtLink
v-else
:to="docsLink.to"
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
>
<span class="i-carbon-document w-4 h-4" aria-hidden="true" />
docs
</NuxtLink>
</li>
<li v-if="displayVersion">
<NuxtLink
:to="{
Expand All @@ -736,6 +770,7 @@ defineOgImageComponent('Package', {
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
aria-keyshortcuts="."
>
<span class="i-carbon-code w-4 h-4" aria-hidden="true" />
code
<kbd
class="hidden sm:inline-flex items-center justify-center w-4 h-4 text-xs bg-bg-muted border border-border rounded"
Expand Down
Loading
Loading