diff --git a/src/pages/social-cards/[slug].png.ts b/src/pages/social-cards/[slug].png.ts index cfd06d1e..fe36715a 100644 --- a/src/pages/social-cards/[slug].png.ts +++ b/src/pages/social-cards/[slug].png.ts @@ -6,8 +6,11 @@ import { html } from 'satori-html' import { dateString, getSortedPosts, resolveThemeColorStyles } from '~/utils' import path from 'path' import fs from 'fs' +import sharp from 'sharp' import type { ReactNode } from 'react' +const COVER_IMAGE_SIZE = 300 + // Load the font file as binary data const fontPath = path.resolve( './node_modules/@expo-google-fonts/jetbrains-mono/400Regular/JetBrainsMono_400Regular.ttf', @@ -15,16 +18,10 @@ const fontPath = path.resolve( const fontData = fs.readFileSync(fontPath) // Reads the file as a Buffer const avatarPath = path.resolve(siteConfig.socialCardAvatarImage) -let avatarData: Buffer | undefined -let avatarBase64: string | undefined -if ( - fs.existsSync(avatarPath) && - (path.extname(avatarPath).toLowerCase() === '.jpg' || - path.extname(avatarPath).toLowerCase() === '.jpeg') -) { - avatarData = fs.readFileSync(avatarPath) - avatarBase64 = `data:image/jpeg;base64,${avatarData.toString('base64')}` -} +const avatarBase64 = + fs.existsSync(avatarPath) ? + await convertImageToBase64(avatarPath) : + undefined const defaultTheme = siteConfig.themes.default === 'auto' @@ -40,6 +37,19 @@ if (!bg || !fg || !accent) { throw new Error(`Theme ${defaultTheme} does not have required colors`) } +async function convertImageToBase64(imagePath: string): Promise { + const imageBuffer = await sharp(imagePath) + .resize(COVER_IMAGE_SIZE, COVER_IMAGE_SIZE, { fit: 'cover' }) + .png({ + compressionLevel: 9, + quality: 80, + progressive: true + }) + .toBuffer() + + return `data:image/png;base64,${imageBuffer.toString('base64')}` +} + const ogOptions: SatoriOptions = { // debug: true, fonts: [ @@ -54,29 +64,103 @@ const ogOptions: SatoriOptions = { width: 1200, } -const markup = (title: string, pubDate: string | undefined, author: string) => - html(`
-
- ${ - avatarBase64 - ? `
- -
` - : '' - } -
- ${pubDate ? `

${pubDate}

` : ''} -

${title}

- ${author !== title ? `

${author}

` : ''} +type Props = InferGetStaticPropsType + +// for some reason the path metadata doesn't contain the original path to the cover image +// instead it has only a reference to the "dist" directory of the build +// since we don't want to rely on the location of that directory and on the timing of when the image +// appears there, we try to recreate the original image path by inspecting the assets and matching them +// to the mangled name we got from the post +function findOriginalImagePath(mangledSrc: string, assetImports: string[], postDir: string): string | null { + // Extract the filename without hash from the mangled path + // e.g., "/_astro/cover.DX2hdcLU.png" -> "cover.png" + const mangledFilename = path.basename(mangledSrc) + const originalName = mangledFilename.replace(/\.[A-Za-z0-9_-]+\./, '.') + + // Find matching asset import + const matchingAsset = assetImports.find(assetPath => + path.basename(assetPath) === originalName + ) + + return matchingAsset ? path.resolve(postDir, matchingAsset) : null +} + +async function getCoverImage(coverImageSrc: string | undefined, assetImports: string[], postDir: string) { + if (!coverImageSrc) return undefined + + const originalPath = findOriginalImagePath(coverImageSrc, assetImports, postDir) + if (!originalPath || !fs.existsSync(originalPath)) { + console.warn(`Could not find original image for ${coverImageSrc}`) + return undefined + } + + return convertImageToBase64(originalPath) +} + +const getMarkup = ({ + title, + pubDate, + author, + coverImageBase64, + avatarBase64, + bg, + fg, + accent, + imageSize }: SocialCardProps) => { + + // this formatting does not look good... + const titleFontSize = + title.length > 80 ? 'text-3xl' : + title.length > 60 ? 'text-4xl' : + title.length > 40 ? 'text-5xl' : + 'text-6xl' + + const image = + coverImageBase64 ? ` +
+ +
`: + avatarBase64 ? ` +
+ +
`: '' + + return html(` +
+ +
+ + + ${pubDate ? `

${pubDate}

` : ''} + + +
+ ${image} +
+

${title}

+ ${author !== title ? `

${author}

` : ''} +
-
`) + `) +} +export async function GET(context: APIContext) { + const { pubDate, title, author, coverImage, assetImports, postDir } = context.props as Props + const coverImageBase64 = await getCoverImage(coverImage, assetImports, postDir) -type Props = InferGetStaticPropsType + const markup = getMarkup({ + title, + pubDate, + author, + coverImageBase64, + avatarBase64, + bg, + fg, + accent, + imageSize: COVER_IMAGE_SIZE + }) -export async function GET(context: APIContext) { - const { pubDate, title, author } = context.props as Props - const svg = await satori(markup(title, pubDate, author) as ReactNode, ogOptions) + const svg = await satori(markup as ReactNode, ogOptions) const png = new Resvg(svg).render().asPng() return new Response(png, { headers: { @@ -88,19 +172,46 @@ export async function GET(context: APIContext) { export async function getStaticPaths() { const posts = await getSortedPosts() - return posts - .map((post) => ({ + + const postPaths = posts.map(post => { + const postDir = path.dirname(path.resolve(post.filePath)) + + return { params: { slug: post.id }, props: { pubDate: post.data.published ? dateString(post.data.published) : undefined, title: post.data.title, author: post.data.author || siteConfig.author, + coverImage: post.data.coverImage?.src.src, + assetImports: post.assetImports || [], + postDir, }, - })) - .concat([ - { - params: { slug: '__default' }, - props: { pubDate: undefined, title: siteConfig.title, author: siteConfig.author }, - }, - ]) + } + }) + + const defaultPath = { + params: { slug: '__default' }, + props: { + pubDate: undefined, + title: siteConfig.title, + author: siteConfig.author, + coverImage: undefined, + assetImports: [], + postDir: '', + }, + } + + return [...postPaths, defaultPath] +} + +interface SocialCardProps { + title: string + pubDate?: string + author: string + coverImageBase64?: string + avatarBase64?: string + bg?: string + fg?: string + accent?: string + imageSize: number }