Skip to content

Commit 5e3ec21

Browse files
authored
feat: extract and surface playground links from READMEs (#55)
1 parent e8e18c3 commit 5e3ec21

10 files changed

Lines changed: 551 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
3333
- **Vulnerability warnings** – security advisories from the OSV database
3434
- **Download statistics** – weekly download counts with sparkline charts
3535
- **Install size** – total install size including dependencies
36+
- **Playground links** – quick access to StackBlitz, CodeSandbox, and other demo environments from READMEs
3637
- **Infinite search** – auto-load additional search pages as you scroll
3738

3839
### User & org pages
@@ -62,6 +63,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
6263
| JSR cross-reference |||
6364
| Vulnerability warnings |||
6465
| Download charts |||
66+
| Playground links |||
6567
| Dependents list || 🚧 |
6668
| Package admin (access/owners) || 🚧 |
6769
| Org/team management || 🚧 |

app/components/AppTooltip.vue

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
/** Tooltip text */
4+
text: string
5+
/** Position: 'top' | 'bottom' | 'left' | 'right' */
6+
position?: 'top' | 'bottom' | 'left' | 'right'
7+
}>()
8+
9+
const isVisible = ref(false)
10+
const tooltipId = useId()
11+
12+
const positionClasses: Record<string, string> = {
13+
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1',
14+
bottom: 'top-full left-0 mt-1',
15+
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
16+
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
17+
}
18+
19+
const tooltipPosition = computed(() => positionClasses[props.position || 'bottom'])
20+
21+
function show() {
22+
isVisible.value = true
23+
}
24+
25+
function hide() {
26+
isVisible.value = false
27+
}
28+
</script>
29+
30+
<template>
31+
<div
32+
class="relative inline-flex"
33+
:aria-describedby="isVisible ? tooltipId : undefined"
34+
@mouseenter="show"
35+
@mouseleave="hide"
36+
@focusin="show"
37+
@focusout="hide"
38+
>
39+
<slot />
40+
41+
<Transition
42+
enter-active-class="transition-opacity duration-150 motion-reduce:transition-none"
43+
leave-active-class="transition-opacity duration-100 motion-reduce:transition-none"
44+
enter-from-class="opacity-0"
45+
leave-to-class="opacity-0"
46+
>
47+
<div
48+
v-if="isVisible"
49+
:id="tooltipId"
50+
role="tooltip"
51+
class="absolute px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-[100] pointer-events-none"
52+
:class="tooltipPosition"
53+
>
54+
{{ text }}
55+
</div>
56+
</Transition>
57+
</div>
58+
</template>
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<script setup lang="ts">
2+
import { onClickOutside } from '@vueuse/core'
3+
import type { PlaygroundLink } from '#shared/types'
4+
5+
const props = defineProps<{
6+
links: PlaygroundLink[]
7+
}>()
8+
9+
// Map provider id to icon class
10+
const providerIcons: Record<string, string> = {
11+
'stackblitz': 'i-simple-icons-stackblitz',
12+
'codesandbox': 'i-simple-icons-codesandbox',
13+
'codepen': 'i-simple-icons-codepen',
14+
'replit': 'i-simple-icons-replit',
15+
'gitpod': 'i-simple-icons-gitpod',
16+
'vue-playground': 'i-simple-icons-vuedotjs',
17+
'nuxt-new': 'i-simple-icons-nuxtdotjs',
18+
'vite-new': 'i-simple-icons-vite',
19+
'jsfiddle': 'i-carbon-code',
20+
}
21+
22+
// Map provider id to color class
23+
const providerColors: Record<string, string> = {
24+
'stackblitz': 'text-provider-stackblitz',
25+
'codesandbox': 'text-provider-codesandbox',
26+
'codepen': 'text-provider-codepen',
27+
'replit': 'text-provider-replit',
28+
'gitpod': 'text-provider-gitpod',
29+
'vue-playground': 'text-provider-vue',
30+
'nuxt-new': 'text-provider-nuxt',
31+
'vite-new': 'text-provider-vite',
32+
'jsfiddle': 'text-provider-jsfiddle',
33+
}
34+
35+
function getIcon(provider: string): string {
36+
return providerIcons[provider] || 'i-carbon-play'
37+
}
38+
39+
function getColor(provider: string): string {
40+
return providerColors[provider] || 'text-fg-muted'
41+
}
42+
43+
// Dropdown state
44+
const isOpen = ref(false)
45+
const dropdownRef = ref<HTMLElement>()
46+
const menuRef = ref<HTMLElement>()
47+
const focusedIndex = ref(-1)
48+
49+
onClickOutside(dropdownRef, () => {
50+
isOpen.value = false
51+
})
52+
53+
// Single vs multiple
54+
const hasSingleLink = computed(() => props.links.length === 1)
55+
const hasMultipleLinks = computed(() => props.links.length > 1)
56+
const firstLink = computed(() => props.links[0])
57+
58+
function closeDropdown() {
59+
isOpen.value = false
60+
focusedIndex.value = -1
61+
}
62+
63+
function handleKeydown(event: KeyboardEvent) {
64+
if (!isOpen.value) {
65+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
66+
event.preventDefault()
67+
isOpen.value = true
68+
focusedIndex.value = 0
69+
nextTick(() => focusMenuItem(0))
70+
}
71+
return
72+
}
73+
74+
switch (event.key) {
75+
case 'Escape':
76+
event.preventDefault()
77+
closeDropdown()
78+
break
79+
case 'ArrowDown':
80+
event.preventDefault()
81+
focusedIndex.value = (focusedIndex.value + 1) % props.links.length
82+
focusMenuItem(focusedIndex.value)
83+
break
84+
case 'ArrowUp':
85+
event.preventDefault()
86+
focusedIndex.value = focusedIndex.value <= 0 ? props.links.length - 1 : focusedIndex.value - 1
87+
focusMenuItem(focusedIndex.value)
88+
break
89+
case 'Home':
90+
event.preventDefault()
91+
focusedIndex.value = 0
92+
focusMenuItem(0)
93+
break
94+
case 'End':
95+
event.preventDefault()
96+
focusedIndex.value = props.links.length - 1
97+
focusMenuItem(props.links.length - 1)
98+
break
99+
case 'Tab':
100+
closeDropdown()
101+
break
102+
}
103+
}
104+
105+
function focusMenuItem(index: number) {
106+
const items = menuRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]')
107+
items?.[index]?.focus()
108+
}
109+
</script>
110+
111+
<template>
112+
<section v-if="links.length > 0" aria-labelledby="playgrounds-heading">
113+
<h2 id="playgrounds-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
114+
Try it out
115+
</h2>
116+
117+
<div ref="dropdownRef" class="relative">
118+
<!-- Single link: direct button -->
119+
<AppTooltip v-if="hasSingleLink && firstLink" :text="firstLink.providerName" class="w-full">
120+
<a
121+
:href="firstLink.url"
122+
target="_blank"
123+
rel="noopener noreferrer"
124+
class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
125+
>
126+
<span
127+
:class="[getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']"
128+
aria-hidden="true"
129+
/>
130+
<span class="truncate text-fg-muted">{{ firstLink.label }}</span>
131+
</a>
132+
</AppTooltip>
133+
134+
<!-- Multiple links: dropdown button -->
135+
<button
136+
v-if="hasMultipleLinks"
137+
type="button"
138+
aria-haspopup="true"
139+
:aria-expanded="isOpen"
140+
class="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
141+
@click="isOpen = !isOpen"
142+
@keydown="handleKeydown"
143+
>
144+
<span class="flex items-center gap-2">
145+
<span class="i-carbon-play w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" />
146+
<span class="text-fg-muted">choose playground ({{ links.length }})</span>
147+
</span>
148+
<span
149+
class="i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200 motion-reduce:transition-none"
150+
:class="{ 'rotate-180': isOpen }"
151+
aria-hidden="true"
152+
/>
153+
</button>
154+
155+
<!-- Dropdown menu -->
156+
<Transition
157+
enter-active-class="transition duration-150 ease-out motion-reduce:transition-none"
158+
enter-from-class="opacity-0 scale-95 motion-reduce:scale-100"
159+
enter-to-class="opacity-100 scale-100"
160+
leave-active-class="transition duration-100 ease-in motion-reduce:transition-none"
161+
leave-from-class="opacity-100 scale-100"
162+
leave-to-class="opacity-0 scale-95 motion-reduce:scale-100"
163+
>
164+
<div
165+
v-if="isOpen && hasMultipleLinks"
166+
ref="menuRef"
167+
role="menu"
168+
class="absolute top-full left-0 right-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 overflow-visible"
169+
@keydown="handleKeydown"
170+
>
171+
<AppTooltip v-for="link in links" :key="link.url" :text="link.providerName" class="block">
172+
<a
173+
:href="link.url"
174+
target="_blank"
175+
rel="noopener noreferrer"
176+
role="menuitem"
177+
class="flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted focus-visible:outline-none focus-visible:text-fg focus-visible:bg-bg-muted transition-colors duration-150"
178+
@click="closeDropdown"
179+
>
180+
<span
181+
:class="[getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']"
182+
aria-hidden="true"
183+
/>
184+
<span class="truncate">{{ link.label }}</span>
185+
</a>
186+
</AppTooltip>
187+
</div>
188+
</Transition>
189+
</div>
190+
</section>
191+
</template>

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import { joinURL } from 'ufo'
3-
import type { PackumentVersion, NpmVersionDist } from '#shared/types'
3+
import type { PackumentVersion, NpmVersionDist, ReadmeResponse } from '#shared/types'
44
import type { JsrPackageInfo } from '#shared/types/jsr'
55
import { assertValidPackageName } from '#shared/utils/npm'
66
@@ -56,13 +56,13 @@ const { data: downloads } = usePackageDownloads(packageName, 'last-week')
5656
const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 })
5757
5858
// Fetch README for specific version if requested, otherwise latest
59-
const { data: readmeData } = useLazyFetch<{ html: string }>(
59+
const { data: readmeData } = useLazyFetch<ReadmeResponse>(
6060
() => {
6161
const base = `/api/registry/readme/${packageName.value}`
6262
const version = requestedVersion.value
6363
return version ? `${base}/v/${version}` : base
6464
},
65-
{ default: () => ({ html: '' }) },
65+
{ default: () => ({ html: '', playgroundLinks: [] }) },
6666
)
6767
6868
// Check if package exists on JSR (only for scoped packages)
@@ -686,9 +686,15 @@ defineOgImageComponent('Package', {
686686
</ul>
687687
</section>
688688

689-
<!-- Donwload stats -->
689+
<!-- Download stats -->
690690
<PackageDownloadStats :downloads="weeklyDownloads" />
691691

692+
<!-- Playground links -->
693+
<PackagePlaygrounds
694+
v-if="readmeData?.playgroundLinks?.length"
695+
:links="readmeData.playgroundLinks"
696+
/>
697+
692698
<section
693699
v-if="
694700
displayVersion?.engines && (displayVersion.engines.node || displayVersion.engines.npm)

server/api/registry/readme/[...pkg].get.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { parseRepositoryInfo } from '#server/utils/readme'
2-
31
/**
42
* Fetch README from jsdelivr CDN for a specific package version.
53
* Falls back through common README filenames.
@@ -82,14 +80,13 @@ export default defineCachedEventHandler(
8280
}
8381

8482
if (!readmeContent) {
85-
return { html: '' }
83+
return { html: '', playgroundLinks: [] }
8684
}
8785

8886
// Parse repository info for resolving relative URLs to GitHub
8987
const repoInfo = parseRepositoryInfo(packageData.repository)
9088

91-
const html = await renderReadmeHtml(readmeContent, packageName, repoInfo)
92-
return { html }
89+
return await renderReadmeHtml(readmeContent, packageName, repoInfo)
9390
} catch (error) {
9491
if (error && typeof error === 'object' && 'statusCode' in error) {
9592
throw error

0 commit comments

Comments
 (0)