spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/render/og-image.mjs8 * Purpose : Dynamic Open Graph cards per repo (satori → resvg → PNG)9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { readFileSync, existsSync } from 'node:fs';14import { join } from 'node:path';15import satori from 'satori';16import { Resvg } from '@resvg/resvg-js';17import { PROJECT_ROOT, OWNER } from '../config.mjs';1819const FONT_DIR = join(PROJECT_ROOT, 'src/web/assets/fonts/og');20const WIDTH = 1200;21const HEIGHT = 630;2223const PALETTE = {24 bg: '#0b0e14',25 surface: '#11151c',26 border: '#1f2530',27 text: '#e6e9ef',28 muted: '#8b93a3',29 accent: '#4f8cff',30 accent2: '#22d3aa',31};3233let fontsCache = null;3435/** @returns {Array<{name: string, data: Buffer, weight: number, style: 'normal'}>|null} */36function loadFonts() {37 if (fontsCache) return fontsCache;38 const specs = [39 ['inter-400.woff', 'Inter', 400],40 ['inter-700.woff', 'Inter', 700],41 ['jetbrains-mono-400.woff', 'JetBrains Mono', 400],42 ];43 const fonts = [];44 for (const [file, name, weight] of specs) {45 const path = join(FONT_DIR, file);46 if (!existsSync(path)) return null;47 fonts.push({ name, data: readFileSync(path), weight, style: 'normal' });48 }49 fontsCache = fonts;50 return fonts;51}5253/** Shorthand for satori element objects. */54function h(type, style, ...children) {55 const props = { style };56 if (children.length > 0) props.children = children.length === 1 ? children[0] : children;57 return { type, props };58}5960/**61 * Compose the repo card element tree.62 * @param {{name: string, description: string, languages: Array<{name: string, percent: number, color: string}>}} data63 */64function repoCardTree(data) {65 const description = data.description || 'A repository by Simon-Pierre Boucher';66 const langs = (data.languages ?? []).slice(0, 5);67 const bar = h(68 'div',69 { display: 'flex', width: '100%', height: 14, borderRadius: 7, overflow: 'hidden', backgroundColor: PALETTE.border },70 ...(langs.length > 071 ? langs.map((l) => h('div', { width: `${Math.max(2, l.percent)}%`, height: '100%', backgroundColor: l.color }))72 : [h('div', { width: '100%', height: '100%', backgroundColor: PALETTE.border })]),73 );74 const legend = h(75 'div',76 { display: 'flex', gap: 24, marginTop: 18, fontSize: 22, color: PALETTE.muted, fontFamily: 'JetBrains Mono' },77 ...langs.map((l) =>78 h(79 'div',80 { display: 'flex', alignItems: 'center', gap: 8 },81 h('div', { width: 14, height: 14, borderRadius: 7, backgroundColor: l.color }),82 h('div', {}, `${l.name} ${l.percent}%`),83 ),84 ),85 );86 return h(87 'div',88 {89 width: '100%',90 height: '100%',91 display: 'flex',92 flexDirection: 'column',93 justifyContent: 'space-between',94 backgroundColor: PALETTE.bg,95 padding: 64,96 fontFamily: 'Inter',97 borderBottom: `14px solid ${PALETTE.accent}`,98 },99 h(100 'div',101 { display: 'flex', alignItems: 'center', gap: 20 },102 h(103 'div',104 {105 width: 64,106 height: 64,107 borderRadius: 16,108 backgroundColor: PALETTE.accent,109 color: '#ffffff',110 display: 'flex',111 alignItems: 'center',112 justifyContent: 'center',113 fontSize: 28,114 fontWeight: 700,115 },116 'SPB',117 ),118 h('div', { fontSize: 34, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),119 h('div', { fontSize: 26, color: PALETTE.muted, marginLeft: 8 }, '· git.spboucher.ai'),120 ),121 h(122 'div',123 { display: 'flex', flexDirection: 'column', gap: 22 },124 h('div', { fontSize: 72, fontWeight: 700, color: PALETTE.text, lineHeight: 1.1 }, truncate(data.name, 28)),125 h('div', { fontSize: 32, color: PALETTE.muted, lineHeight: 1.4 }, truncate(description, 110)),126 ),127 h('div', { display: 'flex', flexDirection: 'column' }, bar, legend),128 );129}130131/** Home-page / fallback card. */132function siteCardTree() {133 return h(134 'div',135 {136 width: '100%',137 height: '100%',138 display: 'flex',139 flexDirection: 'column',140 justifyContent: 'center',141 alignItems: 'center',142 gap: 26,143 backgroundColor: PALETTE.bg,144 fontFamily: 'Inter',145 borderBottom: `14px solid ${PALETTE.accent}`,146 },147 h(148 'div',149 {150 width: 110,151 height: 110,152 borderRadius: 28,153 backgroundColor: PALETTE.accent,154 color: '#ffffff',155 display: 'flex',156 alignItems: 'center',157 justifyContent: 'center',158 fontSize: 44,159 fontWeight: 700,160 },161 'SPB',162 ),163 h('div', { fontSize: 70, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),164 h('div', { fontSize: 32, color: PALETTE.muted }, `${OWNER.name} · ${OWNER.email}`),165 h('div', { fontSize: 28, color: PALETTE.accent2, fontFamily: 'JetBrains Mono' }, 'git.spboucher.ai'),166 );167}168169function truncate(text, max) {170 const s = String(text ?? '');171 return s.length > max ? `${s.slice(0, max - 1)}…` : s;172}173174/** Minimal static SVG used when fonts were never downloaded. */175function fallbackSvg(title) {176 const safe = String(title).replace(/[<>&"]/g, '');177 return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="${PALETTE.bg}"/><rect y="${HEIGHT - 14}" width="${WIDTH}" height="14" fill="${PALETTE.accent}"/><text x="64" y="330" font-family="sans-serif" font-size="72" font-weight="bold" fill="${PALETTE.text}">${safe}</text><text x="64" y="400" font-family="sans-serif" font-size="30" fill="${PALETTE.muted}">git.spboucher.ai</text></svg>`;178}179180/**181 * Render an OG card PNG.182 * @param {{name: string, description?: string, languages?: object[]}|null} repoData null → site card183 * @returns {Promise<{buffer: Buffer, type: string}>}184 */185export async function renderOgImage(repoData) {186 const fonts = loadFonts();187 let svg;188 if (fonts) {189 const tree = repoData ? repoCardTree(repoData) : siteCardTree();190 svg = await satori(tree, { width: WIDTH, height: HEIGHT, fonts });191 } else {192 svg = fallbackSvg(repoData?.name ?? 'SPB Git');193 }194 const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } });195 return { buffer: resvg.render().asPng(), type: 'image/png' };196}197198/**199 * Render the SPB monogram as a PNG favicon/app icon.200 * @param {number} size201 * @returns {Promise<Buffer>}202 */203export async function renderMonogramPng(size) {204 const fonts = loadFonts();205 const svg = fonts206 ? await satori(207 h(208 'div',209 {210 width: '100%',211 height: '100%',212 display: 'flex',213 alignItems: 'center',214 justifyContent: 'center',215 backgroundColor: PALETTE.accent,216 borderRadius: Math.round(size * 0.22),217 color: '#ffffff',218 fontFamily: 'Inter',219 fontWeight: 700,220 fontSize: Math.round(size * 0.42),221 },222 'SPB',223 ),224 { width: size, height: size, fonts },225 )226 : `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" rx="${Math.round(size * 0.22)}" fill="${PALETTE.accent}"/></svg>`;227 const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: size } });228 return resvg.render().asPng();229}230