Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
806e120
feat: add OG image for compare pages
Adebesin-Cell Mar 25, 2026
663f2d4
chore: remove screenshot assets from repo
Adebesin-Cell Mar 25, 2026
fdd8983
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2026
2758f20
feat: show live download bars and versions in compare OG image
Adebesin-Cell Mar 25, 2026
9adfedc
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 25, 2026
75f2bf5
fix: mark packages prop as optional
Adebesin-Cell Mar 25, 2026
e3bdf73
fix: handle empty state in compare OG image
Adebesin-Cell Mar 25, 2026
ddbeefa
fix: use translated description for empty compare OG image
Adebesin-Cell Mar 25, 2026
8dad8ae
fix: skip a11y test for OgImage/Compare (server-rendered image)
Adebesin-Cell Mar 25, 2026
9fcab62
fix(ui): increase visual prominence of download numbers in compare OG…
Adebesin-Cell Mar 26, 2026
7770052
fix: add fetch timeouts and boost download number prominence
Adebesin-Cell Mar 26, 2026
28ca4d3
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
f518e4a
fix: normalise array package input and fix zero-download bar width
Adebesin-Cell Mar 26, 2026
838cf32
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
eac610a
fix: truncate long package names in compare OG image
Adebesin-Cell Mar 26, 2026
8d77d56
Merge branch 'main' into feat/compare-og-image
Adebesin-Cell Mar 26, 2026
0274948
Merge remote-tracking branch 'upstream/main' into feat/compare-og-image
Adebesin-Cell Apr 1, 2026
de10674
feat: add tiered OG image layouts for compare page (up to 10 packages)
Adebesin-Cell Apr 1, 2026
793f1b4
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 1, 2026
63db575
fix: sort OG packages by downloads and match icon to other OG images
Adebesin-Cell Apr 1, 2026
494b3d2
feat: add versions to all tiers, sort by downloads, add ./npmx branding
Adebesin-Cell Apr 1, 2026
6019761
Merge branch 'main' into feat/compare-og-image
graphieros Apr 6, 2026
29b0833
fix: improve OG image text visibility and fix lint issues
Adebesin-Cell Apr 6, 2026
8e0d98e
fix: use RTL-safe inset-ie-20 instead of right-20 in OG image
Adebesin-Cell Apr 6, 2026
935c48b
fix: truncate long package names in compare OG image
Adebesin-Cell Apr 6, 2026
6d5da40
fix: improve OG grid layout with proper Satori flex gap and truncation
Adebesin-Cell Apr 6, 2026
4e18cb6
fix: remove unused CONTENT_WIDTH and OG_PADDING_X constants
Adebesin-Cell Apr 6, 2026
310692a
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 6, 2026
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
181 changes: 181 additions & 0 deletions app/components/OgImage/Compare.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { encodePackageName } from '#shared/utils/npm'

const props = withDefaults(
defineProps<{
packages?: string | string[]
emptyDescription?: string
primaryColor?: string
}>(),
{
packages: () => [],
emptyDescription: 'Compare npm packages side-by-side',
primaryColor: '#60a5fa',
},
)
Comment thread
Adebesin-Cell marked this conversation as resolved.

const ACCENT_COLORS = ['#60a5fa', '#f472b6', '#34d399', '#fbbf24']

const displayPackages = computed(() => {
const raw = props.packages
const list =
typeof raw === 'string'
? raw
.split(',')
.map(p => p.trim())
.filter(Boolean)
: raw
return list.slice(0, 4)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface PkgStats {
name: string
downloads: number
version: string
color: string
}

const stats = ref<PkgStats[]>([])

const FETCH_TIMEOUT_MS = 2500

try {
const results = await Promise.all(
displayPackages.value.map(async (name, index) => {
const encoded = encodePackageName(name)
const [dlData, pkgData] = await Promise.all([
$fetch<{ downloads: number }>(
`https://api.npmjs.org/downloads/point/last-week/${encoded}`,
{ timeout: FETCH_TIMEOUT_MS },
).catch(() => null),
$fetch<{ 'dist-tags'?: { latest?: string } }>(`https://registry.npmjs.org/${encoded}`, {
timeout: FETCH_TIMEOUT_MS,
headers: { Accept: 'application/vnd.npm.install-v1+json' },
}).catch(() => null),
])
return {
name,
downloads: dlData?.downloads ?? 0,
version: pkgData?.['dist-tags']?.latest ?? '',
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
}
}),
)
stats.value = results
} catch {
stats.value = displayPackages.value.map((name, index) => ({
name,
downloads: 0,
version: '',
color: ACCENT_COLORS[index % ACCENT_COLORS.length]!,
}))
}

const maxDownloads = computed(() => Math.max(...stats.value.map(s => s.downloads), 1))

function formatDownloads(n: number): string {
if (n === 0) return '—'
return Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(n)
}

// Bar width as percentage string (max 100%)
function barPct(downloads: number): string {
const pct = (downloads / maxDownloads.value) * 100
return `${Math.max(pct, 5)}%`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</script>

<template>
<div
class="h-full w-full flex flex-col justify-center px-20 bg-[#050505] text-[#fafafa] relative overflow-hidden"
style="font-family: 'Geist Mono', sans-serif"
>
<div class="relative z-10 flex flex-col gap-5">
<!-- Icon + title row -->
<div class="flex items-start gap-4">
<div
class="flex items-center justify-center w-14 h-14 p-3 rounded-xl shadow-lg bg-gradient-to-tr from-[#3b82f6]"
:style="{ backgroundColor: primaryColor }"
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
<path d="M11 18H8a2 2 0 0 1-2-2V9" />
</svg>
</div>

<h1 class="text-7xl font-bold tracking-tight">
<span
class="opacity-80 tracking-[-0.1em]"
:style="{ color: primaryColor }"
style="margin-right: 0.25rem"
>./</span
>compare
</h1>
</div>

<!-- Empty state -->
<div
v-if="stats.length === 0"
class="text-4xl text-[#a3a3a3]"
style="font-family: 'Geist', sans-serif"
>
{{ emptyDescription }}
</div>

<!-- Bar chart rows -->
<div v-else class="flex flex-col gap-2">
<div v-for="pkg in stats" :key="pkg.name" class="flex flex-col gap-1">
<!-- Label row: name + downloads + version -->
<div class="flex items-center gap-3" style="font-family: 'Geist', sans-serif">
<span class="text-2xl font-semibold tracking-tight" :style="{ color: pkg.color }">
{{ pkg.name }}
</span>
<span class="text-3xl font-bold text-[#fafafa]">
{{ formatDownloads(pkg.downloads) }}/wk
</span>
<span
v-if="pkg.version"
class="text-lg px-2 py-0.5 rounded-md border"
:style="{
color: pkg.color,
backgroundColor: pkg.color + '10',
borderColor: pkg.color + '30',
}"
>
{{ pkg.version }}
</span>
</div>

<!-- Bar -->
<div
class="h-6 rounded-md"
:style="{
width: barPct(pkg.downloads),
background: `linear-gradient(90deg, ${pkg.color}50, ${pkg.color}20)`,
}"
/>
</div>
</div>
</div>

<div
class="absolute -top-32 -inset-ie-32 w-[550px] h-[550px] rounded-full blur-3xl"
:style="{ backgroundColor: primaryColor + '10' }"
/>
</div>
</template>
5 changes: 5 additions & 0 deletions app/pages/compare.vue
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ function exportComparisonDataAsMarkdown() {
copy(markdown)
}

defineOgImageComponent('Compare', {
packages: () => packages.value,
emptyDescription: () => $t('compare.packages.meta_description_empty'),
})

useSeoMeta({
title: () =>
packages.value.length > 0
Expand Down
1 change: 1 addition & 0 deletions test/unit/a11y-component-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const SKIPPED_COMPONENTS: Record<string, string> = {
'OgImage/BlogPost.vue': 'OG Image component - server-rendered image, not interactive UI',
'OgImage/Default.vue': 'OG Image component - server-rendered image, not interactive UI',
'OgImage/Package.vue': 'OG Image component - server-rendered image, not interactive UI',
'OgImage/Compare.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