[object Object]

← back to Ventura Claw Leads

feat(seo): per-business dynamic Open Graph images

d818054413cbff08a409fe64eab9be0dd06a370f · 2026-05-07 08:34:08 -0700 · Steve Abrams

- new lib/og-image.js builds 1200x630 SVG (vertical-keyed gradient + business
  name + neighborhood + venturaclaw wordmark) and renders to PNG via sharp
- new GET /business/:slug/og.png route (24h immutable cache, 404 on bad slug)
- business page passes ogImage = photo_path absolute URL OR /og.png fallback
- head.ejs already supports ogImage; business.ejs include block now resolves
  the absolute URL at the route layer rather than in the template
- 92KB output, 46/46 tests stable, deployed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d818054413cbff08a409fe64eab9be0dd06a370f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 7 08:34:08 2026 -0700

    feat(seo): per-business dynamic Open Graph images
    
    - new lib/og-image.js builds 1200x630 SVG (vertical-keyed gradient + business
      name + neighborhood + venturaclaw wordmark) and renders to PNG via sharp
    - new GET /business/:slug/og.png route (24h immutable cache, 404 on bad slug)
    - business page passes ogImage = photo_path absolute URL OR /og.png fallback
    - head.ejs already supports ogImage; business.ejs include block now resolves
      the absolute URL at the route layer rather than in the template
    - 92KB output, 46/46 tests stable, deployed
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/og-image.js           | 94 +++++++++++++++++++++++++++++++++++++++++++++++
 routes/public.js          | 40 +++++++++++++++++++-
 views/public/business.ejs |  4 +-
 3 files changed, 134 insertions(+), 4 deletions(-)

diff --git a/lib/og-image.js b/lib/og-image.js
new file mode 100644
index 0000000..4ce2a53
--- /dev/null
+++ b/lib/og-image.js
@@ -0,0 +1,94 @@
+// 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 };
diff --git a/routes/public.js b/routes/public.js
index f2c951e..dd5ca3d 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -217,17 +217,55 @@ router.get('/business/:slug', async (req, res, next) => {
       `, [biz.id, biz.vertical, biz.latitude, biz.longitude]);
     }
 
+    // Open Graph image: prefer the business's own photo when present (better
+    // social-share preview), else fall back to the dynamically rendered
+    // /og.png card. Both are absolute URLs because OG crawlers don't run JS
+    // and need fully-qualified hrefs.
+    const _publicUrl = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+    const ogImage = biz.photo_path
+      ? (biz.photo_path.startsWith('http') ? biz.photo_path : _publicUrl + biz.photo_path)
+      : `${_publicUrl}/business/${encodeURIComponent(biz.slug)}/og.png`;
+
     res.render('public/business', {
       title: `${biz.business_name} · ${BY_KEY[biz.vertical]?.label || biz.vertical} on Ventura Blvd`,
       metaDescription: biz.headline || `${biz.business_name} — ${BY_KEY[biz.vertical]?.label || biz.vertical} in ${biz.neighborhood || biz.city || 'the Ventura Blvd corridor'}.`,
       business: biz,
       verticalMeta: BY_KEY[biz.vertical] || null,
       activity, showActivity, nearby,
-      verticals: VERTICALS
+      verticals: VERTICALS,
+      ogImage
     });
   } catch (err) { next(err); }
 });
 
+// Dynamic OG image. 24h CDN cache. Generated PNG never carries personal
+// data — just business name, vertical, neighborhood — so caching is safe.
+router.get('/business/:slug/og.png', async (req, res, next) => {
+  try {
+    const biz = await db.one(`
+      SELECT slug, business_name, vertical, neighborhood
+        FROM businesses WHERE slug = $1 AND status = 'active'
+    `, [req.params.slug]);
+    if (!biz) return res.status(404).type('text/plain').send('not_found');
+
+    const og = require('../lib/og-image');
+    const png = await og.renderPng({
+      businessName: biz.business_name,
+      verticalKey: biz.vertical,
+      verticalLabel: BY_KEY[biz.vertical]?.label || biz.vertical,
+      neighborhood: biz.neighborhood || ''
+    });
+    res.set('Content-Type', 'image/png');
+    res.set('Cache-Control', 'public, max-age=86400, immutable');
+    res.send(png);
+  } catch (err) {
+    if (err && err.message === 'sharp_unavailable') {
+      return res.status(503).type('text/plain').send('og_unavailable');
+    }
+    next(err);
+  }
+});
+
 router.post('/business/:slug/contact', async (req, res, next) => {
   try {
     const biz = await db.one(`SELECT id, slug, business_name, claim_status, status FROM businesses WHERE slug = $1`, [req.params.slug]);
diff --git a/views/public/business.ejs b/views/public/business.ejs
index 4ec2310..7a78159 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -1,8 +1,6 @@
 <%- include('../partials/head', {
   title,
-  ogImage: business.photo_path
-    ? ((typeof publicUrl !== 'undefined' && publicUrl ? publicUrl.replace(/\/+$/, '') : 'https://leads.venturaclaw.com') + business.photo_path)
-    : null
+  ogImage: typeof ogImage !== 'undefined' && ogImage ? ogImage : null
 }) %>
 <%- include('../partials/header') %>
 

← c5e7571 feat(admin): surface share-count on dashboard stat row  ·  back to Ventura Claw Leads  ·  feat(seo): image sitemap support for Google Image search 92a37d6 →