Skip to content

Commit 5795503

Browse files
committed
feat: add per-page Open Graph images
Generate a branded OG image per route at build time with nuxt-og-image (Takumi renderer). Sessions render a card with title, speakers, room, and time; every other page uses a Default template. Session images are prerendered for both zh and en. - Add @nuxt/fonts with Noto Sans SC/TC/JP/KR (weight 400) for CJK glyph coverage. SC is primary: Takumi selects one font per script run rather than per glyph, and SC has the widest Han coverage (Simplified and Traditional, each in its correct form). - Add @iconify-json/noto for full emoji coverage (the module bundles only ~100 by default). - Raise the build heap to 8 GB via cross-env for 4 CJK fonts + ~670 images. - Set nitro.prerender.failOnError: false to tolerate transient Pretalx 500s. - Drop the static site-wide og:image/twitter:image meta; the module now injects both per page. - Share the colors, font-family chain, logo path, and base card layout across both templates via a small app/components/OgImage/theme.ts module.
1 parent 32ba4f6 commit 5795503

10 files changed

Lines changed: 1474 additions & 99 deletions

File tree

app/app.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ useHead({
1818
{ property: 'og:url', content: canonicalUrl },
1919
],
2020
})
21+
22+
// Site-wide fallback OG image; individual pages override it with their own.
23+
defineOgImage('Default')
2124
</script>
2225

2326
<template>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang="ts">
2+
import { OG_COLORS, OG_LOGO, ogCardBaseStyle } from './theme'
3+
4+
withDefaults(
5+
defineProps<{
6+
title?: string
7+
subtitle?: string
8+
}>(),
9+
{
10+
title: 'COSCUP 2026 x UbuCon Asia',
11+
subtitle: '',
12+
},
13+
)
14+
</script>
15+
16+
<template>
17+
<div :style="{ ...ogCardBaseStyle, padding: '80px' }">
18+
<div :style="{ display: 'flex', alignItems: 'center' }">
19+
<svg
20+
height="61"
21+
:viewBox="OG_LOGO.viewBox"
22+
width="150"
23+
xmlns="http://www.w3.org/2000/svg"
24+
>
25+
<path
26+
:d="OG_LOGO.path"
27+
:fill="OG_COLORS.accent"
28+
fill-rule="evenodd"
29+
:transform="OG_LOGO.transform"
30+
/>
31+
</svg>
32+
<span :style="{ marginLeft: '28px', fontSize: '34px', fontWeight: 700, color: OG_COLORS.brand }">
33+
COSCUP 2026 x UbuCon Asia
34+
</span>
35+
</div>
36+
37+
<div :style="{ display: 'flex', flexDirection: 'column' }">
38+
<div
39+
:style="{
40+
display: 'block',
41+
fontSize: '60px',
42+
fontWeight: 400,
43+
lineHeight: 1.25,
44+
color: OG_COLORS.text,
45+
}"
46+
>
47+
{{ title }}
48+
</div>
49+
<div
50+
v-if="subtitle"
51+
:style="{
52+
display: 'block',
53+
marginTop: '28px',
54+
fontSize: '32px',
55+
lineHeight: 1.4,
56+
color: OG_COLORS.muted,
57+
}"
58+
>
59+
{{ subtitle }}
60+
</div>
61+
</div>
62+
63+
<div :style="{ display: 'flex', height: '12px', width: '180px', backgroundColor: OG_COLORS.accent, borderRadius: '6px' }" />
64+
</div>
65+
</template>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<script setup lang="ts">
2+
import { OG_COLORS, OG_LOGO, ogCardBaseStyle } from './theme'
3+
4+
withDefaults(
5+
defineProps<{
6+
title?: string
7+
speakers?: string
8+
room?: string
9+
time?: string
10+
}>(),
11+
{
12+
title: '',
13+
speakers: '',
14+
room: '',
15+
time: '',
16+
},
17+
)
18+
</script>
19+
20+
<template>
21+
<div :style="{ ...ogCardBaseStyle, padding: '72px' }">
22+
<div :style="{ display: 'flex', alignItems: 'center' }">
23+
<svg
24+
height="54"
25+
:viewBox="OG_LOGO.viewBox"
26+
width="132"
27+
xmlns="http://www.w3.org/2000/svg"
28+
>
29+
<path
30+
:d="OG_LOGO.path"
31+
:fill="OG_COLORS.accent"
32+
fill-rule="evenodd"
33+
:transform="OG_LOGO.transform"
34+
/>
35+
</svg>
36+
<span :style="{ marginLeft: '24px', fontSize: '30px', fontWeight: 700, color: OG_COLORS.brand }">
37+
COSCUP 2026 x UbuCon Asia
38+
</span>
39+
</div>
40+
41+
<div
42+
:style="{
43+
display: 'block',
44+
fontSize: '60px',
45+
fontWeight: 400,
46+
lineHeight: 1.25,
47+
color: OG_COLORS.text,
48+
}"
49+
>
50+
{{ title }}
51+
</div>
52+
53+
<div :style="{ display: 'flex', flexDirection: 'column', fontSize: '32px', color: OG_COLORS.brand }">
54+
<div
55+
v-if="speakers"
56+
:style="{ display: 'flex', alignItems: 'center', color: OG_COLORS.text, fontWeight: 400 }"
57+
>
58+
{{ speakers }}
59+
</div>
60+
<div
61+
v-if="room || time"
62+
:style="{ display: 'flex', marginTop: '12px', color: OG_COLORS.muted }"
63+
>
64+
{{ [room, time].filter(Boolean).join(' · ') }}
65+
</div>
66+
</div>
67+
</div>
68+
</template>

app/components/OgImage/theme.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Shared style constants for the OG image templates (Default + Session).
2+
// Takumi only supports inline styles, so brand colors are duplicated here.
3+
export const OG_COLORS = {
4+
background: '#191733', // primary-800
5+
brand: '#CCC7FF', // primary-100
6+
muted: '#B2ABFE', // primary-200
7+
accent: '#3C9838', // cp-green
8+
text: '#ffffff',
9+
}
10+
11+
// Mirrors the fonts in nuxt.config.ts; SC first for widest Han coverage.
12+
export const OG_FONT_FAMILY = '\'Noto Sans SC\', \'Noto Sans TC\', \'Noto Sans JP\', \'Noto Sans KR\', sans-serif'
13+
14+
// COSCUP wordmark. viewBox/path/transform are fixed; callers set width/height.
15+
export const OG_LOGO = {
16+
viewBox: '0 0 92.993 38',
17+
path: 'M145.315,50.538l-6.406-19.789-3.327,5.666C95.4,17.41,78.007,54.746,78.007,54.746s14.47-27.308,49.942-5.39L124.631,55ZM171,36.779,164.6,17l-3.332,5.641C121.094,3.648,103.7,40.989,103.7,40.989s14.465-27.3,49.941-5.4l-3.327,5.648Z',
18+
transform: 'translate(-78.007 -17)',
19+
}
20+
21+
// Base card layout shared by both templates; callers add their own padding.
22+
export const ogCardBaseStyle = {
23+
width: '100%',
24+
height: '100%',
25+
display: 'flex',
26+
flexDirection: 'column',
27+
justifyContent: 'space-between',
28+
backgroundColor: OG_COLORS.background,
29+
color: OG_COLORS.text,
30+
fontFamily: OG_FONT_FAMILY,
31+
} as const

app/pages/[...slug].vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ useSeoMeta({
2727
twitterTitle: () => page.value?.title,
2828
twitterDescription: () => page.value?.description,
2929
})
30+
31+
defineOgImage('Default', {
32+
title: page.value?.title || 'COSCUP 2026 x UbuCon Asia',
33+
subtitle: page.value?.description || '',
34+
})
3035
</script>
3136

3237
<template>

app/pages/session.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const selectedDay = computed({
2020
prerenderRoutes(
2121
Object.values(data.value ?? {})
2222
.flat()
23-
.map((s) => `/session/${s.id}`),
23+
.flatMap((s) => [`/session/${s.id}`, `/en/session/${s.id}`]),
2424
)
2525
2626
useSeoMeta({

app/pages/session/[id].vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ useSeoMeta({
4646
twitterDescription: () => sessionInfo.value?.description,
4747
})
4848
49+
// Data is awaited via useFetch, so sessionInfo is populated at prerender time.
50+
defineOgImage('Session', {
51+
title: sessionInfo.value?.title ?? '',
52+
speakers: sessionInfo.value?.speakers.map((speaker) => speaker.name).join(', ') ?? '',
53+
room: sessionInfo.value?.room ?? '',
54+
time: sessionInfo.value?.time ?? '',
55+
})
56+
4957
function close() {
5058
router.push(localePath('/session'))
5159
}

nuxt.config.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,10 @@ export default defineNuxtConfig({
5151
{ property: 'og:description', content: DESC },
5252
{ property: 'og:site_name', content: TITLE },
5353
{ property: 'og:type', content: 'website' },
54-
{ property: 'og:image', content: `${URL}/coscup_logo.png` },
5554
{ name: 'twitter:card', content: 'summary_large_image' },
5655
{ name: 'twitter:title', content: TITLE },
5756
{ name: 'twitter:description', content: DESC },
5857
{ name: 'twitter:site', content: '@coscup' },
59-
{ name: 'twitter:image', content: `${URL}/coscup_logo.png` },
6058
],
6159
script: [
6260
{
@@ -96,6 +94,9 @@ export default defineNuxtConfig({
9694
output: {
9795
publicDir: process.env.NUXT_OUTPUT_DIR || '.output/public',
9896
},
97+
prerender: {
98+
failOnError: false,
99+
},
99100
},
100101

101102
imports: {
@@ -110,12 +111,34 @@ export default defineNuxtConfig({
110111
'@unocss/nuxt',
111112
'@nuxt/content',
112113
'@nuxt/eslint',
114+
'@nuxt/fonts',
113115
'@nuxt/image',
114116
'@nuxt/icon',
115117
'@nuxtjs/i18n',
116118
'nuxt-gtag',
119+
'nuxt-og-image',
117120
],
118121

122+
// Origin only — app.baseURL ('/2026') is appended automatically by nuxt-site-config.
123+
site: {
124+
url: 'https://coscup.org',
125+
name: TITLE,
126+
},
127+
128+
fonts: {
129+
// global: true required so nuxt-og-image's renderer can use these fonts.
130+
// Only weight 400: the Takumi renderer corrupts the 700-weight CJK glyphs
131+
// (double-draw artifacts), so OG templates render CJK at regular weight.
132+
// Order matters: Takumi picks one font per script run, so the widest-coverage
133+
// CJK font (SC) must be first.
134+
families: [
135+
{ name: 'Noto Sans SC', weights: [400], global: true },
136+
{ name: 'Noto Sans TC', weights: [400], global: true },
137+
{ name: 'Noto Sans JP', weights: [400], global: true },
138+
{ name: 'Noto Sans KR', weights: [400], global: true },
139+
],
140+
},
141+
119142
content: {
120143
experimental: { nativeSqlite: true },
121144
renderer: { anchorLinks: false },
@@ -133,6 +156,8 @@ export default defineNuxtConfig({
133156
},
134157

135158
i18n: {
159+
// Match site.url so nuxt-site-config can build absolute hreflang/canonical links.
160+
baseUrl: 'https://coscup.org',
136161
locales: [
137162
{ code: 'en', name: 'English', language: 'en-US' },
138163
{ code: 'zh', name: '中文', language: 'zh-Hant-TW' },

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"node": ">=24.0.0"
88
},
99
"scripts": {
10-
"build": "nuxt generate",
10+
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt generate",
1111
"dev": "nuxt dev",
1212
"preview": "nuxt preview",
1313
"postinstall": "nuxt prepare",
@@ -21,28 +21,35 @@
2121
"@nuxt/image": "2.0.0",
2222
"@nuxtjs/i18n": "10.4.0",
2323
"@nuxtjs/mdc": "0.22.0",
24+
"@resvg/resvg-js": "2.6.2",
2425
"@vueuse/core": "14.3.0",
2526
"csv-parse": "6.2.1",
2627
"eslint": "10.4.1",
2728
"leaflet": "1.9.4",
2829
"nuxt": "4.4.7",
2930
"nuxt-gtag": "4.1.0",
31+
"satori": "0.26.0",
3032
"ufo": "1.6.4",
3133
"vue": "3.5.35",
3234
"vue-router": "5.1.0",
3335
"zod": "4.4.3"
3436
},
3537
"devDependencies": {
3638
"@antfu/eslint-config": "9.0.0",
39+
"@iconify-json/noto": "1.2.7",
3740
"@iconify-json/tabler": "1.2.35",
41+
"@nuxt/fonts": "0.14.0",
42+
"@takumi-rs/core": "1.7.0",
3843
"@types/geojson": "7946.0.16",
3944
"@types/leaflet": "1.9.21",
4045
"@unocss/eslint-plugin": "66.7.0",
4146
"@unocss/extractor-mdc": "66.7.0",
4247
"@unocss/nuxt": "66.7.0",
4348
"@unocss/preset-wind4": "66.7.0",
4449
"@unocss/transformer-directives": "66.7.0",
50+
"cross-env": "10.1.0",
4551
"eslint-plugin-format": "2.0.1",
52+
"nuxt-og-image": "6.5.2",
4653
"typescript": "6.0.3",
4754
"unocss": "66.7.0",
4855
"unocss-preset-theme": "0.14.1",

0 commit comments

Comments
 (0)