| 1 | /**
|
|---|
| 2 | * OgImageService — generates a themed Open Graph card (1200x630 PNG) per site,
|
|---|
| 3 | * derived from the site's palette + accent, so every site has a branded social
|
|---|
| 4 | * preview even without uploading one. SVG is hand-built and rasterized with
|
|---|
| 5 | * @resvg/resvg-js. Result is cached on disk (keyed by the theming inputs).
|
|---|
| 6 | *
|
|---|
| 7 | * Graceful: if @resvg/resvg-js can't load (exotic platform), ogImageFor()
|
|---|
| 8 | * returns null and the caller falls back to no/other og:image — never throws.
|
|---|
| 9 | */
|
|---|
| 10 | import fs from 'fs';
|
|---|
| 11 | import path from 'path';
|
|---|
| 12 | import crypto from 'crypto';
|
|---|
| 13 | import { fileURLToPath } from 'url';
|
|---|
| 14 | import { createRequire } from 'module';
|
|---|
| 15 | import ThemeService from './ThemeService.js';
|
|---|
| 16 |
|
|---|
| 17 | const require = createRequire(import.meta.url);
|
|---|
| 18 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 19 |
|
|---|
| 20 | const FONT = path.join(__dirname, '..', 'assets', 'fonts', 'fraunces-og.ttf');
|
|---|
| 21 | const DATA_DIR = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
|
|---|
| 22 | const CACHE_DIR = path.join(DATA_DIR, 'og');
|
|---|
| 23 | const TEMPLATE_VERSION = 1; // bump to invalidate all cached cards after a design change
|
|---|
| 24 |
|
|---|
| 25 | let _Resvg = null, _tried = false;
|
|---|
| 26 | function getResvg() {
|
|---|
| 27 | if (_tried) return _Resvg;
|
|---|
| 28 | _tried = true;
|
|---|
| 29 | try { _Resvg = require('@resvg/resvg-js').Resvg; } catch { _Resvg = null; }
|
|---|
| 30 | return _Resvg;
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| 33 | // ── tiny colour helpers ───────────────────────────────────────────
|
|---|
| 34 | function hx(h) {
|
|---|
| 35 | h = String(h || '').replace('#', '');
|
|---|
| 36 | if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|---|
| 37 | return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) || 0);
|
|---|
| 38 | }
|
|---|
| 39 | function rgb(a) {
|
|---|
| 40 | return '#' + a.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|---|
| 41 | }
|
|---|
| 42 | function mix(a, b, t) { const A = hx(a), B = hx(b); return rgb(A.map((v, i) => v + (B[i] - v) * t)); }
|
|---|
| 43 | function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); }
|
|---|
| 44 |
|
|---|
| 45 | function buildSvg(site, palette, accent, theme) {
|
|---|
| 46 | const _p = ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt;
|
|---|
| 47 | const pal = _p[theme] || _p.dark;
|
|---|
| 48 | const paper = pal.paper, ink = pal.ink;
|
|---|
| 49 | const paper2 = mix(paper, ink, 0.08);
|
|---|
| 50 | const muted = mix(ink, paper, 0.42);
|
|---|
| 51 |
|
|---|
| 52 | let title = (site.title || 'Klonkt').trim();
|
|---|
| 53 | let tag = (site.tagline || site.description || '').trim();
|
|---|
| 54 | if (title.length > 38) title = title.slice(0, 37) + '…';
|
|---|
| 55 | if (tag.length > 74) tag = tag.slice(0, 73) + '…';
|
|---|
| 56 | const tsize = title.length <= 12 ? 100 : title.length <= 20 ? 82 : title.length <= 30 ? 64 : 54;
|
|---|
| 57 |
|
|---|
| 58 | return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
|
|---|
| 59 | <defs>
|
|---|
| 60 | <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|---|
| 61 | <stop offset="0" stop-color="${paper}"/><stop offset="1" stop-color="${paper2}"/>
|
|---|
| 62 | </linearGradient>
|
|---|
| 63 | <radialGradient id="glow" cx="0.85" cy="0.12" r="0.7">
|
|---|
| 64 | <stop offset="0" stop-color="${accent}" stop-opacity="0.20"/>
|
|---|
| 65 | <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
|
|---|
| 66 | </radialGradient>
|
|---|
| 67 | </defs>
|
|---|
| 68 | <rect width="1200" height="630" fill="url(#bg)"/>
|
|---|
| 69 | <rect width="1200" height="630" fill="url(#glow)"/>
|
|---|
| 70 | <rect x="0" y="0" width="14" height="630" fill="${accent}"/>
|
|---|
| 71 | <g transform="translate(96,232)">
|
|---|
| 72 | <rect x="0" y="14" width="11" height="34" rx="3" fill="${accent}"/>
|
|---|
| 73 | <rect x="18" y="0" width="11" height="48" rx="3" fill="${accent}"/>
|
|---|
| 74 | <rect x="36" y="22" width="11" height="26" rx="3" fill="${accent}"/>
|
|---|
| 75 | <rect x="54" y="8" width="11" height="40" rx="3" fill="${accent}"/>
|
|---|
| 76 | </g>
|
|---|
| 77 | <text x="96" y="400" font-size="${tsize}" fill="${ink}">${esc(title)}</text>
|
|---|
| 78 | ${tag ? `<text x="98" y="462" font-size="34" fill="${muted}">${esc(tag)}</text>` : ''}
|
|---|
| 79 | <text x="96" y="566" font-size="30" fill="${accent}">klonkt</text>
|
|---|
| 80 | </svg>`;
|
|---|
| 81 | }
|
|---|
| 82 |
|
|---|
| 83 | /**
|
|---|
| 84 | * Returns a PNG Buffer of the site's OG card (cached), or null if generation
|
|---|
| 85 | * isn't possible. `site` needs: slug, title, palette, accent, tagline/description.
|
|---|
| 86 | */
|
|---|
| 87 | export function ogImageFor(site) {
|
|---|
| 88 | const Resvg = getResvg();
|
|---|
| 89 | if (!Resvg || !site || !site.slug) return null;
|
|---|
| 90 |
|
|---|
| 91 | const palette = ThemeService.PALETTES[site.palette] ? site.palette : 'klonkt';
|
|---|
| 92 | // Follow the site's "default theme for new visitors" (theme_override): a light card when the site
|
|---|
| 93 | // is set to Light, otherwise dark (an OG image is static, so Auto/Dark → dark).
|
|---|
| 94 | const theme = site.theme_override === 'light' ? 'light' : 'dark';
|
|---|
| 95 | const accent = site.accent || ((ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt)[theme] || ThemeService.PALETTES.klonkt.dark).accent;
|
|---|
| 96 | const key = crypto.createHash('sha1')
|
|---|
| 97 | .update([TEMPLATE_VERSION, site.slug, palette, accent, theme, site.title || '', site.tagline || site.description || ''].join('\x1f'))
|
|---|
| 98 | .digest('hex').slice(0, 16);
|
|---|
| 99 | const file = path.join(CACHE_DIR, key + '.png');
|
|---|
| 100 |
|
|---|
| 101 | try { return fs.readFileSync(file); } catch { /* not cached yet */ }
|
|---|
| 102 |
|
|---|
| 103 | try {
|
|---|
| 104 | const svg = buildSvg(site, palette, accent, theme);
|
|---|
| 105 | const png = new Resvg(svg, {
|
|---|
| 106 | font: { fontFiles: [FONT], loadSystemFonts: false },
|
|---|
| 107 | fitTo: { mode: 'width', value: 1200 },
|
|---|
| 108 | }).render().asPng();
|
|---|
| 109 | try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(file, png); } catch { /* cache best-effort */ }
|
|---|
| 110 | return png;
|
|---|
| 111 | } catch {
|
|---|
| 112 | return null;
|
|---|
| 113 | }
|
|---|
| 114 | }
|
|---|
| 115 |
|
|---|
| 116 | export default { ogImageFor };
|
|---|