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 @@ + + + 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 +} + + + 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 =