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
58 changes: 58 additions & 0 deletions app/components/OgImage/UserProfile.takumi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script setup lang="ts">
const { username } = defineProps<{
username?: string
}>()

const total = shallowRef(0)

if (username && isValidNpmName(username)) {
let algoliaTotal = 0
try {
const { search } = useAlgoliaSearch()
const algolia = await search('', { filters: `owners.name:${username}`, size: 1 })
algoliaTotal = algolia.total
} catch {
// Algolia unavailable — fall through to the npm-registry lookup below.
}

if (algoliaTotal > 0) {
total.value = algoliaTotal
} else {
// Fall back to the npm registry's `maintainer:` search (matching the page's
// provider order) when Algolia is empty or failed.
const npm = await $fetch<{ total?: number }>('https://registry.npmjs.org/-/v1/search', {
params: { text: `maintainer:${username}`, size: 1 },
timeout: 2500,
}).catch(() => null)
total.value = npm?.total ?? 0
}
}

const description = computed(() =>
username ? `${total.value} package${total.value === 1 ? '' : 's'}` : 'npm user profile',
)
</script>

<template>
<OgLayout>
<div class="px-15 py-12 flex flex-col justify-center gap-12 h-full">
<OgBrand :height="48" />

<div v-if="username" class="flex flex-col max-w-full gap-3">
<div
class="lg:text-7xl text-5xl tracking-tighter font-mono leading-none"
:style="{ lineClamp: 1, textOverflow: 'ellipsis' }"
>
{{ `~${username}` }}
</div>
</div>

<div
class="pt-3 lg:text-4xl text-3xl opacity-70"
:style="{ lineClamp: 2, textOverflow: 'ellipsis' }"
>
{{ description }}
</div>
</div>
</OgLayout>
</template>
5 changes: 2 additions & 3 deletions app/pages/~[username]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,9 @@ useSeoMeta({
})

defineOgImage(
'Page.takumi',
'UserProfile.takumi',
{
title: () => `~${username.value}`,
description: () => (results.value ? `${results.value.total} packages` : 'npm user profile'),
username: () => username.value,
},
{ alt: () => `~${username.value} npm user profile on npmx` },
)
Expand Down
165 changes: 165 additions & 0 deletions test/nuxt/components/OgImageUserProfile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUseAlgoliaSearch, mockSearch } = vi.hoisted(() => {
const search = vi.fn()
return {
mockSearch: search,
mockUseAlgoliaSearch: vi.fn(() => ({ search })),
}
})

mockNuxtImport('useAlgoliaSearch', () => mockUseAlgoliaSearch)

import OgImageUserProfile from '~/components/OgImage/UserProfile.takumi.vue'

/** Build the minimal NpmSearchResponse shape the component reads (`total`). */
function searchResponse(total: number) {
return { isStale: false, objects: [], total, time: '' }
}

/** Spy on the global `$fetch` used for the npm-registry fallback. */
function mockNpmFallback(total: number | null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(globalThis, '$fetch').mockImplementation((() =>
total === null ? Promise.reject(new Error('network')) : Promise.resolve({ total })) as any)
}

describe('OgImageUserProfile', () => {
beforeEach(() => {
vi.restoreAllMocks()
mockSearch.mockReset()
})

it('renders the username with a tilde prefix', async () => {
mockSearch.mockResolvedValue(searchResponse(9))

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'houtan-rocky' },
})

expect(component.text()).toContain('~houtan-rocky')
})

it('shows the Algolia package count', async () => {
mockSearch.mockResolvedValue(searchResponse(9))

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'houtan-rocky' },
})

expect(component.text()).toContain('9 packages')
// Algolia had results, so the npm fallback must not run.
expect(mockSearch).toHaveBeenCalledWith('', { filters: 'owners.name:houtan-rocky', size: 1 })
})

it('queries Algolia by the maintainer, not the raw username field', async () => {
mockSearch.mockResolvedValue(searchResponse(3))

await mountSuspended(OgImageUserProfile, { props: { username: 'sindresorhus' } })

expect(mockSearch).toHaveBeenCalledWith('', {
filters: 'owners.name:sindresorhus',
size: 1,
})
})

it('uses the singular noun for a single package', async () => {
mockSearch.mockResolvedValue(searchResponse(1))

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'solo' },
})

expect(component.text()).toContain('1 package')
expect(component.text()).not.toContain('1 packages')
})

it('falls back to the npm registry when Algolia returns zero', async () => {
mockSearch.mockResolvedValue(searchResponse(0))
mockNpmFallback(11)

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'npm-only' },
})

expect($fetch).toHaveBeenCalledWith(
'https://registry.npmjs.org/-/v1/search',
expect.objectContaining({ params: { text: 'maintainer:npm-only', size: 1 } }),
)
expect(component.text()).toContain('11 packages')
})

it('shows zero packages when both providers are empty', async () => {
mockSearch.mockResolvedValue(searchResponse(0))
mockNpmFallback(0)

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'nobody' },
})

expect(component.text()).toContain('0 packages')
})

it('degrades to zero packages when the npm fallback rejects', async () => {
mockSearch.mockResolvedValue(searchResponse(0))
mockNpmFallback(null)

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'flaky' },
})

expect(component.text()).toContain('0 packages')
})

it('falls back to the npm registry when Algolia throws', async () => {
mockSearch.mockRejectedValue(new Error('algolia down'))
mockNpmFallback(11)

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'houtan-rocky' },
})

expect(component.text()).toContain('~houtan-rocky')
expect($fetch).toHaveBeenCalledWith(
'https://registry.npmjs.org/-/v1/search',
expect.objectContaining({ params: { text: 'maintainer:houtan-rocky', size: 1 } }),
)
expect(component.text()).toContain('11 packages')
})

it('degrades to zero packages when both providers fail', async () => {
mockSearch.mockRejectedValue(new Error('algolia down'))
mockNpmFallback(null)

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'houtan-rocky' },
})

expect(component.text()).toContain('0 packages')
})

it('skips the lookups for an invalid username', async () => {
// Rejecting mock guards against a real network call if the skip path regresses.
mockNpmFallback(null)

const component = await mountSuspended(OgImageUserProfile, {
props: { username: 'not a valid name!' },
})

expect(mockSearch).not.toHaveBeenCalled()
expect($fetch).not.toHaveBeenCalled()
expect(component.text()).toContain('0 packages')
})

it('renders a generic description and skips fetching when no username is given', async () => {
// Rejecting mock guards against a real network call if the skip path regresses.
mockNpmFallback(null)

const component = await mountSuspended(OgImageUserProfile, { props: {} })

expect(component.text()).toContain('npm user profile')
expect(mockSearch).not.toHaveBeenCalled()
expect($fetch).not.toHaveBeenCalled()
})
})
2 changes: 2 additions & 0 deletions test/unit/a11y-component-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const SKIPPED_COMPONENTS: Record<string, string> = {
'OgImage/Page.takumi.vue': 'OG Image component - server-rendered image, not interactive UI',
'OgImage/Profile.takumi.vue': 'OG Image component - server-rendered image, not interactive UI',
'OgImage/Splash.takumi.vue': 'OG Image component - server-rendered image, not interactive UI',
'OgImage/UserProfile.takumi.vue':
'OG Image component - server-rendered image, not interactive UI',

// Client-only components with complex dependencies
'Header/AuthModal.client.vue': 'Complex auth modal with navigation - requires full app context',
Expand Down
Loading