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 @@ -83,6 +83,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 })
Comment on lines +118 to +143
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle multiple modules sharing the same npm package.

npmToModule keeps only one module per npm, so aliases/duplicates silently drop health assignment for others.

💡 Proposed fix
-  const npmToModule = new Map(uncached.map(m => [m.npm, m]))
+  const npmToModules = new Map<string, BaseModule[]>()
+  for (const m of uncached) {
+    const existing = npmToModules.get(m.npm)
+    if (existing) existing.push(m)
+    else npmToModules.set(m.npm, [m])
+  }
...
-      for (const item of data) {
-        const module = npmToModule.get(item.npm)
-        if (!module) continue
+      for (const item of data) {
+        const matchedModules = npmToModules.get(item.npm)
+        if (!matchedModules?.length) 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 })
+        for (const module of matchedModules) {
+          result[module.name] = health
+          await kv.set(`module:health:${module.name}`, health, { ttl: 60 * 60 * 24 })
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/module.ts` around lines 118 - 143, npmToModule currently maps
each npm name to a single module entry so modules that share the same npm
(aliases/duplicates) lose their health info; change the grouping so npmToModule
maps an npm string to an array of Module entries (build it from uncached into
arrays), then in the response loop (where you access npmToModule.get(item.npm))
iterate over the array of modules and assign the same ModuleHealth to each
(setting result[module.name] and calling kv.set(`module:health:${module.name}`,
health, { ttl: 60*60*24 }) for each module) so all aliases receive the health
update while leaving CHUNK_SIZE, $fetch call, statusColorMap lookup, and
ModuleHealth structure unchanged.

}
} 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