Skip to content
58 changes: 58 additions & 0 deletions app/components/AppTooltip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script setup lang="ts">
const props = defineProps<{
/** Tooltip text */
text: string
/** Position: 'top' | 'bottom' | 'left' | 'right' */
position?: 'top' | 'bottom' | 'left' | 'right'
}>()

const isVisible = ref(false)
const tooltipId = useId()

const positionClasses: Record<string, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1',
bottom: 'top-full left-0 mt-1',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
}

const tooltipPosition = computed(() => positionClasses[props.position || 'bottom'])

function show() {
isVisible.value = true
}

function hide() {
isVisible.value = false
}
</script>

<template>
<div
class="relative inline-flex"
:aria-describedby="isVisible ? tooltipId : undefined"
@mouseenter="show"
@mouseleave="hide"
@focusin="show"
@focusout="hide"
>
<slot />

<Transition
enter-active-class="transition-opacity duration-150 motion-reduce:transition-none"
leave-active-class="transition-opacity duration-100 motion-reduce:transition-none"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
:id="tooltipId"
role="tooltip"
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"
:class="tooltipPosition"
>
{{ text }}
</div>
</Transition>
</div>
</template>
191 changes: 191 additions & 0 deletions app/components/PackagePlaygrounds.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import type { PlaygroundLink } from '#shared/types'

const props = defineProps<{
links: PlaygroundLink[]
}>()

// Map provider id to icon class
const providerIcons: Record<string, string> = {
'stackblitz': 'i-simple-icons-stackblitz',
'codesandbox': 'i-simple-icons-codesandbox',
'codepen': 'i-simple-icons-codepen',
'replit': 'i-simple-icons-replit',
'gitpod': 'i-simple-icons-gitpod',
'vue-playground': 'i-simple-icons-vuedotjs',
'nuxt-new': 'i-simple-icons-nuxtdotjs',
'vite-new': 'i-simple-icons-vite',
'jsfiddle': 'i-carbon-code',
}

// Map provider id to color class
const providerColors: Record<string, string> = {
'stackblitz': 'text-provider-stackblitz',
'codesandbox': 'text-provider-codesandbox',
'codepen': 'text-provider-codepen',
'replit': 'text-provider-replit',
'gitpod': 'text-provider-gitpod',
'vue-playground': 'text-provider-vue',
'nuxt-new': 'text-provider-nuxt',
'vite-new': 'text-provider-vite',
'jsfiddle': 'text-provider-jsfiddle',
}

function getIcon(provider: string): string {
return providerIcons[provider] || 'i-carbon-play'
}

function getColor(provider: string): string {
return providerColors[provider] || 'text-fg-muted'
}

// Dropdown state
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement>()
const menuRef = ref<HTMLElement>()
const focusedIndex = ref(-1)

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

// Single vs multiple
const hasSingleLink = computed(() => props.links.length === 1)
const hasMultipleLinks = computed(() => props.links.length > 1)
const firstLink = computed(() => props.links[0])

function closeDropdown() {
isOpen.value = false
focusedIndex.value = -1
}

function handleKeydown(event: KeyboardEvent) {
if (!isOpen.value) {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
isOpen.value = true
focusedIndex.value = 0
nextTick(() => focusMenuItem(0))
}
return
}

switch (event.key) {
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'ArrowDown':
event.preventDefault()
focusedIndex.value = (focusedIndex.value + 1) % props.links.length
focusMenuItem(focusedIndex.value)
break
case 'ArrowUp':
event.preventDefault()
focusedIndex.value = focusedIndex.value <= 0 ? props.links.length - 1 : focusedIndex.value - 1
focusMenuItem(focusedIndex.value)
break
case 'Home':
event.preventDefault()
focusedIndex.value = 0
focusMenuItem(0)
break
case 'End':
event.preventDefault()
focusedIndex.value = props.links.length - 1
focusMenuItem(props.links.length - 1)
break
case 'Tab':
closeDropdown()
break
}
}

function focusMenuItem(index: number) {
const items = menuRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]')
items?.[index]?.focus()
}
</script>

<template>
<section v-if="links.length > 0" aria-labelledby="playgrounds-heading">
<h2 id="playgrounds-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
Try it out
</h2>

<div ref="dropdownRef" class="relative">
<!-- Single link: direct button -->
<AppTooltip v-if="hasSingleLink && firstLink" :text="firstLink.providerName" class="w-full">
<a
:href="firstLink.url"
target="_blank"
rel="noopener noreferrer"
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"
>
<span
:class="[getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']"
aria-hidden="true"
/>
<span class="truncate text-fg-muted">{{ firstLink.label }}</span>
</a>
</AppTooltip>

<!-- Multiple links: dropdown button -->
<button
v-if="hasMultipleLinks"
type="button"
aria-haspopup="true"
:aria-expanded="isOpen"
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"
@click="isOpen = !isOpen"
@keydown="handleKeydown"
>
<span class="flex items-center gap-2">
<span class="i-carbon-play w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" />
<span class="text-fg-muted">choose playground ({{ links.length }})</span>
</span>
<span
class="i-carbon-chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200 motion-reduce:transition-none"
:class="{ 'rotate-180': isOpen }"
aria-hidden="true"
/>
</button>

<!-- Dropdown menu -->
<Transition
enter-active-class="transition duration-150 ease-out motion-reduce:transition-none"
enter-from-class="opacity-0 scale-95 motion-reduce:scale-100"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in motion-reduce:transition-none"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95 motion-reduce:scale-100"
>
<div
v-if="isOpen && hasMultipleLinks"
ref="menuRef"
role="menu"
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"
@keydown="handleKeydown"
>
<AppTooltip v-for="link in links" :key="link.url" :text="link.providerName" class="block">
<a
:href="link.url"
target="_blank"
rel="noopener noreferrer"
role="menuitem"
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"
@click="closeDropdown"
>
<span
:class="[getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']"
aria-hidden="true"
/>
<span class="truncate">{{ link.label }}</span>
</a>
</AppTooltip>
</div>
</Transition>
</div>
</section>
</template>
14 changes: 10 additions & 4 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { joinURL } from 'ufo'
import type { PackumentVersion, NpmVersionDist } from '#shared/types'
import type { PackumentVersion, NpmVersionDist, ReadmeResponse } from '#shared/types'
import type { JsrPackageInfo } from '#shared/types/jsr'
import { assertValidPackageName } from '#shared/utils/npm'

Expand Down Expand Up @@ -56,13 +56,13 @@ const { data: downloads } = usePackageDownloads(packageName, 'last-week')
const { data: weeklyDownloads } = usePackageWeeklyDownloadEvolution(packageName, { weeks: 52 })

// Fetch README for specific version if requested, otherwise latest
const { data: readmeData } = useLazyFetch<{ html: string }>(
const { data: readmeData } = useLazyFetch<ReadmeResponse>(
() => {
const base = `/api/registry/readme/${packageName.value}`
const version = requestedVersion.value
return version ? `${base}/v/${version}` : base
},
{ default: () => ({ html: '' }) },
{ default: () => ({ html: '', playgroundLinks: [] }) },
)

// Check if package exists on JSR (only for scoped packages)
Expand Down Expand Up @@ -686,9 +686,15 @@ defineOgImageComponent('Package', {
</ul>
</section>

<!-- Donwload stats -->
<!-- Download stats -->
<PackageDownloadStats :downloads="weeklyDownloads" />

<!-- Playground links -->
<PackagePlaygrounds
v-if="readmeData?.playgroundLinks?.length"
:links="readmeData.playgroundLinks"
/>

<section
v-if="
displayVersion?.engines && (displayVersion.engines.node || displayVersion.engines.npm)
Expand Down
7 changes: 2 additions & 5 deletions server/api/registry/readme/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { parseRepositoryInfo } from '#server/utils/readme'

/**
* Fetch README from jsdelivr CDN for a specific package version.
* Falls back through common README filenames.
Expand Down Expand Up @@ -82,14 +80,13 @@ export default defineCachedEventHandler(
}

if (!readmeContent) {
return { html: '' }
return { html: '', playgroundLinks: [] }
}

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

const html = await renderReadmeHtml(readmeContent, packageName, repoInfo)
return { html }
return await renderReadmeHtml(readmeContent, packageName, repoInfo)
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
Expand Down
Loading