/**
* ─────────────────────────────────────────────
* 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 ``;
}
/**
* 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();
}