Skip to content

Commit 5498313

Browse files
Adebesin-Cellclaudeautofix-ci[bot]graphieros
authored
feat: add OG image for compare pages (#2277)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Alec Lloyd Probert <55991794+graphieros@users.noreply.github.com>
1 parent 973b9c7 commit 5498313

File tree

4 files changed

+355
-10
lines changed

4 files changed

+355
-10
lines changed

app/components/OgImage/Compare.vue

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from 'vue'
3+
import { encodePackageName } from '#shared/utils/npm'
4+
5+
const props = withDefaults(
6+
defineProps<{
7+
packages?: string | string[]
8+
emptyDescription?: string
9+
primaryColor?: string
10+
}>(),
11+
{
12+
packages: () => [],
13+
emptyDescription: 'Compare npm packages side-by-side',
14+
primaryColor: '#60a5fa',
15+
},
16+
)
17+
18+
const ACCENT_COLORS = [
19+
'#60a5fa',
20+
'#f472b6',
21+
'#34d399',
22+
'#fbbf24',
23+
'#a78bfa',
24+
'#fb923c',
25+
'#22d3ee',
26+
'#e879f9',
27+
'#4ade80',
28+
'#f87171',
29+
'#38bdf8',
30+
'#facc15',
31+
]
32+
33+
// Tier thresholds
34+
const FULL_MAX = 4
35+
const COMPACT_MAX = 6
36+
const GRID_MAX = 12
37+
const SUMMARY_TOP_COUNT = 3
38+
39+
const displayPackages = computed(() => {
40+
const raw = props.packages
41+
return (typeof raw === 'string' ? raw.split(',') : raw).map(p => p.trim()).filter(Boolean)
42+
})
43+
44+
type LayoutTier = 'full' | 'compact' | 'grid' | 'summary'
45+
const layoutTier = computed<LayoutTier>(() => {
46+
const count = displayPackages.value.length
47+
if (count <= FULL_MAX) return 'full'
48+
if (count <= COMPACT_MAX) return 'compact'
49+
if (count <= GRID_MAX) return 'grid'
50+
return 'summary'
51+
})
52+
53+
interface PkgStats {
54+
name: string
55+
downloads: number
56+
version: string
57+
color: string
58+
}
59+
60+
const stats = ref<PkgStats[]>([])
61+
62+
const FETCH_TIMEOUT_MS = 2500
63+
64+
if (layoutTier.value !== 'summary') {
65+
try {
66+
const results = await Promise.all(
67+
displayPackages.value.map(async (name, index) => {
68+
const encoded = encodePackageName(name)
69+
const [dlData, pkgData] = await Promise.all([
70+
$fetch<{ downloads: number }>(
71+
`https://api.npmjs.org/downloads/point/last-week/${encoded}`,
72+
{ timeout: FETCH_TIMEOUT_MS },
73+
).catch(() => null),
74+
$fetch<{ 'dist-tags'?: { latest?: string } }>(`https://registry.npmjs.org/${encoded}`, {
75+
timeout: FETCH_TIMEOUT_MS,
76+
headers: { Accept: 'application/vnd.npm.install-v1+json' },
77+
}).catch(() => null),
78+
])
79+
return {
80+
name,
81+
downloads: dlData?.downloads ?? 0,
82+
version: pkgData?.['dist-tags']?.latest ?? '',
83+
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
84+
}
85+
}),
86+
)
87+
// Sort by downloads descending for readability
88+
stats.value = results.sort((a, b) => b.downloads - a.downloads)
89+
} catch {
90+
stats.value = displayPackages.value.map((name, index) => ({
91+
name,
92+
downloads: 0,
93+
version: '',
94+
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
95+
}))
96+
}
97+
}
98+
99+
const maxDownloads = computed(() => Math.max(...stats.value.map(s => s.downloads), 1))
100+
101+
function formatDownloads(n: number): string {
102+
if (n === 0) return ''
103+
return Intl.NumberFormat('en', {
104+
notation: 'compact',
105+
maximumFractionDigits: 1,
106+
}).format(n)
107+
}
108+
109+
const BAR_MIN_PCT = 5
110+
const BAR_MAX_PCT = 100
111+
112+
function barPct(downloads: number): string {
113+
if (downloads <= 0) return '0%'
114+
const pct = (downloads / maxDownloads.value) * 100
115+
return `${Math.min(BAR_MAX_PCT, Math.max(pct, BAR_MIN_PCT))}%`
116+
}
117+
118+
const summaryTopNames = computed(() => displayPackages.value.slice(0, SUMMARY_TOP_COUNT))
119+
const summaryRemainder = computed(() =>
120+
Math.max(0, displayPackages.value.length - SUMMARY_TOP_COUNT),
121+
)
122+
</script>
123+
124+
<template>
125+
<div
126+
class="h-full w-full flex flex-col justify-center relative overflow-hidden bg-[#050505] text-[#fafafa] px-20"
127+
style="font-family: 'Geist Mono', sans-serif"
128+
>
129+
<div class="relative z-10 flex flex-col gap-5">
130+
<!-- Icon + title row -->
131+
<div class="flex items-start gap-4">
132+
<div
133+
class="flex items-center justify-center w-16 h-16 p-3.5 rounded-xl shadow-lg"
134+
:style="{ background: `linear-gradient(to top right, #3b82f6, ${primaryColor})` }"
135+
>
136+
<svg
137+
width="36"
138+
height="36"
139+
viewBox="0 0 24 24"
140+
fill="none"
141+
stroke="white"
142+
stroke-width="2.5"
143+
stroke-linecap="round"
144+
stroke-linejoin="round"
145+
>
146+
<path d="m7.5 4.27 9 5.15" />
147+
<path
148+
d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
149+
/>
150+
<path d="m3.3 7 8.7 5 8.7-5" />
151+
<path d="M12 22V12" />
152+
</svg>
153+
</div>
154+
155+
<h1 class="text-7xl font-bold tracking-tight">
156+
<span
157+
class="opacity-80 tracking-[-0.1em]"
158+
:style="{ color: primaryColor }"
159+
style="margin-right: 0.25rem"
160+
>./</span
161+
>compare
162+
</h1>
163+
</div>
164+
165+
<!-- Empty state -->
166+
<div
167+
v-if="displayPackages.length === 0"
168+
class="text-4xl text-[#a3a3a3]"
169+
style="font-family: 'Geist', sans-serif"
170+
>
171+
{{ emptyDescription }}
172+
</div>
173+
174+
<!-- FULL layout (1-4 packages): name + downloads + version badge + bar -->
175+
<div v-else-if="layoutTier === 'full'" class="flex flex-col gap-2">
176+
<div v-for="pkg in stats" :key="pkg.name" class="flex flex-col gap-1">
177+
<div class="flex items-center gap-3" style="font-family: 'Geist', sans-serif">
178+
<span
179+
class="text-2xl font-semibold tracking-tight"
180+
:style="{
181+
color: pkg.color,
182+
maxWidth: '400px',
183+
overflow: 'hidden',
184+
textOverflow: 'ellipsis',
185+
whiteSpace: 'nowrap',
186+
}"
187+
>
188+
{{ pkg.name }}
189+
</span>
190+
<span
191+
v-if="pkg.version"
192+
class="text-lg px-2 py-0.5 rounded-md border"
193+
:style="{
194+
color: pkg.color,
195+
backgroundColor: pkg.color + '10',
196+
borderColor: pkg.color + '30',
197+
}"
198+
>
199+
{{ pkg.version }}
200+
</span>
201+
<span class="text-3xl font-bold text-[#fafafa]">
202+
{{ formatDownloads(pkg.downloads) }}/wk
203+
</span>
204+
</div>
205+
<div
206+
class="h-6 rounded-md"
207+
:style="{
208+
width: barPct(pkg.downloads),
209+
background: `linear-gradient(90deg, ${pkg.color}50, ${pkg.color}20)`,
210+
}"
211+
/>
212+
</div>
213+
</div>
214+
215+
<!-- COMPACT layout (5-6 packages): name + downloads + thinner bar, no version -->
216+
<div v-else-if="layoutTier === 'compact'" class="flex flex-col gap-2">
217+
<div v-for="pkg in stats" :key="pkg.name" class="flex flex-col gap-0.5">
218+
<div class="flex items-center gap-2" style="font-family: 'Geist', sans-serif">
219+
<span
220+
class="text-xl font-semibold tracking-tight"
221+
:style="{
222+
color: pkg.color,
223+
maxWidth: '300px',
224+
overflow: 'hidden',
225+
textOverflow: 'ellipsis',
226+
whiteSpace: 'nowrap',
227+
}"
228+
>
229+
{{ pkg.name }}
230+
</span>
231+
<span
232+
v-if="pkg.version"
233+
class="text-sm px-1.5 py-0.5 rounded border"
234+
:style="{
235+
color: pkg.color,
236+
backgroundColor: pkg.color + '10',
237+
borderColor: pkg.color + '30',
238+
}"
239+
>
240+
{{ pkg.version }}
241+
</span>
242+
<span class="text-xl font-bold text-[#fafafa]">
243+
{{ formatDownloads(pkg.downloads) }}/wk
244+
</span>
245+
</div>
246+
<div
247+
class="h-3 rounded-sm"
248+
:style="{
249+
width: barPct(pkg.downloads),
250+
background: `linear-gradient(90deg, ${pkg.color}50, ${pkg.color}20)`,
251+
}"
252+
/>
253+
</div>
254+
</div>
255+
256+
<!-- GRID layout (7-12 packages): flex-wrap grid -->
257+
<div
258+
v-else-if="layoutTier === 'grid'"
259+
:style="{
260+
display: 'flex',
261+
flexWrap: 'wrap',
262+
rowGap: 24,
263+
columnGap: 40,
264+
fontFamily: 'Geist, sans-serif',
265+
}"
266+
>
267+
<span
268+
v-for="pkg in stats"
269+
:key="pkg.name"
270+
:style="{
271+
display: 'flex',
272+
flexDirection: 'column',
273+
gap: 2,
274+
width: '220px',
275+
}"
276+
>
277+
<span
278+
class="font-semibold tracking-tight"
279+
:style="{
280+
fontSize: '18px',
281+
maxWidth: '220px',
282+
overflow: 'hidden',
283+
textOverflow: 'ellipsis',
284+
whiteSpace: 'nowrap',
285+
color: pkg.color,
286+
}"
287+
>{{ pkg.name }}</span
288+
>
289+
<span :style="{ display: 'flex', alignItems: 'baseline', gap: 2 }">
290+
<span class="text-2xl font-bold text-[#e5e5e5]">{{
291+
formatDownloads(pkg.downloads)
292+
}}</span>
293+
<span class="text-sm font-medium text-[#d4d4d4]">/wk</span>
294+
</span>
295+
</span>
296+
</div>
297+
298+
<!-- SUMMARY layout (13+ packages): package count + top names -->
299+
<div v-else class="flex flex-col gap-3" style="font-family: 'Geist', sans-serif">
300+
<div class="text-2xl text-[#a3a3a3]">
301+
<span class="text-4xl font-bold text-[#fafafa]">{{ displayPackages.length }}</span>
302+
packages
303+
</div>
304+
<div :style="{ display: 'flex', alignItems: 'baseline', gap: 8, whiteSpace: 'nowrap' }">
305+
<span
306+
v-for="(name, i) in summaryTopNames"
307+
:key="name"
308+
class="text-xl font-semibold"
309+
:style="{
310+
color: ACCENT_COLORS[i % ACCENT_COLORS.length],
311+
maxWidth: '280px',
312+
overflow: 'hidden',
313+
textOverflow: 'ellipsis',
314+
whiteSpace: 'nowrap',
315+
flexShrink: 1,
316+
}"
317+
>{{ name }}{{ i < summaryTopNames.length - 1 ? ',' : '' }}</span
318+
>
319+
<span v-if="summaryRemainder > 0" class="text-xl text-[#737373]">
320+
+{{ summaryRemainder }} more
321+
</span>
322+
</div>
323+
</div>
324+
</div>
325+
326+
<!-- Branding -->
327+
<div
328+
class="absolute bottom-6 inset-ie-20 text-lg font-semibold tracking-tight text-[#525252]"
329+
style="font-family: 'Geist Mono', sans-serif"
330+
>
331+
<span :style="{ color: primaryColor }" class="opacity-80 tracking-[-0.1em]">./</span>npmx
332+
</div>
333+
334+
<div
335+
class="absolute -top-32 -inset-ie-32 w-[550px] h-[550px] rounded-full blur-3xl"
336+
:style="{ backgroundColor: primaryColor + '10' }"
337+
/>
338+
</div>
339+
</template>

app/pages/compare.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ async function exportComparisonDataAsMarkdown() {
138138
await copy(markdown)
139139
}
140140
141+
defineOgImageComponent('Compare', {
142+
packages: () => packages.value,
143+
emptyDescription: () => $t('compare.packages.meta_description_empty'),
144+
})
145+
141146
const { announce } = useCommandPalette()
142147
143148
useCommandPaletteContextCommands(

test/nuxt/components/compare/FacetSelector.spec.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ vi.mock('@vueuse/router', () => ({
100100
useRouteQuery: () => ref(''),
101101
}))
102102

103+
function findCategoryActionButton(
104+
component: Awaited<ReturnType<typeof mountSuspended>>,
105+
category: string,
106+
action: 'all' | 'none',
107+
) {
108+
return component.find(
109+
`button[data-facet-category="${category}"][data-facet-category-action="${action}"]`,
110+
)
111+
}
112+
103113
describe('FacetSelector', () => {
104114
beforeEach(() => {
105115
mockSelectedFacets.value = ['downloads', 'types']
@@ -232,16 +242,6 @@ describe('FacetSelector', () => {
232242
})
233243

234244
describe('category all/none buttons', () => {
235-
function findCategoryActionButton(
236-
component: Awaited<ReturnType<typeof mountSuspended>>,
237-
category: string,
238-
action: 'all' | 'none',
239-
) {
240-
return component.find(
241-
`button[data-facet-category="${category}"][data-facet-category-action="${action}"]`,
242-
)
243-
}
244-
245245
it('calls selectCategory when all button is clicked', async () => {
246246
const component = await mountSuspended(FacetSelector)
247247

test/unit/a11y-component-coverage.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const SKIPPED_COMPONENTS: Record<string, string> = {
2525
'OgImage/BlogPost.vue': 'OG Image component - server-rendered image, not interactive UI',
2626
'OgImage/Default.vue': 'OG Image component - server-rendered image, not interactive UI',
2727
'OgImage/Package.vue': 'OG Image component - server-rendered image, not interactive UI',
28+
'OgImage/Compare.vue': 'OG Image component - server-rendered image, not interactive UI',
2829

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

0 commit comments

Comments
 (0)