← back to Ventura Claw Leads
lib/og-image.js
95 lines
// Per-business Open Graph image generator. Renders a 1200x630 PNG with the
// business name in serif over the vertical's gradient banner, plus
// neighborhood and the venturaclaw.com wordmark.
//
// Render path: SVG -> sharp PNG. No external services, no fonts to ship —
// SVG uses generic font-family fallback chains so the image renders fine
// without our webfont stack.
let sharp;
try { sharp = require('sharp'); } catch (e) {
console.warn('[og-image] sharp not available; OG endpoint will 404');
}
const VERTICAL_GRADIENT = {
food: { from: '#fef3c7', to: '#f59e0b' },
beauty: { from: '#fce7f3', to: '#ec4899' },
retail: { from: '#ede9fe', to: '#8b5cf6' },
fitness: { from: '#d1fae5', to: '#10b981' },
pet_services: { from: '#fed7aa', to: '#f97316' },
auto_detail: { from: '#dbeafe', to: '#3b82f6' },
cleaning: { from: '#cffafe', to: '#06b6d4' },
creative: { from: '#fee2e2', to: '#ef4444' }
};
function escapeXml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
// Wrap business name across at most 2 lines, max ~22 chars per line.
// SVG <text> doesn't auto-wrap, so we break in JS and emit two <tspan>s.
function wrapName(name, maxPerLine = 22) {
const words = String(name || '').split(/\s+/).filter(Boolean);
if (words.length <= 1) return [name];
let line1 = '', line2 = '';
for (const w of words) {
if (!line1 || (line1 + ' ' + w).length <= maxPerLine) {
line1 = line1 ? line1 + ' ' + w : w;
} else {
line2 = line2 ? line2 + ' ' + w : w;
}
}
if (line2.length > maxPerLine) line2 = line2.slice(0, maxPerLine - 1) + '…';
return line2 ? [line1, line2] : [line1];
}
function buildSvg({ businessName, verticalKey, verticalLabel, neighborhood }) {
const grad = VERTICAL_GRADIENT[verticalKey] || { from: '#e5e7eb', to: '#6b7280' };
const nameLines = wrapName(businessName, 22);
const fontSize = nameLines.length === 2 ? 78 : 96;
const ny1 = nameLines.length === 2 ? 286 : 332;
const ny2 = ny1 + fontSize + 4;
const subtitleParts = [verticalLabel, neighborhood].filter(Boolean).join(' · ');
const tspans = nameLines.map((line, i) =>
`<tspan x="80" y="${i === 0 ? ny1 : ny2}">${escapeXml(line)}</tspan>`
).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${grad.from}"/>
<stop offset="100%" stop-color="${grad.to}"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#g)"/>
<rect x="60" y="60" width="1080" height="510" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.32)" stroke-width="2" rx="6"/>
<text x="80" y="160" fill="rgba(255,255,255,0.92)"
font-family="Cormorant Garamond, Georgia, serif" font-size="22"
letter-spacing="0.24em">VENTURA CLAW</text>
<text fill="#0e0e0e" font-family="Cormorant Garamond, Georgia, serif"
font-size="${fontSize}" font-weight="500">${tspans}</text>
<text x="80" y="500" fill="rgba(0,0,0,0.62)"
font-family="Inter, -apple-system, sans-serif" font-size="32"
letter-spacing="0.04em">${escapeXml(subtitleParts)}</text>
<text x="80" y="570" fill="rgba(255,255,255,0.78)"
font-family="Inter, -apple-system, sans-serif" font-size="20"
letter-spacing="0.16em">VENTURACLAW.COM</text>
</svg>`;
}
async function renderPng(opts) {
if (!sharp) throw new Error('sharp_unavailable');
const svg = buildSvg(opts);
const png = await sharp(Buffer.from(svg, 'utf8'))
.png({ compressionLevel: 9 })
.toBuffer();
return png;
}
module.exports = { renderPng, buildSvg, VERTICAL_GRADIENT };