source: Klonkt/src/services/OgImageService.js@ ad10715

main
Last change on this file since ad10715 was 3b4095f, checked in by roboburr <roboburr@…>, 2 months ago

feat(seo): og:image light/dark override setting

The auto-generated share card followed the site's default theme (1.2.0); Admin →
SEO now has an explicit override: Auto (follow site theme) / Light / Dark.

  • src/config/database.js — sites.og_theme column (NULL = auto).
  • src/services/OgImageService.js — og_theme wins over theme_override when set.
  • src/routes/og.js — select og_theme for the card route.
  • src/routes/admin-seo.js — save og_theme (validated to light/dark, else auto).
  • src/views/pages/admin-seo.ejs — select under the share-image field.
  • src/services/i18n.js — aseo.og_theme* (nl/en/de).
  • CHANGELOG(.nl/.de).md — under Added.

Closes prutfolio-src-krm.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 5.4 KB
Line 
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 */
10import fs from 'fs';
11import path from 'path';
12import crypto from 'crypto';
13import { fileURLToPath } from 'url';
14import { createRequire } from 'module';
15import ThemeService from './ThemeService.js';
16
17const require = createRequire(import.meta.url);
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20const FONT = path.join(__dirname, '..', 'assets', 'fonts', 'fraunces-og.ttf');
21const DATA_DIR = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
22const CACHE_DIR = path.join(DATA_DIR, 'og');
23const TEMPLATE_VERSION = 1; // bump to invalidate all cached cards after a design change
24
25let _Resvg = null, _tried = false;
26function 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 ───────────────────────────────────────────
34function 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}
39function rgb(a) {
40 return '#' + a.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
41}
42function mix(a, b, t) { const A = hx(a), B = hx(b); return rgb(A.map((v, i) => v + (B[i] - v) * t)); }
43function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c])); }
44
45function 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 */
87export 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 // Card variant: an explicit SEO override (og_theme) wins; else follow the site's "default
93 // theme for new visitors" (theme_override) — light when the site is set to Light, otherwise
94 // dark (an OG image is static, so Auto/Dark → dark).
95 const theme = (site.og_theme === 'light' || site.og_theme === 'dark')
96 ? site.og_theme
97 : (site.theme_override === 'light' ? 'light' : 'dark');
98 const accent = site.accent || ((ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt)[theme] || ThemeService.PALETTES.klonkt.dark).accent;
99 const key = crypto.createHash('sha1')
100 .update([TEMPLATE_VERSION, site.slug, palette, accent, theme, site.title || '', site.tagline || site.description || ''].join('\x1f'))
101 .digest('hex').slice(0, 16);
102 const file = path.join(CACHE_DIR, key + '.png');
103
104 try { return fs.readFileSync(file); } catch { /* not cached yet */ }
105
106 try {
107 const svg = buildSvg(site, palette, accent, theme);
108 const png = new Resvg(svg, {
109 font: { fontFiles: [FONT], loadSystemFonts: false },
110 fitTo: { mode: 'width', value: 1200 },
111 }).render().asPng();
112 try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(file, png); } catch { /* cache best-effort */ }
113 return png;
114 } catch {
115 return null;
116 }
117}
118
119export default { ogImageFor };
Note: See TracBrowser for help on using the repository browser.