← 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}

// 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 };