Skip to content

Commit b9c3a1d

Browse files
harlan-zwdanielroeautofix-ci[bot]
authored
feat: new og images (#2292)
Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 791ce70 commit b9c3a1d

File tree

71 files changed

+4695
-1043
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+4695
-1043
lines changed

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#secure password, can use openssl rand -hex 32
22
NUXT_SESSION_PASSWORD=""
33

4-
#HMAC secret for image proxy URL signing, can use openssl rand -hex 32
4+
#HMAC secret for image-proxy and OG image URL signing, can use openssl rand -hex 32
55
NUXT_IMAGE_PROXY_SECRET=""

.storybook/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import npmxDark from './theme'
1111
initialize()
1212

1313
// @ts-expect-error - dynamic global name
14-
globalThis.defineOgImageComponent = fn()
14+
globalThis.defineOgImage = fn()
1515

1616
// Subscribe to locale changes from storybook-i18n addon (once, outside decorator)
1717
let currentI18nInstance: any = null

app/app.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ if (import.meta.client) {
127127
useEventListener(document, 'click', handleModalLightDismiss)
128128
}
129129
}
130+
131+
// title and description will be inferred
132+
// this will be overridden by upstream pages that use different templates
133+
defineOgImage('Page.takumi', {}, { alt: 'npmx — a fast, modern browser for the npm registry' })
130134
</script>
131135

132136
<template>

app/components/OgBrand.vue

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
const props = withDefaults(
3+
defineProps<{
4+
height?: number
5+
}>(),
6+
{
7+
height: 60,
8+
},
9+
)
10+
11+
const width = computed(() => Math.round(props.height * (602 / 170)))
12+
</script>
13+
14+
<template>
15+
<img
16+
src="/logo.svg"
17+
alt="npmx"
18+
:width="width"
19+
:height="height"
20+
:style="{ width: `${width}px`, height: `${height}px` }"
21+
/>
22+
</template>

app/components/OgImage/BlogPost.d.vue.ts

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<script setup lang="ts">
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
3+
4+
const {
5+
title,
6+
authors = [],
7+
date = '',
8+
} = defineProps<{
9+
title: string
10+
authors?: ResolvedAuthor[]
11+
date?: string
12+
}>()
13+
14+
const formattedDate = computed(() => {
15+
if (!date) return ''
16+
const parsed = new Date(date)
17+
if (Number.isNaN(parsed.getTime())) return date
18+
19+
return parsed.toLocaleDateString('en-US', {
20+
year: 'numeric',
21+
month: 'short',
22+
day: 'numeric',
23+
timeZone: 'UTC',
24+
})
25+
})
26+
27+
const MAX_VISIBLE_AUTHORS = 2
28+
29+
const getInitials = (name: string) =>
30+
name
31+
.trim()
32+
.split(/\s+/)
33+
.map(part => part[0] ?? '')
34+
.join('')
35+
.toUpperCase()
36+
.slice(0, 2)
37+
38+
const visibleAuthors = computed(() => {
39+
if (authors.length <= 3) return authors
40+
return authors.slice(0, MAX_VISIBLE_AUTHORS)
41+
})
42+
43+
const extraCount = computed(() => {
44+
if (authors.length <= 3) return 0
45+
return authors.length - MAX_VISIBLE_AUTHORS
46+
})
47+
48+
const formattedAuthorNames = computed(() => {
49+
const allNames = authors.map(a => a.name)
50+
if (allNames.length === 0) return ''
51+
if (allNames.length === 1) return allNames[0]
52+
if (allNames.length === 2) return `${allNames[0]} and ${allNames[1]}`
53+
if (allNames.length === 3) return `${allNames[0]}, ${allNames[1]}, and ${allNames[2]}`
54+
const shown = allNames.slice(0, MAX_VISIBLE_AUTHORS)
55+
const remaining = allNames.length - MAX_VISIBLE_AUTHORS
56+
return `${shown.join(', ')} and ${remaining} others`
57+
})
58+
</script>
59+
60+
<template>
61+
<OgLayout>
62+
<div class="px-15 py-12 flex flex-col justify-center gap-5 h-full">
63+
<OgBrand :height="48" />
64+
65+
<!-- Date + Title -->
66+
<div class="flex flex-col gap-2">
67+
<span v-if="formattedDate" class="text-3xl text-fg-muted">
68+
{{ formattedDate }}
69+
</span>
70+
71+
<div
72+
class="lg:text-6xl text-5xl tracking-tighter font-mono leading-tight"
73+
:style="{ lineClamp: 2, textOverflow: 'ellipsis' }"
74+
>
75+
{{ title }}
76+
</div>
77+
</div>
78+
79+
<!-- Authors -->
80+
<div v-if="authors.length" class="flex items-center gap-4 flex-nowrap">
81+
<!-- Stacked avatars -->
82+
<span class="flex flex-row items-center">
83+
<span
84+
v-for="(author, index) in visibleAuthors"
85+
:key="author.name"
86+
class="flex items-center justify-center rounded-full border border-bg bg-bg-muted overflow-hidden w-12 h-12"
87+
:style="{ marginLeft: index > 0 ? '-20px' : '0' }"
88+
>
89+
<img
90+
v-if="author.avatar"
91+
:src="author.avatar"
92+
:alt="author.name"
93+
width="48"
94+
height="48"
95+
class="w-full h-full object-cover"
96+
/>
97+
<span v-else class="text-5 text-fg-muted font-medium">
98+
{{ getInitials(author.name) }}
99+
</span>
100+
</span>
101+
<!-- +N badge -->
102+
<span
103+
v-if="extraCount > 0"
104+
class="flex items-center justify-center text-lg font-medium text-fg-muted rounded-full border border-bg bg-bg-muted overflow-hidden w-12 h-12"
105+
:style="{ marginLeft: '-20px' }"
106+
>
107+
+{{ extraCount }}
108+
</span>
109+
</span>
110+
<!-- Names -->
111+
<span class="text-6 text-fg-muted font-light">{{ formattedAuthorNames }}</span>
112+
</div>
113+
</div>
114+
</OgLayout>
115+
</template>

app/components/OgImage/BlogPost.vue

Lines changed: 0 additions & 142 deletions
This file was deleted.

0 commit comments

Comments
 (0)