diff --git a/app/components/OgImage/BlogPost.vue b/app/components/OgImage/BlogPost.vue
index 293979b1f2..555a0346df 100644
--- a/app/components/OgImage/BlogPost.vue
+++ b/app/components/OgImage/BlogPost.vue
@@ -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
diff --git a/app/components/OgImage/ShareCard.vue b/app/components/OgImage/ShareCard.vue
new file mode 100644
index 0000000000..0e0423ea5f
--- /dev/null
+++ b/app/components/OgImage/ShareCard.vue
@@ -0,0 +1,318 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ truncate(name, 24) }}
+
+
+ v{{ version }}
+
+ latest
+
+
+
+
+
+ {{ compactFormatter.format(weeklyDownloads) }}
+
+ weekly
+
+
+
+
+
+ {{ truncate(description || 'No description.', 440) }}
+
+
+
+
+ Types
+ {{ moduleFormat }}
+ {{ license }}
+ {{ repoSlug }}
+
+
+
+
+
+
+
+
+
+
{{ compactFormatter.format(stars) }}
+
+
+
+
+
+
{{ compactFormatter.format(forks) }}
+
+
+
+
+
+
{{ bytesFormatter.format(unpackedSize) }}
+
+
+
+
+
+
{{ depsCount }}
+
+
+
+
+
+
{{ formatDate(publishedAt) }}
+
+
+
+
+
+
+
+
+
+ ./npmx
+ · npm package explorer
+
+
+ npmx.dev/package/{{ name }}
+
+
+
+
diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue
index 7cf6b4c8bc..236519b415 100644
--- a/app/components/Package/Header.vue
+++ b/app/components/Package/Header.vue
@@ -174,6 +174,8 @@ onKeyStroke(
{ dedupe: true },
)
+const shareModal = useModal('share-modal')
+
const fundingUrl = computed(() => {
let funding = props.displayVersion?.funding
if (Array.isArray(funding)) funding = funding[0]
@@ -229,9 +231,25 @@ const fundingUrl = computed(() => {
>
{{ $t('package.links.fund') }}
+
+
+ share
+
+
+
+const props = defineProps<{
+ packageName: string
+ resolvedVersion: string
+ isLatest: boolean
+ license?: string
+}>()
+
+const { origin } = useRequestURL()
+const colorMode = useColorMode()
+const theme = computed(() => (colorMode.value === 'dark' ? 'dark' : 'light'))
+const { selectedAccentColor } = useAccentColor()
+
+const colorParam = computed(() =>
+ selectedAccentColor.value ? `&color=${encodeURIComponent(selectedAccentColor.value)}` : '',
+)
+
+const cardUrl = computed(
+ () => `/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`,
+)
+const absoluteCardUrl = computed(
+ () => `${origin}/api/card/${props.packageName}.png?theme=${theme.value}${colorParam.value}`,
+)
+
+// Downloads for alt text
+const { data: downloadsData } = usePackageDownloads(
+ computed(() => props.packageName),
+ 'last-week',
+)
+
+const altText = computed(() => {
+ const tag = props.isLatest ? 'latest' : props.resolvedVersion
+ const parts: string[] = [`${props.packageName} ${props.resolvedVersion} (${tag})`]
+ const dl = downloadsData.value?.downloads
+ if (dl && dl > 0) {
+ parts.push(
+ `${Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(dl)} weekly downloads`,
+ )
+ }
+ if (props.license) parts.push(`${props.license} license`)
+ parts.push('via npmx.dev')
+ return parts.join(' — ')
+})
+
+// Copy link button
+const { copied: linkCopied, copy: copyLink } = useClipboard({
+ source: absoluteCardUrl,
+ copiedDuring: 1500,
+})
+
+// Copy alt text button
+const { copied: altCopied, copy: copyAlt } = useClipboard({
+ source: altText,
+ copiedDuring: 1500,
+})
+
+// Reveal Copy ALT after the user has downloaded or copied the link
+const showAlt = ref(false)
+
+// Image load state
+const imgLoaded = ref(false)
+const imgError = ref(false)
+
+watch(cardUrl, () => {
+ imgLoaded.value = false
+ imgError.value = false
+})
+
+async function downloadCard() {
+ const a = document.createElement('a')
+ a.href = cardUrl.value
+ a.download = `${props.packageName.replace('/', '-')}-card.png`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ showAlt.value = true
+}
+
+function handleCopyLink() {
+ copyLink(absoluteCardUrl.value)
+ showAlt.value = true
+}
+
+
+
+
+
+
+
+
+
+ Generating card…
+
+
+
+
+ Failed to load card.
+
+
+
+
![]()
+
+
+
+
+
+
+
+ {{ altCopied ? 'Copied!' : 'Copy ALT' }}
+
+
+
+
+
+
+ {{ linkCopied ? 'Copied!' : 'Copy link' }}
+
+
+
+ Download PNG
+
+
+
+
+
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index 83b0f2b86b..e1dd096566 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -1,11 +1,20 @@
+
+
+
+
diff --git a/app/utils/colors.ts b/app/utils/colors.ts
index 1e6e4a95f9..45631c7ecf 100644
--- a/app/utils/colors.ts
+++ b/app/utils/colors.ts
@@ -1,5 +1,17 @@
// Vue Data UI does not support CSS vars nor OKLCH for now
+/**
+ * Appends an alpha value to a hex or oklch color string.
+ * Needed because OG image renderers (satori) don't support CSS variables or
+ * opacity utilities — colors must be fully resolved values.
+ */
+export function withAlpha(color: string, alpha: number): string {
+ if (color.startsWith('oklch(')) return color.replace(')', ` / ${alpha})`)
+ if (color.startsWith('#'))
+ return color + Math.round(alpha * 255).toString(16).padStart(2, '0')
+ return color
+}
+
/**
* Default neutral OKLCH color used as fallback when CSS variables are unavailable (e.g., during SSR).
* This matches the dark mode value of --fg-subtle defined in main.css.
diff --git a/app/utils/formatters.ts b/app/utils/formatters.ts
index c135506d71..9c0147d698 100644
--- a/app/utils/formatters.ts
+++ b/app/utils/formatters.ts
@@ -4,3 +4,20 @@ export function toIsoDateString(date: Date): string {
const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
+
+/**
+ * Format an ISO date string to a human-readable date (e.g. "Jan 1, 2024").
+ * Returns the original string if parsing fails, or an empty string if no date is provided.
+ */
+export function formatDate(date: string | undefined): string {
+ if (!date) return ''
+ try {
+ return new Date(date).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+ } catch {
+ return date
+ }
+}
diff --git a/app/utils/string.ts b/app/utils/string.ts
new file mode 100644
index 0000000000..8917c5aa8d
--- /dev/null
+++ b/app/utils/string.ts
@@ -0,0 +1,3 @@
+export function truncate(s: string, n: number): string {
+ return s.length > n ? s.slice(0, n - 1) + '…' : s
+}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 88d3822376..2c378ed912 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -100,6 +100,13 @@ export default defineNuxtConfig({
routeRules: {
// API routes
'/api/**': { isr: 60 },
+ '/api/card/**': {
+ isr: {
+ expiration: 60,
+ passQuery: true,
+ allowQuery: ['theme', 'color'],
+ },
+ },
'/api/registry/badge/**': {
isr: {
expiration: 60 * 60 /* one hour */,
@@ -128,7 +135,13 @@ export default defineNuxtConfig({
'/api/registry/package-meta/**': { isr: 300 },
'/:pkg/.well-known/skills/**': { isr: 3600 },
'/:scope/:pkg/.well-known/skills/**': { isr: 3600 },
- '/__og-image__/**': getISRConfig(60),
+ '/__og-image__/**': {
+ isr: {
+ expiration: 60,
+ passQuery: true,
+ allowQuery: ['theme', 'color'],
+ },
+ },
'/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' },
'/opensearch.xml': { isr: true },
'/oauth-client-metadata.json': { prerender: true },
diff --git a/server/api/card/[...pkg].get.ts b/server/api/card/[...pkg].get.ts
new file mode 100644
index 0000000000..8cfc55c9a6
--- /dev/null
+++ b/server/api/card/[...pkg].get.ts
@@ -0,0 +1,30 @@
+import { createError, getQuery, getRouterParam, sendRedirect } from 'h3'
+import { assertValidPackageName } from '#shared/utils/npm'
+
+export default defineEventHandler(async event => {
+ const segments = getRouterParam(event, 'pkg')?.split('/') ?? []
+
+ // Strip .png extension from the final segment (e.g. /api/card/nuxt.png)
+ if (segments.length > 0) {
+ const last = segments[segments.length - 1]!
+ if (last.endsWith('.png')) segments[segments.length - 1] = last.slice(0, -4)
+ }
+
+ const packageName = segments.join('/')
+
+ if (!packageName) {
+ throw createError({ statusCode: 404, message: 'Package name is required.' })
+ }
+
+ assertValidPackageName(packageName)
+
+ const query = getQuery(event)
+ const theme = query.theme === 'light' ? 'light' : 'dark'
+ const color = typeof query.color === 'string' ? `&color=${encodeURIComponent(query.color)}` : ''
+
+ return sendRedirect(
+ event,
+ `/__og-image__/image/share-card/${packageName}/og.png?theme=${theme}${color}`,
+ 302,
+ )
+})
diff --git a/server/utils/image-proxy.ts b/server/utils/image-proxy.ts
index ae590a9d0d..927a1884fe 100644
--- a/server/utils/image-proxy.ts
+++ b/server/utils/image-proxy.ts
@@ -76,6 +76,7 @@ const TRUSTED_IMAGE_DOMAINS = [
'travis-ci.org',
'secure.travis-ci.org',
'img.badgesize.io',
+ 'api.securityscorecards.dev',
]
/**
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index 72aa92148e..e1a7c56670 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -92,6 +92,43 @@ export const ACCENT_COLORS = {
},
} as const satisfies Record<'light' | 'dark', Record
>
+export interface AccentColorToken {
+ light: { oklch: string; hex: string }
+ dark: { oklch: string; hex: string }
+}
+
+// todo(atriiy): This is duplicated with ACCENT_COLORS, will be refactored later
+export const ACCENT_COLOR_TOKENS = {
+ sky: {
+ light: { oklch: 'oklch(0.53 0.16 247.27)', hex: '#006fc2' },
+ dark: { oklch: 'oklch(0.787 0.128 230.318)', hex: '#51c8fc' },
+ },
+ coral: {
+ light: { oklch: 'oklch(0.56 0.17 10.75)', hex: '#c23d5c' },
+ dark: { oklch: 'oklch(0.704 0.177 14.75)', hex: '#f9697c' },
+ },
+ amber: {
+ light: { oklch: 'oklch(0.58 0.18 46.34)', hex: '#cb4c00' },
+ dark: { oklch: 'oklch(0.828 0.165 84.429)', hex: '#f8bc1c' },
+ },
+ emerald: {
+ light: { oklch: 'oklch(0.51 0.13 162.4)', hex: '#007c4f' },
+ dark: { oklch: 'oklch(0.792 0.153 166.95)', hex: '#2edaa6' },
+ },
+ violet: {
+ light: { oklch: 'oklch(0.56 0.13 282.067)', hex: '#6a68be' },
+ dark: { oklch: 'oklch(0.78 0.148 286.067)', hex: '#b0a9ff' },
+ },
+ magenta: {
+ light: { oklch: 'oklch(0.56 0.14 325)', hex: '#9c54a1' },
+ dark: { oklch: 'oklch(0.78 0.15 330)', hex: '#ec92e5' },
+ },
+ neutral: {
+ light: { oklch: 'oklch(0.145 0 0)', hex: '#0a0a0a' },
+ dark: { oklch: 'oklch(1 0 0)', hex: '#ffffff' },
+ },
+} as const satisfies Record
+
export const BACKGROUND_THEMES = {
neutral: 'oklch(0.555 0 0)',
stone: 'oklch(0.555 0.013 58.123)',
@@ -100,6 +137,50 @@ export const BACKGROUND_THEMES = {
black: 'oklch(0.4 0 0)',
} as const
+export type BackgroundThemeId = 'neutral' | 'stone' | 'zinc' | 'slate' | 'black'
+
+export interface BackgroundThemeToken {
+ oklch: string
+ hex: string
+}
+
+export const BACKGROUND_THEME_TOKENS = {
+ neutral: { oklch: 'oklch(0.555 0 0)', hex: '#737373' },
+ stone: { oklch: 'oklch(0.555 0.013 58.123)', hex: '#79716c' },
+ zinc: { oklch: 'oklch(0.555 0.016 285.931)', hex: '#72727c' },
+ slate: { oklch: 'oklch(0.555 0.046 257.407)', hex: '#62748e' },
+ black: { oklch: 'oklch(0.4 0 0)', hex: '#484848' },
+} as const satisfies Record
+
+/**
+ * Static theme tokens for the share card OG image.
+ * Must use hex/rgb — satori (the OG image renderer) does not support oklch.
+ */
+export const SHARE_CARD_THEMES = {
+ dark: {
+ bg: '#101010',
+ border: '#262626',
+ borderMuted: '#26262699',
+ borderFaint: '#26262680',
+ divider: '#1f1f1f',
+ text: '#f9f9f9',
+ textMuted: '#adadad',
+ textSubtle: '#969696',
+ textFaint: '#969696cc',
+ },
+ light: {
+ bg: '#ffffff',
+ border: '#cecece',
+ borderMuted: '#cecece99',
+ borderFaint: '#cecece80',
+ divider: '#e5e5e5',
+ text: '#0a0a0a',
+ textMuted: '#474747',
+ textSubtle: '#5d5d5d',
+ textFaint: '#5d5d5dcc',
+ },
+} as const satisfies Record<'light' | 'dark', Record>
+
// INFO: Regex for capture groups
export const BLUESKY_URL_EXTRACT_REGEX = /profile\/([^/]+)\/post\/([^/]+)/
export const BSKY_POST_AT_URI_REGEX =