feat(static-web): implement static OG image generation service and add font support

- Updated Dockerfile to use Alpine variant for Node.js and added Perl installation.
- Introduced StaticOgService for generating Open Graph images with customizable templates.
- Added Geist-Medium font for rendering OG images.
- Updated TypeScript configuration to include JSX support and adjusted paths.
- Created type declarations for assets and Vercel OG module.
- Added new static assets for OG image generation.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-06 21:33:30 +08:00
parent 768acfe848
commit 4cdb3bfd4e
6 changed files with 467 additions and 449 deletions

View File

@@ -1,8 +1,9 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=22.11.0
ARG NODE_VERSION=lts
ARG NODE_VARIANT=alpine
FROM node:${NODE_VERSION}-slim AS builder
FROM node:${NODE_VERSION}-${NODE_VARIANT} AS builder
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
@@ -27,10 +28,11 @@ RUN pnpm --filter core build
RUN mkdir -p be/apps/core/dist/static/web && cp -r apps/web/dist/. be/apps/core/dist/static/web/
RUN mkdir -p be/apps/core/dist/static/dashboard && cp -r be/apps/dashboard/dist/. be/apps/core/dist/static/dashboard/
FROM node:${NODE_VERSION}-slim AS runner
FROM node:${NODE_VERSION}-${NODE_VARIANT} AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN apk add --no-cache perl
COPY --from=builder /workspace/be/apps/core/dist ./dist
COPY --from=builder /workspace/be/apps/core/drizzle ./drizzle

Binary file not shown.

View File

@@ -1,22 +1,21 @@
import { Buffer } from 'node:buffer'
import { readFile } from 'node:fs/promises'
import type { PhotoManifestItem } from '@afilmory/builder'
import { createLogger } from '@afilmory/framework'
import { ImageResponse } from '@vercel/og'
import type { Context } from 'hono'
import type { JSX } from 'react'
import * as React from 'react'
import { injectable } from 'tsyringe'
import geistFont from '../../../../../../apps/ssr/src/app/og/[photoId]/Geist-Medium.ttf.ts'
import sansFont from '../../../../../../apps/ssr/src/app/og/[photoId]/PingFangSC.ttf.ts'
import { siteConfig } from '../../../../../../site.config'
import geistFont from './Geist-Medium.ttf?url'
import { StaticWebManifestService } from './static-web-manifest.service'
const OG_IMAGE_WIDTH = 1200
const OG_IMAGE_HEIGHT = 628
type OgElement = {
type: string
props: Record<string, unknown>
}
interface ExifInfo {
focalLength?: string | null
aperture?: string | null
@@ -46,7 +45,7 @@ interface OgTemplateContext {
@injectable()
export class StaticOgService {
private readonly logger = createLogger('StaticOgService')
private fontDataPromise?: Promise<{ geist: ArrayBuffer; sans: ArrayBuffer }>
private geistFontPromise?: Promise<ArrayBuffer>
constructor(private readonly manifestService: StaticWebManifestService) {}
@@ -59,14 +58,11 @@ export class StaticOgService {
}
try {
const [{ geist, sans }, template] = await Promise.all([
this.loadFonts(),
this.buildTemplateContext(context, photo),
])
const [geist, template] = await Promise.all([this.loadGeistFont(), this.buildTemplateContext(context, photo)])
const element = this.renderTemplate(template)
return new ImageResponse(element as unknown as OgElement, {
return new ImageResponse(element, {
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
emoji: 'noto',
@@ -77,12 +73,6 @@ export class StaticOgService {
style: 'normal',
weight: 400,
},
{
name: 'SF Pro Display',
data: sans,
style: 'normal',
weight: 400,
},
],
headers: {
'Cache-Control': 'public, max-age=31536000, stale-while-revalidate=31536000',
@@ -118,19 +108,79 @@ export class StaticOgService {
}
}
private async loadFonts(): Promise<{ geist: ArrayBuffer; sans: ArrayBuffer }> {
if (!this.fontDataPromise) {
this.fontDataPromise = Promise.resolve({
geist: this.bufferToArrayBuffer(geistFont),
sans: this.bufferToArrayBuffer(sansFont),
})
private async loadGeistFont(): Promise<ArrayBuffer> {
if (!this.geistFontPromise) {
this.geistFontPromise = this.loadFontData(geistFont)
}
return this.fontDataPromise
return this.geistFontPromise
}
private bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
private async loadFontData(assetPath: string): Promise<ArrayBuffer> {
if (/^https?:\/\//.test(assetPath)) {
const response = await fetch(assetPath)
if (!response.ok) {
throw new Error(`Failed to fetch font from ${assetPath}`)
}
const arrayBuffer = await response.arrayBuffer()
return this.toArrayBuffer(arrayBuffer)
}
if (assetPath.startsWith('data:')) {
const base64 = assetPath.split(',')[1] ?? ''
return this.toArrayBuffer(Buffer.from(base64, 'base64'))
}
const url = assetPath.startsWith('file:') ? new URL(assetPath) : new URL(assetPath, import.meta.url)
const buffer = await readFile(url)
return this.toArrayBuffer(buffer)
}
private toArrayBuffer(data: ArrayBuffer | SharedArrayBuffer | Uint8Array): ArrayBuffer {
if (data instanceof ArrayBuffer) {
return data
}
if (data instanceof SharedArrayBuffer) {
const copy = new Uint8Array(data.byteLength)
copy.set(new Uint8Array(data))
return copy.buffer
}
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
}
private arrayBufferToDataUri(buffer: ArrayBuffer, mimeType: string): string {
const base64 = Buffer.from(buffer).toString('base64')
return `data:${mimeType};base64,${base64}`
}
private inferThumbnailMimeType(thumbnailUrl?: string): string {
if (!thumbnailUrl) {
return 'image/jpeg'
}
const normalized = thumbnailUrl.toLowerCase()
if (normalized.endsWith('.png')) {
return 'image/png'
}
if (normalized.endsWith('.webp')) {
return 'image/webp'
}
if (normalized.endsWith('.gif')) {
return 'image/gif'
}
if (normalized.endsWith('.avif')) {
return 'image/avif'
}
return 'image/jpeg'
}
private formatDate(photo: PhotoManifestItem): string {
@@ -252,30 +302,9 @@ export class StaticOgService {
throw new Error('Unable to load thumbnail image')
}
private renderTemplate(context: OgTemplateContext): OgElement {
private renderTemplate(context: OgTemplateContext): JSX.Element {
const { photo, formattedDate, tags, exifInfo, layout, thumbnailBuffer } = context
const h = (
type: string,
props: Record<string, unknown> | null,
...children: Array<OgElement | string | null>
): OgElement => {
const filteredChildren = children
.flat()
.filter((child): child is OgElement | string => child !== null && child !== undefined && child !== false)
const normalizedProps: Record<string, unknown> = { ...props }
if (filteredChildren.length > 0) {
normalizedProps.children = filteredChildren.length === 1 ? filteredChildren[0] : filteredChildren
}
return {
type,
props: normalizedProps,
}
}
const decorativeOverlays = [
{
position: 'absolute',
@@ -316,25 +345,22 @@ export class StaticOgService {
'linear-gradient(45deg, transparent 0%, rgba(255,255,255,0.02) 40%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 60%, transparent 100%)',
transform: 'rotate(15deg)',
},
].map((style) => h('div', { style }, null))
].map((style, index) => <div key={`decorative-${index}`} style={style} />)
const filmHoles = Array.from({ length: 7 }, () =>
h(
'div',
{
style: {
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
},
},
null,
),
)
const filmHoles = Array.from({ length: 7 }).map((_, index) => (
<div
key={`film-hole-${index}`}
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
))
const filmBorderStyles = [
const filmBorderElements = [
{
position: 'absolute',
top: '30%',
@@ -364,12 +390,11 @@ export class StaticOgService {
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: '50%',
},
].map((style) => h('div', { style }, null))
].map((style, index) => <div key={`film-border-${index}`} style={style} />)
const apertureDecor = h(
'div',
{
style: {
const apertureDecor = (
<div
style={{
position: 'absolute',
bottom: '40%',
right: '8%',
@@ -380,12 +405,10 @@ export class StaticOgService {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
h(
'div',
{
style: {
}}
>
<div
style={{
width: '30px',
height: '30px',
border: '1px solid rgba(255,255,255,0.06)',
@@ -393,63 +416,191 @@ export class StaticOgService {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
h(
'div',
{
style: {
}}
>
<div
style={{
width: '15px',
height: '15px',
border: '1px solid rgba(255,255,255,0.04)',
borderRadius: '50%',
},
},
null,
),
),
}}
/>
</div>
</div>
)
const tagElements =
tags.length > 0
? h(
'div',
{
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
margin: '0 0 32px 0',
},
},
...tags.map((tag) =>
h(
'div',
{
style: {
fontSize: '26px',
color: 'rgba(255,255,255,0.9)',
backgroundColor: 'rgba(255,255,255,0.15)',
padding: '12px 20px',
borderRadius: '24px',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
border: '1px solid rgba(255,255,255,0.2)',
backdropFilter: 'blur(8px)',
fontFamily: 'Geist, SF Pro Display',
},
},
`#${tag}`,
),
),
)
: null
const tagElements = tags.slice(0, 3).map((tag) => (
<div
key={tag}
style={{
fontSize: '26px',
color: 'rgba(255,255,255,0.9)',
backgroundColor: 'rgba(255,255,255,0.15)',
padding: '12px 20px',
borderRadius: '24px',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
border: '1px solid rgba(255,255,255,0.2)',
backdropFilter: 'blur(8px)',
fontFamily: 'Geist, SF Pro Display',
}}
>
#{tag}
</div>
))
const leftFilmColumn = h(
'div',
{
style: {
const exifBadges: JSX.Element[] = []
if (exifInfo?.aperture) {
exifBadges.push(
<div
key="aperture"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{`${exifInfo.aperture}`}
</div>,
)
}
if (exifInfo?.shutterSpeed) {
exifBadges.push(
<div
key="shutter"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{`⏱️ ${exifInfo.shutterSpeed}`}
</div>,
)
}
if (exifInfo?.iso) {
exifBadges.push(
<div
key="iso"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{`📊 ISO ${exifInfo.iso}`}
</div>,
)
}
if (exifInfo?.focalLength) {
exifBadges.push(
<div
key="focalLength"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{`🔍 ${exifInfo.focalLength}`}
</div>,
)
}
const footerItems: JSX.Element[] = []
if (formattedDate) {
footerItems.push(
<div
key="date"
style={{
fontSize: '28px',
color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
{`📸 ${formattedDate}`}
</div>,
)
}
if (exifInfo?.camera) {
footerItems.push(
<div
key="camera"
style={{
fontSize: '25px',
color: 'rgba(255,255,255,0.6)',
letterSpacing: '0.3px',
display: 'flex',
}}
>
{`📷 ${exifInfo.camera}`}
</div>,
)
}
if (exifBadges.length > 0) {
footerItems.push(
<div
key="exif"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '18px',
fontSize: '25px',
color: 'rgba(255,255,255,0.8)',
}}
>
{exifBadges}
</div>,
)
}
const footer =
footerItems.length > 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '28px',
}}
>
{footerItems}
</div>
) : null
const thumbnailSrc =
thumbnailBuffer && this.arrayBufferToDataUri(thumbnailBuffer, this.inferThumbnailMimeType(photo.thumbnailUrl))
const leftFilmColumn = (
<div
style={{
position: 'absolute',
left: '0px',
top: '0px',
@@ -462,15 +613,15 @@ export class StaticOgService {
justifyContent: 'space-around',
paddingTop: '25px',
paddingBottom: '25px',
},
},
...filmHoles,
}}
>
{filmHoles}
</div>
)
const rightFilmColumn = h(
'div',
{
style: {
const rightFilmColumn = (
<div
style={{
position: 'absolute',
right: '0px',
top: '0px',
@@ -483,284 +634,42 @@ export class StaticOgService {
justifyContent: 'space-around',
paddingTop: '25px',
paddingBottom: '25px',
},
},
...filmHoles,
}}
>
{filmHoles}
</div>
)
const filmTexture = [
h(
'div',
{
style: {
position: 'absolute',
top: '0',
left: '30px',
width: `${layout.imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
},
},
null,
),
h(
'div',
{
style: {
position: 'absolute',
bottom: '0',
left: '30px',
width: `${layout.imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(0deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderTop: '1px solid rgba(255,255,255,0.05)',
},
},
null,
),
<div
key="film-texture-top"
style={{
position: 'absolute',
top: '0',
left: '30px',
width: `${layout.imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
/>,
<div
key="film-texture-bottom"
style={{
position: 'absolute',
bottom: '0',
left: '30px',
width: `${layout.imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(0deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderTop: '1px solid rgba(255,255,255,0.05)',
}}
/>,
]
const photoFrame =
photo.thumbnailUrl && thumbnailBuffer
? h(
'div',
{
style: {
position: 'absolute',
top: '75px',
right: '45px',
width: `${layout.frameWidth}px`,
height: `${layout.frameHeight}px`,
background: 'linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%)',
borderRadius: '6px',
border: '1px solid #2a2a2a',
boxShadow: '0 12px 48px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.03)',
display: 'flex',
overflow: 'hidden',
},
},
leftFilmColumn,
rightFilmColumn,
h(
'div',
{
style: {
position: 'absolute',
left: '30px',
top: '30px',
width: `${layout.imageAreaWidth}px`,
height: `${layout.imageAreaHeight}px`,
background: '#000',
borderRadius: '2px',
border: '2px solid #1a1a1a',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'inset 0 0 8px rgba(0,0,0,0.5)',
},
},
h(
'div',
{
style: {
position: 'relative',
width: `${layout.displayWidth}px`,
height: `${layout.displayHeight}px`,
overflow: 'hidden',
display: 'flex',
},
},
h(
'img',
{
src: thumbnailBuffer,
style: {
width: `${layout.displayWidth}px`,
height: `${layout.displayHeight}px`,
objectFit: 'cover',
},
},
null,
),
),
h(
'div',
{
style: {
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background:
'linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.06) 25%, transparent 45%, transparent 55%, rgba(255,255,255,0.03) 75%, transparent 100%)',
pointerEvents: 'none',
},
},
null,
),
...filmTexture,
),
)
: null
const footerItems: Array<OgElement | string> = []
if (formattedDate) {
footerItems.push(
h(
'div',
{
style: {
fontSize: '28px',
color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
gap: '12px',
},
},
`📸 ${formattedDate}`,
),
)
}
if (exifInfo?.camera) {
footerItems.push(
h(
'div',
{
style: {
fontSize: '25px',
color: 'rgba(255,255,255,0.6)',
letterSpacing: '0.3px',
display: 'flex',
},
},
`📷 ${exifInfo.camera}`,
),
)
}
if (exifInfo && (exifInfo.aperture || exifInfo.shutterSpeed || exifInfo.iso || exifInfo.focalLength)) {
const exifBadges: OgElement[] = []
if (exifInfo.aperture) {
exifBadges.push(
h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
},
},
`${exifInfo.aperture}`,
),
)
}
if (exifInfo.shutterSpeed) {
exifBadges.push(
h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
},
},
`⏱️ ${exifInfo.shutterSpeed}`,
),
)
}
if (exifInfo.iso) {
exifBadges.push(
h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
},
},
`📊 ISO ${exifInfo.iso}`,
),
)
}
if (exifInfo.focalLength) {
exifBadges.push(
h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
},
},
`🔍 ${exifInfo.focalLength}`,
),
)
}
footerItems.push(
h(
'div',
{
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '18px',
fontSize: '25px',
color: 'rgba(255,255,255,0.8)',
},
},
...exifBadges,
),
)
}
const footer = h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '28px',
},
},
...footerItems,
)
return h(
'div',
{
style: {
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
@@ -772,25 +681,22 @@ export class StaticOgService {
padding: '80px',
fontFamily: 'Geist, system-ui, -apple-system, sans-serif',
position: 'relative',
},
},
...decorativeOverlays,
...filmBorderStyles,
apertureDecor,
h(
'div',
{
style: {
}}
>
{decorativeOverlays}
{filmBorderElements}
{apertureDecor}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
maxWidth: '58%',
},
},
h(
'h1',
{
style: {
}}
>
<h1
style={{
fontSize: '80px',
fontWeight: 'bold',
color: 'white',
@@ -798,14 +704,13 @@ export class StaticOgService {
lineHeight: '1.1',
letterSpacing: '1px',
display: 'flex',
},
},
photo.title || 'Untitled Photo',
),
h(
'p',
{
style: {
}}
>
{photo.title || 'Untitled Photo'}
</h1>
<p
style={{
fontSize: '36px',
color: 'rgba(255,255,255,0.9)',
margin: '0 0 16px 0',
@@ -813,14 +718,101 @@ export class StaticOgService {
letterSpacing: '0.3px',
display: 'flex',
fontFamily: 'Geist, SF Pro Display',
},
},
photo.description || siteConfig.name || siteConfig.title,
),
tagElements,
),
photoFrame,
footer,
}}
>
{photo.description || siteConfig.name || siteConfig.title}
</p>
{tagElements.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
margin: '0 0 32px 0',
}}
>
{tagElements}
</div>
)}
</div>
{thumbnailSrc && (
<div
style={{
position: 'absolute',
top: '75px',
right: '45px',
width: `${layout.frameWidth}px`,
height: `${layout.frameHeight}px`,
background: 'linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%)',
borderRadius: '6px',
border: '1px solid #2a2a2a',
boxShadow: '0 12px 48px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.03)',
display: 'flex',
overflow: 'hidden',
}}
>
{leftFilmColumn}
{rightFilmColumn}
<div
style={{
position: 'absolute',
left: '30px',
top: '30px',
width: `${layout.imageAreaWidth}px`,
height: `${layout.imageAreaHeight}px`,
background: '#000',
borderRadius: '2px',
border: '2px solid #1a1a1a',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'inset 0 0 8px rgba(0,0,0,0.5)',
}}
>
<div
style={{
position: 'relative',
width: `${layout.displayWidth}px`,
height: `${layout.displayHeight}px`,
overflow: 'hidden',
display: 'flex',
}}
>
<img
src={thumbnailSrc}
alt={photo.title ?? 'Photo thumbnail'}
style={{
width: `${layout.displayWidth}px`,
height: `${layout.displayHeight}px`,
objectFit: 'cover',
}}
/>
</div>
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background:
'linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.06) 25%, transparent 45%, transparent 55%, rgba(255,255,255,0.03) 75%, transparent 100%)',
pointerEvents: 'none',
}}
/>
{filmTexture}
</div>
</div>
)}
{footer}
</div>
)
}
}

4
be/apps/core/src/types/assets.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.ttf?url' {
const url: string
export default url
}

25
be/apps/core/src/types/vercel-og.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
declare module '@vercel/og' {
import type { ReactElement } from 'react'
export type EmojiStyle = 'twemoji' | 'apple' | 'blobmoji' | 'noto'
export interface FontConfig {
name: string
data: ArrayBuffer
weight?: number
style?: 'normal' | 'italic'
}
export interface ImageResponseOptions {
width?: number
height?: number
emoji?: EmojiStyle
fonts?: FontConfig[]
headers?: Record<string, string>
debug?: boolean
}
export class ImageResponse extends Response {
constructor(element: ReactElement, options?: ImageResponseOptions)
}
}

View File

@@ -8,16 +8,11 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,
"jsx": "react",
"types": ["node"],
"paths": {
"core": ["./src"],
"core/*": ["./src/*"],
"@afilmory/db": ["../../packages/db/src"],
"@afilmory/db/*": ["../../packages/db/src/*"],
"@afilmory/be-utils": ["../../packages/utils/src"],
"@afilmory/be-utils/*": ["../../packages/utils/src/*"],
"@afilmory/websocket": ["../../packages/websocket/src"],
"@afilmory/websocket/*": ["../../packages/websocket/src/*"]
"core/*": ["./src/*"]
},
"resolveJsonModule": true,
"allowJs": true,