Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/components/module/ModuleItem.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Module } from '#shared/types'
import { getModuleNuxtMajors } from '#shared/utils/modules'

const emit = defineEmits<{
add: [module: Module]
Expand All @@ -23,6 +24,8 @@ const { copy } = useClipboard()
const { selectedSort } = useModules()
const { track } = useAnalytics()

const nuxtMajors = computed(() => getModuleNuxtMajors(props.module.compatibility?.nuxt))

const publishedAgo = useTimeAgo(() => props.module.stats.publishedAt)
const createdAgo = useTimeAgo(() => props.module.stats.createdAt)

Expand Down Expand Up @@ -144,6 +147,17 @@ const items = computed(() => [

<div class="flex items-center justify-between gap-3 -my-1 text-muted">
<div class="flex items-center gap-3 flex-wrap">
<div v-if="nuxtMajors.length" class="flex items-center gap-1">
<UBadge
v-for="major in nuxtMajors"
:key="major"
:label="`Nuxt ${major}`"
color="neutral"
variant="subtle"
size="sm"
/>
</div>

<UTooltip text="Monthly NPM Downloads">
<NuxtLink
class="flex items-center gap-1 hover:text-highlighted"
Expand Down
55 changes: 46 additions & 9 deletions app/composables/useModules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
type ModuleStatsKeys = 'version' | 'downloads' | 'stars' | 'publishedAt' | 'createdAt'
import type { Module } from '#shared/types'
import { moduleSupportsNuxt } from '#shared/utils/modules'
import type { Filter } from '~/types'

const iconsMap = {
Expand Down Expand Up @@ -42,8 +43,28 @@ export const moduleIcon = function (category: string) {
export const useModules = () => {
const route = useRoute()
const router = useRouter()
const { data, execute } = useFetch('/api/v1/modules', {

const versions: Filter[] = [
{ key: '3', label: 'Nuxt 3' },
{ key: '4', label: 'Nuxt 4' },
{ key: 'all', label: 'All' }
]

const selectedVersion = computed(() => {
const raw = route.query.version
const key = raw == null || raw === ''
? undefined
: String(Array.isArray(raw) ? raw[0] : raw)
return versions.find(version => version.key === key) || versions[0]
})

// Always fetch the full catalog once, then filter by major on the client.
// Production still rejects `version=4`, and switching API version clears the
// list during refetch (empty flash / stuck empty after hydration).
const { data, execute, status } = useFetch('/api/v1/modules', {
key: 'modules-catalog',
immediate: false,
query: { version: 'all' },
default: () => ({
modules: [],
stats: {
Expand All @@ -61,7 +82,12 @@ export const useModules = () => {

// Data fetching
async function fetchList() {
if (modules.value.length) {
if (status.value === 'pending') {
return
}
// Re-fetch when the result is empty. Large `/api/v1/modules` responses may
// not hydrate into the client payload, leaving status=success with [].
if (status.value === 'success' && (data.value?.modules?.length ?? 0) > 0) {
return
}

Expand Down Expand Up @@ -89,7 +115,14 @@ export const useModules = () => {
key: category,
label: category,
active: route.query.category === category,
to: { name: 'modules', query: category === route.query.category ? undefined : { category }, state: { smooth: '#smooth' } },
to: {
name: 'modules',
query: {
...route.query,
category: category === route.query.category ? undefined : category
},
state: { smooth: '#smooth' }
},
icon: iconsMap[category as keyof typeof iconsMap] || undefined,
click: (e: Event) => {
if (route.query.category !== category) {
Expand Down Expand Up @@ -135,8 +168,16 @@ export const useModules = () => {
}

const filteredModules = computed<Module[]>(() => {
const versionKey = selectedVersion.value?.key
const versionMajor = versionKey === '3' || versionKey === '4'
? Number(versionKey) as 3 | 4
: null

let filteredModules = [...modules.value]
.filter((module: Module) => {
if (versionMajor && !moduleSupportsNuxt(module.compatibility?.nuxt, versionMajor)) {
return false
}
if (selectedCategory.value) {
if (selectedCategory.value.key === 'Official') {
return module.type === 'official'
Expand Down Expand Up @@ -175,7 +216,7 @@ export const useModules = () => {
// Data fetching
fetchList,
// Data
// versions,
versions,
sorts,
orders,
// Computed
Expand All @@ -184,12 +225,8 @@ export const useModules = () => {
filteredModules,
module,
categories,
// types,
// contributors,
// stats,
selectedCategory,
// selectedType,
// selectedVersion,
selectedVersion,
selectedSort,
selectedOrder,
q
Expand Down
24 changes: 20 additions & 4 deletions app/pages/modules/[slug].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Module } from '#shared/types'
import { getModuleNuxtMajors, moduleSupportsNuxt } from '#shared/utils/modules'
import { ModuleProseA, ModuleProseKbd, ModuleProseImg } from '#components'

definePageMeta({
Expand All @@ -13,6 +14,12 @@ if (!module.value) {
throw createError({ statusCode: 404, statusMessage: 'Module not found', fatal: true })
}

const nuxtMajors = computed(() => getModuleNuxtMajors(module.value?.compatibility?.nuxt))
const isLegacyNuxtModule = computed(() => {
const range = module.value?.compatibility?.nuxt
return !moduleSupportsNuxt(range, 3) && !moduleSupportsNuxt(range, 4)
})

const ownerName = computed(() => {
const [owner, name] = module.value!.repo.split('#')[0].split('/')
return `${owner}/${name}`
Expand Down Expand Up @@ -95,11 +102,11 @@ if (import.meta.server) {

<template>
<UContainer v-if="module">
<div v-if="!module.compatibility?.nuxt?.includes('^3') && !module.compatibility?.nuxt?.includes('>=3')" class="pt-8">
<div v-if="isLegacyNuxtModule" class="pt-8">
<UAlert
icon="i-lucide-triangle-alert"
variant="subtle"
title="This module is not yet compatible with Nuxt 3"
title="This module is not compatible with Nuxt 3 or Nuxt 4"
>
<template #description>
Head over to <NuxtLink to="https://v2.nuxt.com" target="_blank" class="underline">
Expand All @@ -122,12 +129,21 @@ if (import.meta.server) {
class="-m-[4px] rounded-none bg-transparent"
/>

<div>
{{ module.npm }}
<div class="flex flex-wrap items-center gap-2">
<span>{{ module.npm }}</span>

<UTooltip v-if="module.type === 'official'" text="Official module" class="tracking-normal">
<UIcon name="i-lucide-medal" class="size-6 text-primary" />
</UTooltip>

<UBadge
v-for="major in nuxtMajors"
:key="major"
:label="`Nuxt ${major}`"
color="neutral"
variant="subtle"
size="sm"
/>
</div>
</div>
</template>
Expand Down
28 changes: 22 additions & 6 deletions app/pages/modules/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const modulesToAdd = ref<Module[]>([])
const el = useTemplateRef<HTMLElement>('el')

const { replaceRoute } = useFilters('modules')
const { fetchList, filteredModules, q, categories, modules, stats, selectedSort, selectedOrder, selectedCategory, sorts } = useModules()
const { fetchList, filteredModules, q, categories, modules, stats, selectedSort, selectedOrder, selectedCategory, selectedVersion, sorts, versions } = useModules()
const { track } = useAnalytics()

const cacheControl = useResponseHeader('Cache-Control')
Expand Down Expand Up @@ -95,9 +95,8 @@ watch(scrollY, (y) => {

watch(filteredModules, () => {
isLoading.value = false
displayedModules.value = []
initializeModules()
})
}, { immediate: true })

const copyAllInstallCommands = () => {
const moduleNames = modulesToAdd.value.map(module => module.name).join(' ')
Expand Down Expand Up @@ -137,8 +136,6 @@ Steps:
const clearAllModules = () => {
modulesToAdd.value = []
}

initializeModules()
</script>

<template>
Expand All @@ -155,7 +152,7 @@ initializeModules()
}"
>
<template #title>
Build faster with <span class="text-primary">{{ modules.length }}+</span> Nuxt Modules
Build faster with <span class="text-primary">{{ filteredModules.length }}</span> Nuxt Modules
</template>

<template #description>
Expand Down Expand Up @@ -192,6 +189,16 @@ initializeModules()
</UInput>

<div class="hidden sm:flex gap-2 sm:w-auto">
<USelectMenu
:model-value="selectedVersion"
:items="versions"
size="lg"
color="neutral"
class="w-auto"
variant="outline"
@update:model-value="replaceRoute('version', $event)"
/>

<USelectMenu
:model-value="selectedSort"
:items="sorts"
Expand Down Expand Up @@ -234,6 +241,15 @@ initializeModules()
aria-label="Clear category filter"
@click="replaceRoute('category', '')"
/>
<USelectMenu
:model-value="selectedVersion"
:items="versions"
size="lg"
color="neutral"
class="w-auto"
variant="outline"
@update:model-value="replaceRoute('version', $event)"
/>
<USelectMenu
:model-value="selectedSort"
:items="sorts"
Expand Down
2 changes: 1 addition & 1 deletion content/modules.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
title: Nuxt Modules
navigation.title: Modules
description: 'Discover our list of modules to supercharge your Nuxt project. Created by the Nuxt team and community.'
description: 'Discover Nuxt modules for your project. Filter by Nuxt 3 or Nuxt 4. Each card shows version badges from the module''s declared compatibility.nuxt range.'
navigation.icon: i-lucide-package
links:
- label: 'Create a Nuxt module'
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"ofetch": "^1.5.1",
"resend": "^6.17.2",
"scule": "^1.3.0",
"semver": "^7.8.5",
"shaders": "^2.5.135",
"sitemap": "^9.0.1",
"std-env": "^4.2.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 8 additions & 11 deletions server/api/v1/modules/index.get.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import { z } from 'zod'
import { satisfies } from 'semver'
import { moduleSupportsNuxt } from '#shared/utils/modules'
import { modulesCacheKey, normalizeModulesQuery } from '#shared/utils/modules-query'

export default defineCachedEventHandler(async (event) => {
const { version, category } = await getValidatedQuery(event, z.object({
version: z.enum(['2', '2-bridge', '3', 'all']).default('3'),
category: z.string().optional()
}).parse)
const { version, category } = await getValidatedQuery(event, query =>
normalizeModulesQuery(query as Record<string, unknown>)
)
console.log(`Fetching v${version} modules...${category ? ` for category: ${category}` : ''}`)

let modules = await fetchModules(event) || []

if (version !== 'all') {
const major = (version === '2-bridge' ? '2' : version) satisfies '2' | '3'
const testableVersion = `${major}.999.999`
const major = (version === '2-bridge' ? 2 : Number(version)) as 2 | 3 | 4

// Filter out modules by compatibility
modules = modules.filter((module) => {
// Nuxt 2 + bridge
if (version === '2-bridge' && !module.compatibility.requires?.bridge) {
return false
}
return satisfies(testableVersion, module.compatibility.nuxt)
return moduleSupportsNuxt(module.compatibility.nuxt, major)
})
}

Expand Down Expand Up @@ -101,8 +99,7 @@ export default defineCachedEventHandler(async (event) => {
name: 'modules',
swr: true,
getKey(event) {
const query = getQuery(event)
return `${query?.version || '3'}-${query?.category || 'all'}`
return modulesCacheKey(getQuery(event) as Record<string, unknown>)
},
maxAge: 60 * 60 // 1 hour
})
6 changes: 4 additions & 2 deletions server/mcp/tools/modules/get-module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import type { Module } from '#shared/types'
import { getModuleNuxtMajors } from '#shared/utils/modules'

export default defineMcpTool({
description: `Retrieves complete details about a specific Nuxt module including README, compatibility, maintainers, and stats.
Expand All @@ -8,9 +9,9 @@ WHEN TO USE: Use this tool when you know the EXACT module identifier. Common sce
- User asks for a specific module: "Get details about @nuxt/ui"
- User mentions a known module: "Show me nuxt-icon module"
- You found a relevant module from list_modules and want full details
- You need to check Nuxt 4 compatibility for a specific module
- You need to check Nuxt 4 compatibility for a specific module (see compatibility.nuxt and nuxtMajors)

WHEN NOT TO USE: If you don't know the exact module identifier and need to search/discover modules, use list_modules first.
WHEN NOT TO USE: If you don't know the exact module identifier and need to search/discover modules, use list_modules first (pass version "4" to filter the catalog).

PARAMETER: slug (required) - The unique module identifier
EXAMPLES:
Expand Down Expand Up @@ -63,6 +64,7 @@ EXAMPLES:
category: module.category,
type: module.type,
compatibility: module.compatibility,
nuxtMajors: getModuleNuxtMajors(module.compatibility?.nuxt),
stats: module.stats ? { downloads: module.stats.downloads, stars: module.stats.stars } : undefined,
readme,
url: `https://nuxt.com/modules/${module.name}`
Expand Down
Loading