Skip to content

Commit 4c8ce58

Browse files
authored
feat: generate package docs using @deno/doc (npmx-dev#135)
1 parent 1d5b40d commit 4c8ce58

29 files changed

Lines changed: 2869 additions & 23 deletions

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@jsr:registry=https://npm.jsr.io

app/components/AppHeader.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ const { isConnected, npmUser } = useConnector()
1414
</script>
1515

1616
<template>
17-
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
17+
<header
18+
aria-label="Site header"
19+
class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"
20+
>
1821
<nav aria-label="Main navigation" class="container h-14 flex items-center">
1922
<!-- Left: Logo -->
2023
<div class="flex-shrink-0">
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
<script setup lang="ts">
2+
import { onClickOutside } from '@vueuse/core'
3+
import { compare } from 'semver'
4+
5+
const props = defineProps<{
6+
packageName: string
7+
currentVersion: string
8+
versions: Record<string, unknown>
9+
distTags: Record<string, string>
10+
}>()
11+
12+
const isOpen = ref(false)
13+
const dropdownRef = useTemplateRef('dropdownRef')
14+
const listboxRef = useTemplateRef('listboxRef')
15+
const focusedIndex = ref(-1)
16+
17+
onClickOutside(dropdownRef, () => {
18+
isOpen.value = false
19+
})
20+
21+
/** Maximum number of versions to show in dropdown */
22+
const MAX_VERSIONS = 10
23+
24+
/** Safe version comparison that falls back to string comparison on error */
25+
function safeCompareVersions(a: string, b: string): number {
26+
try {
27+
return compare(a, b)
28+
} catch {
29+
return a.localeCompare(b)
30+
}
31+
}
32+
33+
/** Get sorted list of recent versions with their tags */
34+
const recentVersions = computed(() => {
35+
const versionList = Object.keys(props.versions)
36+
.sort((a, b) => safeCompareVersions(b, a))
37+
.slice(0, MAX_VERSIONS)
38+
39+
// Create a map of version -> tags
40+
const versionTags = new Map<string, string[]>()
41+
for (const [tag, version] of Object.entries(props.distTags)) {
42+
const existing = versionTags.get(version)
43+
if (existing) {
44+
existing.push(tag)
45+
} else {
46+
versionTags.set(version, [tag])
47+
}
48+
}
49+
50+
return versionList.map(version => ({
51+
version,
52+
tags: versionTags.get(version) ?? [],
53+
isCurrent: version === props.currentVersion,
54+
}))
55+
})
56+
57+
const latestVersion = computed(() => props.distTags.latest)
58+
59+
function getDocsUrl(version: string): string {
60+
return `/docs/${props.packageName}/v/${version}`
61+
}
62+
63+
function handleButtonKeydown(event: KeyboardEvent) {
64+
if (event.key === 'Escape') {
65+
isOpen.value = false
66+
} else if (event.key === 'ArrowDown' && !isOpen.value) {
67+
event.preventDefault()
68+
isOpen.value = true
69+
focusedIndex.value = 0
70+
}
71+
}
72+
73+
function handleListboxKeydown(event: KeyboardEvent) {
74+
const items = recentVersions.value
75+
76+
switch (event.key) {
77+
case 'Escape':
78+
isOpen.value = false
79+
break
80+
case 'ArrowDown':
81+
event.preventDefault()
82+
focusedIndex.value = Math.min(focusedIndex.value + 1, items.length - 1)
83+
scrollToFocused()
84+
break
85+
case 'ArrowUp':
86+
event.preventDefault()
87+
focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
88+
scrollToFocused()
89+
break
90+
case 'Home':
91+
event.preventDefault()
92+
focusedIndex.value = 0
93+
scrollToFocused()
94+
break
95+
case 'End':
96+
event.preventDefault()
97+
focusedIndex.value = items.length - 1
98+
scrollToFocused()
99+
break
100+
case 'Enter':
101+
case ' ':
102+
event.preventDefault()
103+
if (focusedIndex.value >= 0 && focusedIndex.value < items.length) {
104+
navigateToVersion(items[focusedIndex.value]!.version)
105+
}
106+
break
107+
}
108+
}
109+
110+
function scrollToFocused() {
111+
nextTick(() => {
112+
const focused = listboxRef.value?.querySelector('[data-focused="true"]')
113+
focused?.scrollIntoView({ block: 'nearest' })
114+
})
115+
}
116+
117+
function navigateToVersion(version: string) {
118+
isOpen.value = false
119+
navigateTo(getDocsUrl(version))
120+
}
121+
122+
// Reset focused index when dropdown opens
123+
watch(isOpen, open => {
124+
if (open) {
125+
const currentIdx = recentVersions.value.findIndex(v => v.isCurrent)
126+
focusedIndex.value = currentIdx >= 0 ? currentIdx : 0
127+
}
128+
})
129+
</script>
130+
131+
<template>
132+
<div ref="dropdownRef" class="relative">
133+
<button
134+
type="button"
135+
aria-haspopup="listbox"
136+
:aria-expanded="isOpen"
137+
class="flex items-center gap-1.5 text-fg-subtle font-mono text-sm hover:text-fg transition-[color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg rounded"
138+
@click="isOpen = !isOpen"
139+
@keydown="handleButtonKeydown"
140+
>
141+
<span>{{ currentVersion }}</span>
142+
<span
143+
v-if="currentVersion === latestVersion"
144+
class="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 font-sans font-medium"
145+
>
146+
latest
147+
</span>
148+
<span
149+
class="i-carbon-chevron-down w-3.5 h-3.5 transition-[transform] duration-200 motion-reduce:transition-none"
150+
:class="{ 'rotate-180': isOpen }"
151+
aria-hidden="true"
152+
/>
153+
</button>
154+
155+
<Transition
156+
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
157+
enter-from-class="opacity-0 scale-95"
158+
enter-to-class="opacity-100 scale-100"
159+
leave-active-class="transition-[opacity,transform] duration-100 ease-in motion-reduce:transition-none"
160+
leave-from-class="opacity-100 scale-100"
161+
leave-to-class="opacity-0 scale-95"
162+
>
163+
<div
164+
v-if="isOpen"
165+
ref="listboxRef"
166+
role="listbox"
167+
tabindex="0"
168+
:aria-activedescendant="
169+
focusedIndex >= 0 ? `version-${recentVersions[focusedIndex]?.version}` : undefined
170+
"
171+
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 overscroll-contain focus-visible:outline-none"
172+
@keydown="handleListboxKeydown"
173+
>
174+
<NuxtLink
175+
v-for="({ version, tags, isCurrent }, index) in recentVersions"
176+
:id="`version-${version}`"
177+
:key="version"
178+
:to="getDocsUrl(version)"
179+
role="option"
180+
:aria-selected="isCurrent"
181+
:data-focused="index === focusedIndex"
182+
class="flex items-center justify-between gap-3 px-3 py-2 text-sm font-mono hover:bg-bg-muted transition-[color,background-color] focus-visible:outline-none focus-visible:bg-bg-muted"
183+
:class="[
184+
isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted',
185+
index === focusedIndex ? 'bg-bg-muted' : '',
186+
]"
187+
@click="isOpen = false"
188+
>
189+
<span class="truncate">{{ version }}</span>
190+
<span v-if="tags.length > 0" class="flex items-center gap-1 shrink-0">
191+
<span
192+
v-for="tag in tags"
193+
:key="tag"
194+
class="text-[10px] px-1.5 py-0.5 rounded font-sans font-medium"
195+
:class="
196+
tag === 'latest'
197+
? 'bg-emerald-500/10 text-emerald-400'
198+
: 'bg-bg-muted text-fg-subtle'
199+
"
200+
>
201+
{{ tag }}
202+
</span>
203+
</span>
204+
</NuxtLink>
205+
206+
<div
207+
v-if="Object.keys(versions).length > MAX_VERSIONS"
208+
class="border-t border-border mt-1 pt-1 px-3 py-2"
209+
>
210+
<NuxtLink
211+
:to="`/${packageName}`"
212+
class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg"
213+
@click="isOpen = false"
214+
>
215+
View all {{ Object.keys(versions).length }} versions
216+
</NuxtLink>
217+
</div>
218+
</div>
219+
</Transition>
220+
</div>
221+
</template>

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ const homepageUrl = computed(() => {
216216
return homepage
217217
})
218218
219+
// Docs URL: use our generated API docs
220+
const docsLink = computed(() => {
221+
if (!displayVersion.value) return null
222+
223+
return {
224+
name: 'docs' as const,
225+
params: { path: [...pkg.value!.name.split('/'), 'v', displayVersion.value.version] },
226+
}
227+
})
228+
219229
function normalizeGitUrl(url: string): string {
220230
return url
221231
.replace(/^git\+/, '')
@@ -724,6 +734,15 @@ defineOgImageComponent('Package', {
724734
{{ $t('package.links.jsr') }}
725735
</a>
726736
</li>
737+
<li v-if="docsLink">
738+
<NuxtLink
739+
:to="docsLink"
740+
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
741+
>
742+
<span class="i-carbon-document w-4 h-4" aria-hidden="true" />
743+
{{ $t('package.links.docs') }}
744+
</NuxtLink>
745+
</li>
727746
<li v-if="displayVersion" class="sm:ml-auto">
728747
<NuxtLink
729748
:to="{

0 commit comments

Comments
 (0)