SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
7.7 KB · 230 lines javascript
Raw Blame History
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