Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0faa842
feat: build the basic share card flow
ShroXd Mar 23, 2026
d9cb131
fix: tweaks share card UI
ShroXd Mar 20, 2026
1ce2e9e
fix: tweaks image size
ShroXd Mar 20, 2026
a2200d0
fix: tweaks image size
ShroXd Mar 20, 2026
68ab447
fix: tweaks size and layout
ShroXd Mar 20, 2026
3d43377
fix: tweaks download graph
ShroXd Mar 20, 2026
69b01ee
fix: use common color
ShroXd Mar 20, 2026
77b4713
faet: disable button during rendering
ShroXd Mar 20, 2026
bf24544
refactor: use predefined color value
ShroXd Mar 20, 2026
8853339
fix: fix primary color
ShroXd Mar 20, 2026
5ac51f3
feat: remove all dividers
ShroXd Mar 20, 2026
61f6715
feat: tweaks card design
ShroXd Mar 20, 2026
3266233
fix: remove ./ from title
ShroXd Mar 20, 2026
5ad08c7
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 20, 2026
f1ba967
refactor: tweaks styles
ShroXd Mar 21, 2026
ccc2508
feat: tweaks font and weight
ShroXd Mar 21, 2026
c7408b0
refactor: reuse formatter and move withAlpha to utils
ShroXd Mar 21, 2026
d70d8f4
refactor: avoid type assertion and any
ShroXd Mar 21, 2026
9d78cd3
fix: fix og iamge cache
ShroXd Mar 28, 2026
eb25f04
refactor: refactor the single char variable name
ShroXd Mar 21, 2026
1e0e725
refactor: tweaks button position
ShroXd Mar 21, 2026
1eca0b8
refactor: use predefined color token
ShroXd Mar 21, 2026
09db9a2
refactor: optimize types
ShroXd Mar 21, 2026
28dd0fb
fix: cache theme and color for share card
ShroXd Mar 28, 2026
87681bc
fix: remove font family fallback
ShroXd Mar 23, 2026
6940d33
fix: follow existing pattern to add ShareCard.d.vue.ts
ShroXd Mar 23, 2026
2718b5b
test: add e2e test for share card
ShroXd Mar 23, 2026
0647a23
test: update e2e test snapshot
ShroXd Mar 23, 2026
efc589e
test: update snapshots
ShroXd Mar 23, 2026
714e1f9
fix: use inline css for satori
ShroXd Mar 27, 2026
c5fa1c5
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 27, 2026
33b28dc
test: update snapshots for share card
ShroXd Mar 27, 2026
97fe246
test: add share card and share modal to a11y test
ShroXd Mar 27, 2026
45c491a
fix: follow the same pattern of edge with package page
ShroXd Mar 28, 2026
1a61975
refactor: change network order and edge cases handling
ShroXd Mar 28, 2026
a27daa1
refactor: refactor some code smell
ShroXd Mar 28, 2026
727b046
refactor: add skeleton and tweaks network request logic
ShroXd Mar 28, 2026
0470463
refactor: clean up code comment and any
ShroXd Mar 29, 2026
6040dfb
refactor: remove useless tokens
ShroXd Mar 29, 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
13 changes: 1 addition & 12 deletions app/components/OgImage/BlogPost.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,7 @@ const props = withDefaults(
},
)

const formattedDate = computed(() => {
if (!props.date) return ''
try {
return new Date(props.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return props.date
}
})
const formattedDate = computed(() => formatDate(props.date))

const MAX_VISIBLE_AUTHORS = 2

Expand Down
17 changes: 17 additions & 0 deletions app/components/OgImage/ShareCard.d.vue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// This type declaration file is required to break a circular type resolution in vue-tsc.
// And is based off Package.d.vue.ts
//
// nuxt-og-image generates a type declaration (.nuxt/module/nuxt-og-image.d.ts) that imports
// this component's type. This creates a cycle: nuxt.d.ts β†’ nuxt-og-image.d.ts β†’ ShareCard.vue β†’
// needs auto-import globals from nuxt.d.ts. Without this file, vue-tsc resolves the component
// before the globals are available, so all auto-imports (computed, toRefs, useFetch, etc.) fail.

import type { DefineComponent } from 'vue'

declare const _default: DefineComponent<{
name: string
theme?: 'light' | 'dark'
color?: string
}>

export default _default
331 changes: 331 additions & 0 deletions app/components/OgImage/ShareCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ACCENT_COLOR_TOKENS, SHARE_CARD_THEMES } from '#shared/utils/constants'
import type { AccentColorId } from '#shared/utils/constants'

const props = withDefaults(
defineProps<{
name: string
theme?: 'light' | 'dark'
color?: AccentColorId
}>(),
{ theme: 'dark' },
)

const theme = computed(() => SHARE_CARD_THEMES[props.theme])
const primaryColor = computed(() => ACCENT_COLOR_TOKENS[props.color ?? 'sky'][props.theme].hex)

const compactFormatter = useCompactNumberFormatter()
const bytesFormatter = useBytesFormatter()

const { data: resolvedVersion } = await useResolvedVersion(
computed(() => props.name),
null,
)
const { data: pkg, refresh: refreshPkg } = usePackage(
computed(() => props.name),
() => resolvedVersion.value ?? null,
)
const { data: downloads, refresh: refreshDownloads } = usePackageDownloads(
computed(() => props.name),
'last-week',
)
const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
const { repositoryUrl } = useRepositoryUrl(displayVersion)
const { stars, forks, repoRef, refresh: refreshRepoMeta } = useRepoMeta(repositoryUrl)

try {
await Promise.all([refreshPkg(), refreshDownloads()])
await refreshRepoMeta()
} catch (err) {
console.warn('[share-card] Failed to load data server-side:', err)
}

const version = computed(() => resolvedVersion.value ?? pkg.value?.['dist-tags']?.latest ?? '')
const isLatest = computed(() => pkg.value?.['dist-tags']?.latest === version.value)
const description = computed(() => pkg.value?.description || 'No description.')
const license = computed(() => pkg.value?.license ?? '')
const hasTypes = computed(() =>
Boolean(displayVersion.value?.types || displayVersion.value?.typings),
)
const moduleFormat = computed(() => (displayVersion.value?.type === 'module' ? 'ESM' : 'CJS'))
const depsCount = computed(() => Object.keys(displayVersion.value?.dependencies ?? {}).length)
const unpackedSize = computed(() => displayVersion.value?.dist?.unpackedSize ?? null)
const publishedAt = computed(() => pkg.value?.time?.[version.value] ?? '')
const weeklyDownloads = computed(() => downloads.value?.downloads ?? 0)
const repoSlug = computed(() => {
const ref = repoRef.value
if (!ref) return ''
return truncate(`${ref.owner}/${ref.repo}`, 26)
})

const fontSans = "'Geist'"
const fontMono = "'Geist Mono'"
</script>

<template>
<!-- Rendered at 1280Γ—520 (2.46:1). -->
<!-- Icons inlined as SVG: satori cannot render CSS mask-image/background-image icons (i-lucide-*) -->
<div
class="h-full w-full flex flex-col"
:style="{
backgroundColor: theme.bg,
color: theme.text,
fontFamily: fontSans,
}"
>
<!-- ── Main content ─────────────────────────────────────────────── -->
<div class="flex flex-row flex-1 overflow-hidden">
<!-- Content column -->
<div class="flex flex-col flex-1 overflow-hidden justify-between">
<!-- Top content -->
<div
class="flex flex-col"
style="padding-top: 2rem; padding-right: 2.5rem; padding-left: 2rem"
>
<!-- Top row: name+version+latest ← β†’ downloads β€” single baseline -->
<div class="flex flex-row items-baseline justify-between mb-4">
<!-- Left: name Β· version Β· latest -->
<div class="flex flex-row items-baseline flex-wrap gap-[16px]">
<span
class="text-5xl font-medium leading-none tracking-[-1px]"
:style="{ fontFamily: fontMono }"
>
{{ truncate(name, 24) }}
</span>
<span
class="text-[1.625rem] font-light leading-none"
:style="{ color: theme.textMuted, fontFamily: fontMono }"
>
v{{ version }}
</span>
<span
v-if="isLatest"
class="flex items-center text-xl font-normal py-1 px-[14px] rounded-[6px] leading-[1.5] tracking-[0.04em]"
:style="{
backgroundColor: withAlpha(primaryColor, 0.1),
color: primaryColor,
}"
>latest</span
>
</div>

<!-- Right: weekly downloads β€” flat, single line -->
<div class="flex flex-row items-baseline flex-shrink-0 gap-[10px]">
<span
class="text-[2.5rem] font-medium leading-none tracking-[-1.5px]"
:style="{ color: theme.text, fontFamily: fontMono }"
>
{{ weeklyDownloads > 0 ? compactFormatter.format(weeklyDownloads) : '-' }}
</span>
<span class="text-[1.375rem] font-light" :style="{ color: theme.textMuted }"
>weekly</span
>
</div>
</div>

<!-- Description -->
<div
class="text-[1.375rem] font-light leading-[1.6] mb-5"
:style="{ color: theme.textMuted, fontFamily: fontSans }"
>
{{ truncate(description, 440) }}
</div>

<!-- Tags -->
<div class="flex flex-row flex-wrap gap-[16px]">
<span
v-if="hasTypes"
class="flex items-center text-xl font-light py-1 px-[14px] rounded-[6px] leading-[1.6]"
:style="{
border: `1px solid ${theme.borderMuted}`,
color: theme.textSubtle,
}"
>Types</span
>
<span
v-if="displayVersion"
class="flex items-center text-xl font-light py-1 px-[14px] rounded-[6px] leading-[1.6]"
:style="{
border: `1px solid ${theme.borderMuted}`,
color: theme.textSubtle,
}"
>{{ moduleFormat }}</span
>
<span
v-if="license"
class="flex items-center text-xl font-light py-1 px-[14px] rounded-[6px] leading-[1.6]"
:style="{
border: `1px solid ${theme.borderMuted}`,
color: theme.textSubtle,
}"
>{{ license }}</span
>
<span
v-if="repoSlug"
class="flex items-center text-xl font-light py-1 px-[14px] rounded-[6px] leading-[1.6]"
:style="{
border: `1px solid ${theme.borderFaint}`,
color: theme.textFaint,
fontFamily: fontMono,
}"
>{{ repoSlug }}</span
>
</div>
</div>

<!-- Bottom unified stats row -->
<div
class="flex flex-col justify-center flex-shrink-0 h-[132px]"
style="padding-right: 2.5rem; padding-left: 2rem"
>
<div class="flex flex-row items-center gap-[42px]">
<!-- Stars -->
<div v-if="stars > 0" class="flex flex-row items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
:stroke="theme.textSubtle"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.12 2.12 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.12 2.12 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.12 2.12 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.12 2.12 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.12 2.12 0 0 0 1.597-1.16z"
/>
</svg>
<span
class="text-2xl font-normal leading-none tracking-[-0.3px]"
:style="{ color: theme.textMuted }"
>{{ compactFormatter.format(stars) }}</span
>
</div>

<!-- Forks -->
<div v-if="forks > 0" class="flex flex-row items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
:stroke="theme.textSubtle"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="6" r="3" />
<path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9m6 3v3" />
</svg>
<span
class="text-2xl font-normal leading-none tracking-[-0.3px]"
:style="{ color: theme.textMuted }"
>{{ compactFormatter.format(forks) }}</span
>
</div>

<!-- Install Size -->
<div class="flex flex-row items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
:stroke="theme.textSubtle"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 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.73zm1 .27V12"
/>
<path d="M3.29 7L12 12l8.71-5M7.5 4.27l9 5.15" />
</svg>
<span
class="text-2xl font-normal leading-none tracking-[-0.3px]"
:style="{ color: theme.textMuted }"
>{{ unpackedSize !== null ? bytesFormatter.format(unpackedSize) : '-' }}</span
>
</div>

<!-- Dependencies -->
<div v-if="depsCount > 0" class="flex flex-row items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
:stroke="theme.textSubtle"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
<span
class="text-2xl font-normal leading-none tracking-[-0.3px]"
:style="{ color: theme.textMuted }"
>{{ depsCount }}</span
>
</div>

<!-- Published -->
<div v-if="publishedAt" class="flex flex-row items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
:stroke="theme.textSubtle"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 2v4m8-4v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
<span
class="text-2xl font-normal leading-none tracking-[-0.3px]"
:style="{ color: theme.textMuted }"
>{{ formatDate(publishedAt) }}</span
>
</div>
</div>
</div>
</div>
</div>

<!-- ── Footer ────────────────────────────────────────────────────── -->
<div
class="flex flex-row items-center justify-between flex-shrink-0"
:style="{
borderTop: `1px solid ${theme.border}`,
backgroundColor: theme.bg,
paddingTop: '1rem',
paddingBottom: '1rem',
paddingRight: '2.5rem',
paddingLeft: '2rem',
}"
>
<div
class="flex flex-row items-center text-[1.375rem] font-light"
:style="{ fontFamily: fontMono }"
>
<span class="font-medium" :style="{ color: primaryColor, marginLeft: '-0.25rem' }">.</span
>/npmx
<span
class="font-light"
:style="{ color: theme.textSubtle, fontFamily: fontSans, marginLeft: '0.75rem' }"
>Β· npm package explorer</span
>
</div>
<span class="text-xl font-light" :style="{ color: theme.textSubtle, fontFamily: fontMono }">
npmx.dev/package/{{ name }}
</span>
</div>
</div>
</template>
Loading
Loading