Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Welcome to the Nuxt website repository available on [nuxt.com](https://nuxt.com).

[![Nuxt UI](https://img.shields.io/badge/Made%20with-Nuxt%20UI-00DC82?logo=nuxt.js&labelColor=020420)](https://ui.nuxt.com)
[![nuxt.care](https://img.shields.io/badge/Health%20by-nuxt.care-84cc16?labelColor=020420)](https://nuxt.care)

## Setup

Expand Down
2 changes: 1 addition & 1 deletion app/components/AdminDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const items = computed<DropdownMenuItem[][]>(() => [
]
])

const { data: rawFeedback, refresh: refreshFeedback } = await useFetch('/api/feedback')
const { data: rawFeedback, refresh: refreshFeedback } = await useFetch<FeedbackItem[]>('/api/feedback')
const { deleteFeedback } = useFeedbackDelete()
const { exportFeedbackData, exportPageAnalytics } = useFeedbackExport()

Expand Down
51 changes: 35 additions & 16 deletions app/components/module/ModuleItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const items = computed(() => [
container: 'flex flex-col',
wrapper: 'flex flex-col min-h-0 items-start',
body: 'flex-none',
footer: 'w-full mt-auto pointer-events-auto pt-4 z-[1]'
footer: 'w-full mt-auto pointer-events-auto pt-4 z-1'
}"
@click="handleCardClick"
>
Expand All @@ -111,21 +111,25 @@ const items = computed(() => [
/>
</template>

<UBadge
v-if="showBadge && module.type === 'official'"
class="shine absolute top-4 right-4 sm:top-6 sm:right-6"
variant="subtle"
color="primary"
label="Official"
/>

<UBadge
v-if="showBadge && module.sponsor"
class="shine absolute top-4 right-4 sm:top-6 sm:right-6"
variant="subtle"
color="important"
label="Sponsor"
/>
<div
v-if="showBadge && (module.type === 'official' || module.sponsor || module.health)"
class="absolute top-4 right-4 sm:top-6 sm:right-6 flex items-center gap-2 z-1 pointer-events-auto"
>
<UBadge
v-if="module.type === 'official'"
class="shine"
variant="subtle"
color="primary"
label="Official"
/>
<UBadge
v-if="module.sponsor"
class="shine"
variant="subtle"
color="important"
label="Sponsor"
/>
</div>

<template #footer>
<USeparator type="dashed" class="mb-4" />
Expand Down Expand Up @@ -154,6 +158,21 @@ const items = computed(() => [
</NuxtLink>
</UTooltip>

<template v-if="module.health">
<UTooltip :text="`Health: ${module.health.status} - ${module.health.score}/100`">
<NuxtLink
:to="`https://nuxt.care/?search=npm:${module.npm}`"
class="flex items-center gap-1 hover:text-highlighted"
target="_blank"
>
<UIcon name="i-lucide-heart-pulse" class="size-4 shrink-0" :style="{ color: module.health.color }" />
<span class="text-sm font-medium whitespace-normal">
{{ module.health.score }}
</span>
</NuxtLink>
</UTooltip>
</template>

<UTooltip v-if="selectedSort.key === 'publishedAt'" :text="`Updated ${formatDateByLocale('en', module.stats.publishedAt)}`">
<NuxtLink
class="flex items-center gap-1 hover:text-highlighted"
Expand Down
16 changes: 15 additions & 1 deletion app/pages/modules/[slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ defineOgImageComponent('Module', {
:icon="moduleIcon(module.category)"
:alt="module.name"
size="xl"
class="-m-[4px] rounded-none bg-transparent"
class="-m-1 rounded-none bg-transparent"
/>

<div>
Expand Down Expand Up @@ -151,6 +151,20 @@ defineOgImageComponent('Module', {
</NuxtLink>
</UTooltip>

<template v-if="module.health">
<span class="hidden lg:block text-muted">&bull;</span>
<UTooltip :text="`Health: ${module.health.status} - ${module.health.score}/100`">
<NuxtLink
:to="`https://nuxt.care/?search=npm:${module.npm}`"
class="flex items-center gap-1.5"
target="_blank"
>
<UIcon name="i-lucide-heart-pulse" class="size-5 shrink-0" :style="{ color: module.health.color }" />
<span class="text-sm font-medium">{{ module.health.score }}</span>
</NuxtLink>
</UTooltip>
</template>

<div class="mx-3 h-6 border-l border-gray-200 dark:border-gray-800 w-px hidden lg:block" />

<div v-for="(maintainer, index) in module.maintainers" :key="maintainer.github" class="flex items-center gap-3">
Expand Down
8 changes: 5 additions & 3 deletions server/api/v1/modules/[name].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,19 @@ export default defineCachedEventHandler(async (event) => {
})
}

const [stats, contributors, readme] = await Promise.all([
const [stats, contributors, readme, bulkHealth] = await Promise.all([
fetchModuleStats(event, module),
fetchModuleContributors(event, module),
fetchModuleReadme(event, module)
fetchModuleReadme(event, module),
fetchBulkModuleHealth(event, [module])
])
return {
...module,
generatedAt: new Date().toISOString(),
contributors,
stats,
readme
readme,
health: bulkHealth[module.name] || null
} satisfies Module
}, {
name: 'modules:v1',
Expand Down
6 changes: 5 additions & 1 deletion server/api/v1/modules/index.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export default defineCachedEventHandler(async (event) => {
modules: string[]
}

const bulkNpmStats = await npm.fetchBulkPackageStats(modules.map(m => m.npm), 'last-month')
const [bulkNpmStats, bulkHealth] = await Promise.all([
npm.fetchBulkPackageStats(modules.map(m => m.npm), 'last-month'),
fetchBulkModuleHealth(event, modules)
])

const maintainers: Record<string, MaintainerWithModules> = {}
const contributors: Record<string, ContributorWithModules> = {}
Expand All @@ -66,6 +69,7 @@ export default defineCachedEventHandler(async (event) => {
])
module.stats = mStats
module.contributors = mContributors
module.health = bulkHealth[module.name] || null

if (module.maintainers) {
for (const maintainer of module.maintainers) {
Expand Down
69 changes: 68 additions & 1 deletion server/utils/module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parseMarkdown } from '@nuxtjs/mdc/runtime'

import type { H3Event } from 'h3'
import type { BaseModule, Module, ModuleContributor, ModuleStats } from '#shared/types'
import type { BaseModule, Module, ModuleContributor, ModuleHealth, ModuleStats } from '#shared/types'
import type { NpmDownloadStats } from '../types/npm'

export function isBot(username: string) {
Expand Down Expand Up @@ -97,6 +97,73 @@ export async function fetchModuleContributors(_event: H3Event, module: BaseModul
}
}

interface NuxtCareModuleSlim {
name: string
npm: string
score: number
status: string
lastUpdated: string | null
}

export async function fetchBulkModuleHealth(_event: H3Event, modules: BaseModule[]): Promise<Record<string, ModuleHealth>> {
const result: Record<string, ModuleHealth> = {}
const uncached: BaseModule[] = []

// Check KV cache first
for (const module of modules) {
const cached = await kv.get<ModuleHealth>(`module:health:${module.name}`)
if (cached) {
result[module.name] = cached
} else {
uncached.push(module)
}
}

if (!uncached.length) return result

const CHUNK_SIZE = 50
const statusColorMap: Record<string, string> = {
optimal: '#22c55e',
stable: '#84cc16',
degraded: '#eab308',
critical: '#ef4444',
unknown: '#6b7280'
}
const npmToModule = new Map(uncached.map(m => [m.npm, m]))

console.info(`Fetching health for ${uncached.length} modules from nuxt.care (${Math.ceil(uncached.length / CHUNK_SIZE)} chunks)...`)
for (let i = 0; i < uncached.length; i += CHUNK_SIZE) {
const chunk = uncached.slice(i, i + CHUNK_SIZE)
try {
const query = new URLSearchParams()
query.set('slim', 'true')
for (const m of chunk) {
query.append('package', m.npm)
}
const data = await $fetch<NuxtCareModuleSlim[]>(`https://nuxt.care/api/v1/modules?${query.toString()}`, {
timeout: 10_000,
retry: 2,
retryDelay: 1000
})
for (const item of data) {
const module = npmToModule.get(item.npm)
if (!module) continue
const health: ModuleHealth = {
score: item.score,
color: statusColorMap[item.status] || '#6b7280',
status: item.status
}
result[module.name] = health
await kv.set(`module:health:${module.name}`, health, { ttl: 60 * 60 * 24 })
}
} catch (err) {
console.error(`Cannot fetch bulk health from nuxt.care (chunk ${Math.floor(i / CHUNK_SIZE) + 1}): ${err}`)
}
}

return result
}

export async function fetchModuleReadme(_event: H3Event, module: BaseModule) {
console.info(`Fetching module ${module.name} readme ...`)
const readme = await $fetch(`https://unpkg.com/${module.npm}/README.md`).catch(() => {
Expand Down
7 changes: 7 additions & 0 deletions shared/types/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,15 @@ export interface ModuleStats {
createdAt: number
}

export interface ModuleHealth {
score: number
color: string
status: string
}

export interface Module extends BaseModule {
stats?: ModuleStats
health?: ModuleHealth | null
contributors?: ModuleContributor[]
maintainers?: ModuleMaintainer[]
readme?: MDCParserResult
Expand Down