430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import { ImageResponse } from '@vercel/og'
|
|
import sharp from 'sharp'
|
|
|
|
import defaultOGImageBg from '../assets/ogimage-bg.png'
|
|
import defaultOGImage from '../assets/ogimage.png'
|
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
|
import { urlWithParams } from '../lib/urls'
|
|
|
|
import type { APIContext } from 'astro'
|
|
import type { Prettify } from 'ts-essentials'
|
|
|
|
//////////////////////////////////////////////////////
|
|
// NOTE //
|
|
// Use this website to create and preview templates //
|
|
// https://og-playground.vercel.app/ //
|
|
//////////////////////////////////////////////////////
|
|
|
|
const defaultOptions = {
|
|
width: 1200,
|
|
height: 630,
|
|
fonts: [
|
|
{
|
|
name: 'Inter',
|
|
weight: 400,
|
|
style: 'normal',
|
|
data: fs.readFileSync(
|
|
path.resolve(
|
|
process.cwd(),
|
|
'node_modules',
|
|
'@fontsource',
|
|
'inter',
|
|
'files',
|
|
'inter-latin-400-normal.woff'
|
|
)
|
|
),
|
|
},
|
|
{
|
|
name: 'Inter',
|
|
weight: 700,
|
|
style: 'normal',
|
|
data: fs.readFileSync(
|
|
path.resolve(
|
|
process.cwd(),
|
|
'node_modules',
|
|
'@fontsource',
|
|
'inter',
|
|
'files',
|
|
'inter-latin-700-normal.woff'
|
|
)
|
|
),
|
|
},
|
|
{
|
|
name: 'Space Grotesk',
|
|
weight: 400,
|
|
style: 'normal',
|
|
data: fs.readFileSync(
|
|
path.resolve(
|
|
process.cwd(),
|
|
'node_modules',
|
|
'@fontsource',
|
|
'space-grotesk',
|
|
'files',
|
|
'space-grotesk-latin-400-normal.woff'
|
|
)
|
|
),
|
|
},
|
|
{
|
|
name: 'Space Grotesk',
|
|
weight: 700,
|
|
style: 'normal',
|
|
data: fs.readFileSync(
|
|
path.resolve(
|
|
process.cwd(),
|
|
'node_modules',
|
|
'@fontsource',
|
|
'space-grotesk',
|
|
'files',
|
|
'space-grotesk-latin-700-normal.woff'
|
|
)
|
|
),
|
|
},
|
|
],
|
|
} as const satisfies ConstructorParameters<typeof ImageResponse>[1]
|
|
|
|
export const ogImageTemplates = {
|
|
default: (_props: Record<never, never> = {}, context) => {
|
|
return new ImageResponse(
|
|
(
|
|
<img
|
|
src={absoluteUrl(defaultOGImage.src, context)}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
}}
|
|
/>
|
|
),
|
|
defaultOptions
|
|
)
|
|
},
|
|
service: async (
|
|
{
|
|
title,
|
|
description,
|
|
categories,
|
|
score,
|
|
imageUrl,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
categories: {
|
|
name: string
|
|
icon: string
|
|
}[]
|
|
score: number
|
|
imageUrl: string | null
|
|
},
|
|
context
|
|
) => {
|
|
const scoreInfo = makeOverallScoreInfo(score, 10)
|
|
const scoreColors = {
|
|
'bg-score-1': '#e26136',
|
|
'bg-score-2': '#eba370',
|
|
'bg-score-3': '#eddb82',
|
|
'bg-score-4': '#8de2d7',
|
|
'bg-score-5': '#3cdd71',
|
|
} as const satisfies Record<string, string>
|
|
const scoreColor =
|
|
Object.entries(scoreColors).find(([className]) => scoreInfo.classNameBg?.includes(className))?.[1] ??
|
|
'white'
|
|
|
|
const PADING = 80
|
|
|
|
return new ImageResponse(
|
|
(
|
|
<div
|
|
style={{
|
|
color: 'white',
|
|
backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`,
|
|
width: '100%',
|
|
height: '100%',
|
|
padding: PADING,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative',
|
|
gap: 20,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'flex-end',
|
|
gap: 10,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{!!imageUrl && (
|
|
<img
|
|
src={absoluteUrl(imageUrl, context)}
|
|
style={{
|
|
width: 140,
|
|
height: 140,
|
|
borderRadius: 20,
|
|
objectFit: 'contain',
|
|
}}
|
|
/>
|
|
)}
|
|
<div style={{ display: 'flex', paddingTop: 20 }}>
|
|
<span
|
|
style={{
|
|
fontSize: 100,
|
|
fontWeight: 'bold',
|
|
color: '#3bdb78',
|
|
fontFamily: 'Space Grotesk',
|
|
lineHeight: 1.2,
|
|
height: 120,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
marginTop: -20,
|
|
}}
|
|
>
|
|
{title}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
alignItems: 'flex-end',
|
|
display: 'flex',
|
|
gap: 50,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 10,
|
|
flex: 1,
|
|
justifyContent: 'space-between',
|
|
alignSelf: 'stretch',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 30,
|
|
color: 'white',
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
maxHeight: 115,
|
|
}}
|
|
>
|
|
{description}
|
|
</span>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
gap: 40,
|
|
flexWrap: 'wrap',
|
|
fontWeight: 'bold',
|
|
fontSize: 50,
|
|
marginTop: 10,
|
|
color: 'white',
|
|
}}
|
|
>
|
|
{await Promise.all(
|
|
categories.map(async (category) => (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 10, whiteSpace: 'nowrap' }}>
|
|
<img
|
|
src={await iconUrl(category.icon, 50)}
|
|
width={50}
|
|
height={50}
|
|
style={{ width: 50, height: 50 }}
|
|
/>
|
|
{category.name}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex' }}>
|
|
<div
|
|
style={{
|
|
fontSize: 150,
|
|
color: 'black',
|
|
height: 200,
|
|
width: 200,
|
|
borderRadius: 30,
|
|
backgroundColor: scoreColor,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontWeight: 'bold',
|
|
}}
|
|
>
|
|
{score}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="white"
|
|
width={400}
|
|
viewBox="0 0 204 28"
|
|
style={{ position: 'absolute', top: PADING, right: PADING }}
|
|
>
|
|
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
|
</svg>
|
|
</div>
|
|
),
|
|
defaultOptions
|
|
)
|
|
},
|
|
generic: async (
|
|
{
|
|
title,
|
|
description,
|
|
icon,
|
|
}: {
|
|
title: string
|
|
description?: string | null
|
|
icon?: string | null
|
|
},
|
|
context
|
|
) => {
|
|
const PADING = 80
|
|
|
|
return new ImageResponse(
|
|
(
|
|
<div
|
|
style={{
|
|
color: 'white',
|
|
backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`,
|
|
width: '100%',
|
|
height: '100%',
|
|
padding: PADING,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative',
|
|
gap: 20,
|
|
}}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="white"
|
|
width={400}
|
|
viewBox="0 0 204 28"
|
|
style={{ marginBottom: 'auto' }}
|
|
>
|
|
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
|
</svg>
|
|
|
|
<div style={{ display: 'flex', paddingTop: 20 }}>
|
|
<span
|
|
style={{
|
|
fontSize: 100,
|
|
fontWeight: 'bold',
|
|
color: '#3bdb78',
|
|
fontFamily: 'Space Grotesk',
|
|
lineHeight: 1.2,
|
|
height: 120,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
marginTop: -20,
|
|
}}
|
|
>
|
|
{title}
|
|
</span>
|
|
</div>
|
|
|
|
<span
|
|
style={{
|
|
fontSize: 40,
|
|
color: 'white',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
maxHeight: 200,
|
|
}}
|
|
>
|
|
{description}
|
|
</span>
|
|
|
|
{!!icon && (
|
|
<img
|
|
src={await iconUrl(icon, 200)}
|
|
width={200}
|
|
height={200}
|
|
style={{
|
|
position: 'absolute',
|
|
top: PADING,
|
|
right: PADING,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
),
|
|
defaultOptions
|
|
)
|
|
},
|
|
} as const satisfies Record<
|
|
string,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(props: any, context: APIContext) => ImageResponse | Promise<ImageResponse | null> | null
|
|
>
|
|
|
|
type OgImageTemplate = keyof typeof ogImageTemplates
|
|
type OgImageProps<T extends OgImageTemplate> = Parameters<(typeof ogImageTemplates)[T]>[0]
|
|
|
|
export type OgImageAllTemplatesWithProps = Prettify<
|
|
{
|
|
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
|
|
[K in OgImageTemplate]: { template: K } & Omit<OgImageProps<K>, 'template'>
|
|
}[OgImageTemplate]
|
|
>
|
|
|
|
export function makeOgImageUrl(
|
|
ogImage: OgImageAllTemplatesWithProps | string | undefined,
|
|
baseUrl: URL | string
|
|
) {
|
|
return typeof ogImage === 'string'
|
|
? new URL(ogImage, baseUrl).href
|
|
: urlWithParams(new URL('/ogimage.png', baseUrl), { data: JSON.stringify(ogImage ?? {}) })
|
|
}
|
|
|
|
// Utilities ------------------------------------------------------------
|
|
|
|
function absoluteUrl(url: string, context: Pick<APIContext, 'url'>) {
|
|
return new URL(url, context.url.origin).href
|
|
}
|
|
|
|
async function svgUrlToBase64Png(svgUrl: string, width?: number, height?: number): Promise<string> {
|
|
// 1. Fetch the SVG file
|
|
const response = await fetch(svgUrl)
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch SVG: ${response.statusText}`)
|
|
}
|
|
|
|
const svgBuffer = await response.arrayBuffer()
|
|
|
|
// 2. Convert SVG to PNG using sharp
|
|
let image = sharp(svgBuffer).png().negate({ alpha: false })
|
|
if (width || height) {
|
|
image = image.resize(width, height, {
|
|
fit: 'contain',
|
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
})
|
|
}
|
|
|
|
const pngBuffer = await image.toBuffer()
|
|
|
|
// 3. Convert to base64 string
|
|
const base64 = pngBuffer.toString('base64')
|
|
return `data:image/png;base64,${base64}`
|
|
}
|
|
|
|
async function iconUrl(icon: string, size = 30) {
|
|
const [, prefix, name] = /^([^:]+):(.*)$/.exec(icon) ?? []
|
|
if (!prefix || !name) return undefined
|
|
const url = `https://api.iconify.design/${prefix}/${name}.svg`
|
|
const result = await svgUrlToBase64Png(url, size, size)
|
|
return result
|
|
}
|