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.d.vue.ts b/app/components/OgImage/ShareCard.d.vue.ts new file mode 100644 index 0000000000..ccaeb99685 --- /dev/null +++ b/app/components/OgImage/ShareCard.d.vue.ts @@ -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 diff --git a/app/components/OgImage/ShareCard.vue b/app/components/OgImage/ShareCard.vue new file mode 100644 index 0000000000..e3ad2d85b2 --- /dev/null +++ b/app/components/OgImage/ShareCard.vue @@ -0,0 +1,331 @@ + + + diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index e0aeab97be..23bff2d31a 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -126,6 +126,8 @@ useShortcuts({ 'f': () => diffLink.value, }) +const shareModal = useModal('share-modal') + const fundingUrl = computed(() => { let funding = props.displayVersion?.funding if (Array.isArray(funding)) funding = funding[0] @@ -181,9 +183,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}${cardUrl.value}`) + +// Downloads for alt text +const compactFormatter = useCompactNumberFormatter() +const { data: downloadsData } = usePackageDownloads( + computed(() => props.packageName), + 'last-week', +) + +// e.g. nuxt 4.4.2 (latest) — 1.4M weekly downloads — MIT license — via npmx.dev +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(`${compactFormatter.value.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 + showAlt.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) + try { + a.click() + } finally { + document.body.removeChild(a) + } + showAlt.value = true +} + +function handleCopyLink() { + copyLink() + showAlt.value = true +} + + + diff --git a/app/components/Package/Skeleton.vue b/app/components/Package/Skeleton.vue index 0c2409fd64..818a18a78f 100644 --- a/app/components/Package/Skeleton.vue +++ b/app/components/Package/Skeleton.vue @@ -15,6 +15,8 @@ + +
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 4bc4d85bca..4d61a387bc 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..1739b0b44b 100644 --- a/app/utils/colors.ts +++ b/app/utils/colors.ts @@ -1,5 +1,22 @@ // 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 6a317eed0d..3e08181d5d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -100,6 +100,13 @@ export default defineNuxtConfig({ routeRules: { // API routes '/api/**': { isr: 300 }, + '/api/card/**': { + isr: { + expiration: 3600, + 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(3600), + '/__og-image__/**': { + isr: { + expiration: 3600, + 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..d04db232a0 --- /dev/null +++ b/server/api/card/[...pkg].get.ts @@ -0,0 +1,35 @@ +import { createError, getQuery, getRouterParam, sendRedirect } from 'h3' +import { assertValidPackageName } from '#shared/utils/npm' +import { ACCENT_COLOR_IDS } from '#shared/utils/constants' + +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 rawColor = typeof query.color === 'string' ? query.color : null + const color = + rawColor && (ACCENT_COLOR_IDS as readonly string[]).includes(rawColor) + ? `&color=${rawColor}` + : '' + + return sendRedirect( + event, + `/__og-image__/image/share-card/${packageName}/og.png?theme=${theme}${color}`, + 302, + ) +}) diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 72aa92148e..a1aa8c464e 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,35 @@ export const BACKGROUND_THEMES = { black: 'oklch(0.4 0 0)', } as const +/** + * 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 = diff --git a/test/e2e/og-image.spec.ts b/test/e2e/og-image.spec.ts index d34b62a144..baf3a31e98 100644 --- a/test/e2e/og-image.spec.ts +++ b/test/e2e/og-image.spec.ts @@ -27,3 +27,20 @@ for (const path of paths) { }) }) } + +test.describe('share card', () => { + for (const theme of ['dark', 'light'] as const) { + test(`share card for nuxt (${theme})`, async ({ page, baseURL }) => { + const base = baseURL?.endsWith('/') ? baseURL.slice(0, -1) : baseURL + const response = await page.request.get(`${base}/api/card/nuxt.png?theme=${theme}`) + + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toContain('image/png') + + const imageBuffer = await response.body() + expect(imageBuffer).toMatchSnapshot({ + name: `share-card-nuxt-${theme}.png`, + }) + }) + } +}) diff --git a/test/e2e/og-image.spec.ts-snapshots/og-image-for--package-nuxt-v-3-20-2.png b/test/e2e/og-image.spec.ts-snapshots/og-image-for--package-nuxt-v-3-20-2.png index a8798d49bd..6ac090b3c9 100644 Binary files a/test/e2e/og-image.spec.ts-snapshots/og-image-for--package-nuxt-v-3-20-2.png and b/test/e2e/og-image.spec.ts-snapshots/og-image-for--package-nuxt-v-3-20-2.png differ diff --git a/test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-dark.png b/test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-dark.png new file mode 100644 index 0000000000..d45620a59b Binary files /dev/null and b/test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-dark.png differ diff --git a/test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-light.png b/test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-light.png new file mode 100644 index 0000000000..b5eda08ce9 Binary files /dev/null and b/test/e2e/og-image.spec.ts-snapshots/share-card-nuxt-light.png differ diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 6100cc04dc..25c99640ae 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -25,6 +25,11 @@ const SKIPPED_COMPONENTS: Record = { '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/ShareCard.vue': 'OG Image component - server-rendered image, not interactive UI', + + // Package modals with complex async dependencies + 'Package/ShareModal.vue': + 'Requires modal context, API calls (package downloads), and OG image rendering via /api/card', // Client-only components with complex dependencies 'Header/AuthModal.client.vue': 'Complex auth modal with navigation - requires full app context',