/** * Share images (Open Graph / Twitter), rendered by Satori through `next/og`. * * One render function per family — `renderSiteOg`, `renderCompanyOg`, `renderTopicOg` — used by thin route files * (`opengraph-image.tsx` + `twitter-image.tsx` at the root, under `/company/[slug]`, `/industry/[slug]`, * `/country/[code]`). Route files export only `alt`/`size`/`contentType` + the default function; nothing else is * re-exported between them (re-exporting `runtime` between sibling metadata routes broke sibling builds). * * Satori rules honoured here: every box is `display:flex`; a div never mixes several text children (wrap in spans); * images are data URIs; fonts are Geist TTFs read from the `geist` package at first use. */ import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { geoGraticule, geoOrthographic, geoPath } from 'd3-geo'; import { ImageResponse } from 'next/og'; import { feature } from 'topojson-client'; import type { GeometryCollection, Topology } from 'topojson-specification'; import land110 from 'world-atlas/land-110m.json'; import { MARK_DARK, MarkImg } from '@/components/brand/mark'; import { API_URL } from '@/lib/api'; import { countryName } from '@/lib/countries'; import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format'; import { logoCandidates, monogram } from '@/lib/profile'; import { HOST_NAME, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site'; import type { CompanyDetail, Stats } from '@/lib/types'; export const OG_SIZE = { width: 1200, height: 630 }; export const OG_CONTENT_TYPE = 'image/png'; export const OG_ALT = `${SITE_NAME} — ${TAGLINE}`; /* Palette: the dark theme tokens of globals.css, hard-coded (Satori has no CSS variables). */ const P = { bg: '#0a0d12', surface: '#11151c', surface2: '#171c25', ink: '#e8ebf1', ink2: '#a3a9b8', ink3: '#6f7688', rule: 'rgba(190,200,225,0.14)', ruleStrong: 'rgba(190,200,225,0.26)', accent: '#6d95ff', live: '#3fd07a', positive: '#3fd07a', danger: '#ff6b74', }; const SANS = 'Geist'; const MONO = 'Geist Mono'; const HOST = SITE_URL.replace(/^https?:\/\//, ''); /* ------------------------------------------------------------------------------------------------------------------ */ /* Fonts */ /* ------------------------------------------------------------------------------------------------------------------ */ type Font = { name: string; data: ArrayBuffer; weight: 400 | 500 | 600; style: 'normal' }; function geistFontsDir(): string | null { const cwd = process.cwd(); for (const base of [cwd, path.join(cwd, 'apps', 'web'), path.resolve(cwd, '..', '..')]) { const p = path.join(base, 'node_modules', 'geist', 'dist', 'fonts'); if (existsSync(p)) return p; } try { const req = createRequire(path.join(cwd, 'index.js')); return path.join(path.dirname(req.resolve('geist/font')), 'fonts'); } catch { return null; } } let fontsPromise: Promise | null = null; /** Geist Regular / Medium / SemiBold + Geist Mono Medium. Missing files degrade to Satori's default font. */ export function loadFonts(): Promise { fontsPromise ??= (async () => { const dir = geistFontsDir(); if (!dir) return []; const wanted: [string, string, Font['weight']][] = [ ['geist-sans/Geist-Regular.ttf', SANS, 400], ['geist-sans/Geist-Medium.ttf', SANS, 500], ['geist-sans/Geist-SemiBold.ttf', SANS, 600], ['geist-mono/GeistMono-Medium.ttf', MONO, 500], ]; const out: Font[] = []; for (const [file, name, weight] of wanted) { try { // Runtime lookup on purpose (fonts come from node_modules/geist, which is not deployed as a traced asset); plain // string concatenation keeps Turbopack's output-file tracing from pulling the whole project in. const buf = await fs.readFile(/*turbopackIgnore: true*/ `${dir}/${file}`); out.push({ name, data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer, weight, style: 'normal' }); } catch { /* font missing → skip */ } } return out; })(); return fontsPromise; } async function respond(node: React.ReactElement): Promise { const fonts = await loadFonts(); return new ImageResponse(node, { ...OG_SIZE, fonts: fonts.length ? fonts : undefined }); } /* ------------------------------------------------------------------------------------------------------------------ */ /* Globe wireframe (orthographic land + graticule) — the "atlas" backdrop on the right of every share image */ /* ------------------------------------------------------------------------------------------------------------------ */ let globeCache: string | null = null; function globeDataUri(): string { if (globeCache) return globeCache; const W = 760; const H = 760; const R = 340; const projection = geoOrthographic() .rotate([-12, -28, 0]) .scale(R) .translate([W / 2, H / 2]) .clipAngle(90); const pathOf = geoPath(projection); const topo = land110 as unknown as Topology<{ land: GeometryCollection }>; const land = feature(topo, topo.objects.land); const graticule = geoGraticule().step([15, 15])(); const sphere = pathOf({ type: 'Sphere' }) ?? ''; const svg = `` + `` + `` + `` + `` + `` + ``; globeCache = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; return globeCache; } /* ------------------------------------------------------------------------------------------------------------------ */ /* Building blocks */ /* ------------------------------------------------------------------------------------------------------------------ */ type Style = React.CSSProperties; const row = (extra?: Style): Style => ({ display: 'flex', flexDirection: 'row', alignItems: 'center', ...extra }); const col = (extra?: Style): Style => ({ display: 'flex', flexDirection: 'column', ...extra }); function Frame({ children, globe = 'right' }: { children: React.ReactNode; globe?: 'right' | 'corner' | 'none' }) { return (
{globe !== 'none' && ( // eslint-disable-next-line @next/next/no-img-element )}
{children}
); } /** Mark + wordmark, in Geist: "Company" regular ink-2, "Atlas" semibold ink. */ function Lockup({ markPx = 44, fontSize = 26 }: { markPx?: number; fontSize?: number }) { return (
Company Atlas
); } function LivePill({ label = 'LIVE' }: { label?: string }) { return (
{label}
); } function Eyebrow({ children, color = P.ink3 }: { children: string; color?: string }) { return {children}; } function Counter({ label, value, tone }: { label: string; value: string; tone?: string }) { return (
{label} {value}
); } function Footer({ left }: { left?: string }) { return (
{left ?? HOST}
Hosted on {HOST_NAME}
); } /* ------------------------------------------------------------------------------------------------------------------ */ /* 1. Site */ /* ------------------------------------------------------------------------------------------------------------------ */ export async function renderSiteOg(stats: Stats | null): Promise { const counters: [string, string][] = stats ? [ ['Companies', fmtInt(stats.companies)], ['Sensors', fmtInt(stats.sensors)], ['Observations', fmtInt(stats.observations)], ['Events', fmtInt(stats.events)], ] : []; return respond(
Continuous corporate observation network {TAGLINE} Public-web sensors on thousands of companies: products, hiring, pricing, leadership, locations and technology — observed, versioned, never overwritten.
{counters.length > 0 && (
{counters.map(([label, value]) => ( ))}
)}
, ); } /* ------------------------------------------------------------------------------------------------------------------ */ /* 2. Company */ /* ------------------------------------------------------------------------------------------------------------------ */ const LOGO_TIMEOUT_MS = 2500; const LOGO_MAX_BYTES = 1_500_000; const LOGO_TYPES = new Set(['image/png', 'image/jpeg', 'image/svg+xml']); /** True for URLs the server may fetch: http(s), public hostnames (no IP literals / localhost) — the API host excepted (mock logos in dev). */ function logoUrlAllowed(u: URL): boolean { if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; if (u.origin === new URL(API_URL).origin) return true; const h = u.hostname.toLowerCase(); if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.localhost')) return false; if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.startsWith('[') || h.includes(':')) return false; return true; } /** Fetches the first loadable company logo as a data URI (Satori cannot load remote images reliably); null on any problem. */ export async function fetchLogoDataUri(candidates: string[]): Promise { for (const c of candidates.slice(0, 3)) { try { const u = new URL(c); if (!logoUrlAllowed(u)) continue; const res = await fetch(u, { signal: AbortSignal.timeout(LOGO_TIMEOUT_MS), headers: { accept: 'image/png,image/jpeg,image/svg+xml', 'user-agent': 'CompanyAtlasBot/1.0 (+https://www.company-atlas.co/bot; share-image)' }, redirect: 'follow', next: { revalidate: 86400 } }); if (!res.ok) continue; const type = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase(); if (!LOGO_TYPES.has(type)) continue; const len = Number(res.headers.get('content-length') ?? 0); if (len > LOGO_MAX_BYTES) continue; const buf = Buffer.from(await res.arrayBuffer()); if (!buf.length || buf.length > LOGO_MAX_BYTES) continue; return `data:${type};base64,${buf.toString('base64')}`; } catch { /* try next candidate */ } } return null; } function LogoTile({ name, dataUri, px }: { name: string; dataUri: string | null; px: number }) { if (dataUri) { return (
{/* eslint-disable-next-line @next/next/no-img-element */}
); } return (
{monogram(name)}
); } function Fact({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function MetricTile({ label, value, hint, tone }: { label: string; value: string; hint: string; tone?: string }) { return (
{label} {value} {hint}
); } function clampName(n: string): { text: string; size: number } { const text = n.length > 42 ? `${n.slice(0, 41).trimEnd()}…` : n; return { text, size: text.length > 28 ? 42 : text.length > 18 ? 52 : 64 }; } export async function renderCompanyOg(c: CompanyDetail): Promise { const p = c.profile ?? null; const logo = await fetchLogoDataUri(logoCandidates(c)); const name = clampName(c.display_name); const country = c.country ? countryName(c.country) : null; const industry = c.industry_primary ?? c.industries[0] ?? null; const hqCity = p?.hq?.city ?? c.hq_city; const hq = [hqCity, p?.hq?.region ?? c.hq_region, country].filter(Boolean).join(', '); const founded = p?.founded_year ?? c.founded_year; const employees = p?.employees ? `${fmtInt(p.employees)}${p.employees_year ? ` (${p.employees_year})` : ''}` : c.employees_band ? c.employees_band : null; const facts: [string, string][] = []; if (founded) facts.push(['Founded', String(founded)]); if (employees) facts.push(['Employees', employees]); if (hq) facts.push(['Headquarters', hq]); const detail = new Map((c.metrics_detail ?? []).map((m) => [m.metric, m])); const tile = (metric: 'activity_score' | 'hiring_momentum_30d' | 'ai_adoption', label: string, pct = false) => { const v = num(c.metrics[metric]); const d = detail.get(metric); if (v === null) return { label, value: '—', hint: 'not enough monitored evidence' }; return { label, value: pct ? fmtPctSigned(v) : fmtScore(v), hint: d ? `confidence ${Math.round(d.confidence * 100)} %` : pct ? 'vs 30 days ago' : '0–100', tone: pct ? (v > 0 ? P.positive : v < 0 ? P.danger : undefined) : undefined }; }; const tiles = [tile('activity_score', 'Activity Score'), tile('hiring_momentum_30d', 'Hiring Momentum 30d', true), tile('ai_adoption', 'AI Adoption')]; const chips = [c.canonical_domain, country, industry].filter((x): x is string => Boolean(x)); return respond(
{fmtInt(c.counts.sensors)} sensors · {fmtInt(c.counts.events)} events · {fmtInt(c.counts.changes)} changes
{name.text}
{chips.map((chip, i) => (
{chip}
))}
{facts.length > 0 && (
{facts.map(([l, v]) => ( ))}
)}
{tiles.map((t) => ( ))}