/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/render/og-image.mjs * Purpose : Dynamic Open Graph cards per repo (satori → resvg → PNG) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import satori from 'satori'; import { Resvg } from '@resvg/resvg-js'; import { PROJECT_ROOT, OWNER } from '../config.mjs'; const FONT_DIR = join(PROJECT_ROOT, 'src/web/assets/fonts/og'); const WIDTH = 1200; const HEIGHT = 630; const PALETTE = { bg: '#0b0e14', surface: '#11151c', border: '#1f2530', text: '#e6e9ef', muted: '#8b93a3', accent: '#4f8cff', accent2: '#22d3aa', }; let fontsCache = null; /** @returns {Array<{name: string, data: Buffer, weight: number, style: 'normal'}>|null} */ function loadFonts() { if (fontsCache) return fontsCache; const specs = [ ['inter-400.woff', 'Inter', 400], ['inter-700.woff', 'Inter', 700], ['jetbrains-mono-400.woff', 'JetBrains Mono', 400], ]; const fonts = []; for (const [file, name, weight] of specs) { const path = join(FONT_DIR, file); if (!existsSync(path)) return null; fonts.push({ name, data: readFileSync(path), weight, style: 'normal' }); } fontsCache = fonts; return fonts; } /** Shorthand for satori element objects. */ function h(type, style, ...children) { const props = { style }; if (children.length > 0) props.children = children.length === 1 ? children[0] : children; return { type, props }; } /** * Compose the repo card element tree. * @param {{name: string, description: string, languages: Array<{name: string, percent: number, color: string}>}} data */ function repoCardTree(data) { const description = data.description || 'A repository by Simon-Pierre Boucher'; const langs = (data.languages ?? []).slice(0, 5); const bar = h( 'div', { display: 'flex', width: '100%', height: 14, borderRadius: 7, overflow: 'hidden', backgroundColor: PALETTE.border }, ...(langs.length > 0 ? langs.map((l) => h('div', { width: `${Math.max(2, l.percent)}%`, height: '100%', backgroundColor: l.color })) : [h('div', { width: '100%', height: '100%', backgroundColor: PALETTE.border })]), ); const legend = h( 'div', { display: 'flex', gap: 24, marginTop: 18, fontSize: 22, color: PALETTE.muted, fontFamily: 'JetBrains Mono' }, ...langs.map((l) => h( 'div', { display: 'flex', alignItems: 'center', gap: 8 }, h('div', { width: 14, height: 14, borderRadius: 7, backgroundColor: l.color }), h('div', {}, `${l.name} ${l.percent}%`), ), ), ); return h( 'div', { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', backgroundColor: PALETTE.bg, padding: 64, fontFamily: 'Inter', borderBottom: `14px solid ${PALETTE.accent}`, }, h( 'div', { display: 'flex', alignItems: 'center', gap: 20 }, h( 'div', { width: 64, height: 64, borderRadius: 16, backgroundColor: PALETTE.accent, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, fontWeight: 700, }, 'SPB', ), h('div', { fontSize: 34, fontWeight: 700, color: PALETTE.text }, 'SPB Git'), h('div', { fontSize: 26, color: PALETTE.muted, marginLeft: 8 }, '· git.spboucher.ai'), ), h( 'div', { display: 'flex', flexDirection: 'column', gap: 22 }, h('div', { fontSize: 72, fontWeight: 700, color: PALETTE.text, lineHeight: 1.1 }, truncate(data.name, 28)), h('div', { fontSize: 32, color: PALETTE.muted, lineHeight: 1.4 }, truncate(description, 110)), ), h('div', { display: 'flex', flexDirection: 'column' }, bar, legend), ); } /** Home-page / fallback card. */ function siteCardTree() { return h( 'div', { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', gap: 26, backgroundColor: PALETTE.bg, fontFamily: 'Inter', borderBottom: `14px solid ${PALETTE.accent}`, }, h( 'div', { width: 110, height: 110, borderRadius: 28, backgroundColor: PALETTE.accent, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 44, fontWeight: 700, }, 'SPB', ), h('div', { fontSize: 70, fontWeight: 700, color: PALETTE.text }, 'SPB Git'), h('div', { fontSize: 32, color: PALETTE.muted }, `${OWNER.name} · ${OWNER.email}`), h('div', { fontSize: 28, color: PALETTE.accent2, fontFamily: 'JetBrains Mono' }, 'git.spboucher.ai'), ); } function truncate(text, max) { const s = String(text ?? ''); return s.length > max ? `${s.slice(0, max - 1)}…` : s; } /** Minimal static SVG used when fonts were never downloaded. */ function fallbackSvg(title) { const safe = String(title).replace(/[<>&"]/g, ''); return `${safe}git.spboucher.ai`; } /** * Render an OG card PNG. * @param {{name: string, description?: string, languages?: object[]}|null} repoData null → site card * @returns {Promise<{buffer: Buffer, type: string}>} */ export async function renderOgImage(repoData) { const fonts = loadFonts(); let svg; if (fonts) { const tree = repoData ? repoCardTree(repoData) : siteCardTree(); svg = await satori(tree, { width: WIDTH, height: HEIGHT, fonts }); } else { svg = fallbackSvg(repoData?.name ?? 'SPB Git'); } const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } }); return { buffer: resvg.render().asPng(), type: 'image/png' }; } /** * Render the SPB monogram as a PNG favicon/app icon. * @param {number} size * @returns {Promise} */ export async function renderMonogramPng(size) { const fonts = loadFonts(); const svg = fonts ? await satori( h( 'div', { width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: PALETTE.accent, borderRadius: Math.round(size * 0.22), color: '#ffffff', fontFamily: 'Inter', fontWeight: 700, fontSize: Math.round(size * 0.42), }, 'SPB', ), { width: size, height: size, fonts }, ) : ``; const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: size } }); return resvg.render().asPng(); }