spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1/**2 * Share images (Open Graph / Twitter), rendered by Satori through `next/og`.3 *4 * One render function per family — `renderSiteOg`, `renderCompanyOg`, `renderTopicOg` — used by thin route files5 * (`opengraph-image.tsx` + `twitter-image.tsx` at the root, under `/company/[slug]`, `/industry/[slug]`,6 * `/country/[code]`). Route files export only `alt`/`size`/`contentType` + the default function; nothing else is7 * re-exported between them (re-exporting `runtime` between sibling metadata routes broke sibling builds).8 *9 * Satori rules honoured here: every box is `display:flex`; a div never mixes several text children (wrap in spans);10 * images are data URIs; fonts are Geist TTFs read from the `geist` package at first use.11 */12import { existsSync } from 'node:fs';13import * as fs from 'node:fs/promises';14import { createRequire } from 'node:module';15import path from 'node:path';16import { geoGraticule, geoOrthographic, geoPath } from 'd3-geo';17import { ImageResponse } from 'next/og';18import { feature } from 'topojson-client';19import type { GeometryCollection, Topology } from 'topojson-specification';20import land110 from 'world-atlas/land-110m.json';21import { MARK_DARK, MarkImg } from '@/components/brand/mark';22import { API_URL } from '@/lib/api';23import { countryName } from '@/lib/countries';24import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';25import { logoCandidates, monogram } from '@/lib/profile';26import { HOST_NAME, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';27import type { CompanyDetail, Stats } from '@/lib/types';2829export const OG_SIZE = { width: 1200, height: 630 };30export const OG_CONTENT_TYPE = 'image/png';31export const OG_ALT = `${SITE_NAME} — ${TAGLINE}`;3233/* Palette: the dark theme tokens of globals.css, hard-coded (Satori has no CSS variables). */34const P = {35 bg: '#0a0d12',36 surface: '#11151c',37 surface2: '#171c25',38 ink: '#e8ebf1',39 ink2: '#a3a9b8',40 ink3: '#6f7688',41 rule: 'rgba(190,200,225,0.14)',42 ruleStrong: 'rgba(190,200,225,0.26)',43 accent: '#6d95ff',44 live: '#3fd07a',45 positive: '#3fd07a',46 danger: '#ff6b74',47};48const SANS = 'Geist';49const MONO = 'Geist Mono';50const HOST = SITE_URL.replace(/^https?:\/\//, '');5152/* ------------------------------------------------------------------------------------------------------------------ */53/* Fonts */54/* ------------------------------------------------------------------------------------------------------------------ */55type Font = { name: string; data: ArrayBuffer; weight: 400 | 500 | 600; style: 'normal' };5657function geistFontsDir(): string | null {58 const cwd = process.cwd();59 for (const base of [cwd, path.join(cwd, 'apps', 'web'), path.resolve(cwd, '..', '..')]) {60 const p = path.join(base, 'node_modules', 'geist', 'dist', 'fonts');61 if (existsSync(p)) return p;62 }63 try {64 const req = createRequire(path.join(cwd, 'index.js'));65 return path.join(path.dirname(req.resolve('geist/font')), 'fonts');66 } catch {67 return null;68 }69}7071let fontsPromise: Promise<Font[]> | null = null;72/** Geist Regular / Medium / SemiBold + Geist Mono Medium. Missing files degrade to Satori's default font. */73export function loadFonts(): Promise<Font[]> {74 fontsPromise ??= (async () => {75 const dir = geistFontsDir();76 if (!dir) return [];77 const wanted: [string, string, Font['weight']][] = [78 ['geist-sans/Geist-Regular.ttf', SANS, 400],79 ['geist-sans/Geist-Medium.ttf', SANS, 500],80 ['geist-sans/Geist-SemiBold.ttf', SANS, 600],81 ['geist-mono/GeistMono-Medium.ttf', MONO, 500],82 ];83 const out: Font[] = [];84 for (const [file, name, weight] of wanted) {85 try {86 // Runtime lookup on purpose (fonts come from node_modules/geist, which is not deployed as a traced asset); plain87 // string concatenation keeps Turbopack's output-file tracing from pulling the whole project in.88 const buf = await fs.readFile(/*turbopackIgnore: true*/ `${dir}/${file}`);89 out.push({ name, data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer, weight, style: 'normal' });90 } catch {91 /* font missing → skip */92 }93 }94 return out;95 })();96 return fontsPromise;97}9899async function respond(node: React.ReactElement): Promise<ImageResponse> {100 const fonts = await loadFonts();101 return new ImageResponse(node, { ...OG_SIZE, fonts: fonts.length ? fonts : undefined });102}103104/* ------------------------------------------------------------------------------------------------------------------ */105/* Globe wireframe (orthographic land + graticule) — the "atlas" backdrop on the right of every share image */106/* ------------------------------------------------------------------------------------------------------------------ */107let globeCache: string | null = null;108function globeDataUri(): string {109 if (globeCache) return globeCache;110 const W = 760;111 const H = 760;112 const R = 340;113 const projection = geoOrthographic()114 .rotate([-12, -28, 0])115 .scale(R)116 .translate([W / 2, H / 2])117 .clipAngle(90);118 const pathOf = geoPath(projection);119 const topo = land110 as unknown as Topology<{ land: GeometryCollection }>;120 const land = feature(topo, topo.objects.land);121 const graticule = geoGraticule().step([15, 15])();122 const sphere = pathOf({ type: 'Sphere' }) ?? '';123 const svg =124 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +125 `<defs><radialGradient id="g" cx="38%" cy="32%" r="75%"><stop offset="0" stop-color="#1b2433"/><stop offset="1" stop-color="#0a0d12"/></radialGradient></defs>` +126 `<path d="${sphere}" fill="url(#g)"/>` +127 `<path d="${pathOf(graticule) ?? ''}" fill="none" stroke="rgba(190,200,225,0.16)" stroke-width="1"/>` +128 `<path d="${pathOf(land) ?? ''}" fill="rgba(190,200,225,0.13)" stroke="rgba(190,200,225,0.32)" stroke-width="1"/>` +129 `<path d="${sphere}" fill="none" stroke="rgba(190,200,225,0.34)" stroke-width="2"/>` +130 `</svg>`;131 globeCache = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;132 return globeCache;133}134135/* ------------------------------------------------------------------------------------------------------------------ */136/* Building blocks */137/* ------------------------------------------------------------------------------------------------------------------ */138type Style = React.CSSProperties;139const row = (extra?: Style): Style => ({ display: 'flex', flexDirection: 'row', alignItems: 'center', ...extra });140const col = (extra?: Style): Style => ({ display: 'flex', flexDirection: 'column', ...extra });141142function Frame({ children, globe = 'right' }: { children: React.ReactNode; globe?: 'right' | 'corner' | 'none' }) {143 return (144 <div style={{ ...col({ justifyContent: 'space-between' }), width: OG_SIZE.width, height: OG_SIZE.height, padding: 56, background: P.bg, color: P.ink, fontFamily: SANS, position: 'relative', overflow: 'hidden' }}>145 <div style={{ position: 'absolute', left: 0, top: 0, width: 1200, height: 630, display: 'flex', backgroundImage: `linear-gradient(to right, rgba(190,200,225,0.045) 1px, transparent 1px), linear-gradient(to bottom, rgba(190,200,225,0.045) 1px, transparent 1px)`, backgroundSize: '60px 60px' }} />146 {globe !== 'none' && (147 // eslint-disable-next-line @next/next/no-img-element148 <img src={globeDataUri()} width={760} height={760} alt="" style={globe === 'right' ? { position: 'absolute', right: -170, top: -40, width: 760, height: 760, opacity: 0.95 } : { position: 'absolute', right: -300, top: 230, width: 760, height: 760, opacity: 0.8 }} />149 )}150 <div style={{ position: 'absolute', left: 0, top: 0, width: 1200, height: 630, display: 'flex', backgroundImage: 'linear-gradient(to right, rgba(10,13,18,0.9) 40%, rgba(10,13,18,0.35) 70%, rgba(10,13,18,0) 100%)' }} />151 <div style={{ position: 'absolute', left: 0, bottom: 0, width: 1200, height: 6, display: 'flex', background: `linear-gradient(to right, ${P.live}, ${P.accent})` }} />152 {children}153 </div>154 );155}156157/** Mark + wordmark, in Geist: "Company" regular ink-2, "Atlas" semibold ink. */158function Lockup({ markPx = 44, fontSize = 26 }: { markPx?: number; fontSize?: number }) {159 return (160 <div style={row({ gap: Math.round(markPx * 0.3) })}>161 <MarkImg px={markPx} colors={MARK_DARK} />162 <div style={row({ gap: Math.round(fontSize * 0.28), fontSize, letterSpacing: -0.6 })}>163 <span style={{ color: P.ink2, fontWeight: 400 }}>Company</span>164 <span style={{ color: P.ink, fontWeight: 600 }}>Atlas</span>165 </div>166 </div>167 );168}169170function LivePill({ label = 'LIVE' }: { label?: string }) {171 return (172 <div style={row({ gap: 10, padding: '8px 14px', borderRadius: 999, border: `1px solid rgba(63,208,122,0.4)`, background: 'rgba(63,208,122,0.08)', color: P.live, fontSize: 16, fontFamily: MONO, letterSpacing: 2 })}>173 <div style={{ width: 10, height: 10, borderRadius: 10, background: P.live, display: 'flex' }} />174 <span>{label}</span>175 </div>176 );177}178179function Eyebrow({ children, color = P.ink3 }: { children: string; color?: string }) {180 return <span style={{ fontSize: 15, letterSpacing: 2.5, textTransform: 'uppercase', color, fontFamily: MONO, fontWeight: 500 }}>{children}</span>;181}182183function Counter({ label, value, tone }: { label: string; value: string; tone?: string }) {184 return (185 <div style={col({ gap: 6, minWidth: 150 })}>186 <Eyebrow>{label}</Eyebrow>187 <span style={{ fontSize: 40, fontWeight: 600, letterSpacing: -1.5, fontFamily: MONO, color: tone ?? P.ink, lineHeight: 1 }}>{value}</span>188 </div>189 );190}191192function Footer({ left }: { left?: string }) {193 return (194 <div style={row({ justifyContent: 'space-between', width: '100%' })}>195 <span style={{ fontFamily: MONO, fontSize: 20, color: P.ink2, letterSpacing: -0.3 }}>{left ?? HOST}</span>196 <div style={row({ gap: 10, fontSize: 18, color: P.ink3 })}>197 <span>Hosted on</span>198 <span style={{ color: P.ink2, fontWeight: 500 }}>{HOST_NAME}</span>199 </div>200 </div>201 );202}203204/* ------------------------------------------------------------------------------------------------------------------ */205/* 1. Site */206/* ------------------------------------------------------------------------------------------------------------------ */207export async function renderSiteOg(stats: Stats | null): Promise<ImageResponse> {208 const counters: [string, string][] = stats209 ? [210 ['Companies', fmtInt(stats.companies)],211 ['Sensors', fmtInt(stats.sensors)],212 ['Observations', fmtInt(stats.observations)],213 ['Events', fmtInt(stats.events)],214 ]215 : [];216 return respond(217 <Frame>218 <div style={row({ justifyContent: 'space-between', width: '100%' })}>219 <Lockup markPx={56} fontSize={32} />220 <LivePill />221 </div>222 <div style={col({ gap: 22, maxWidth: 780 })}>223 <Eyebrow>Continuous corporate observation network</Eyebrow>224 <span style={{ fontSize: 78, fontWeight: 600, letterSpacing: -3.5, lineHeight: 1.0, color: P.ink }}>{TAGLINE}</span>225 <span style={{ fontSize: 24, color: P.ink2, lineHeight: 1.35, maxWidth: 700 }}>Public-web sensors on thousands of companies: products, hiring, pricing, leadership, locations and technology — observed, versioned, never overwritten.</span>226 </div>227 <div style={col({ gap: 30, width: '100%' })}>228 {counters.length > 0 && (229 <div style={row({ gap: 56, borderTop: `1px solid ${P.ruleStrong}`, paddingTop: 26 })}>230 {counters.map(([label, value]) => (231 <Counter key={label} label={label} value={value} />232 ))}233 </div>234 )}235 <Footer />236 </div>237 </Frame>,238 );239}240241/* ------------------------------------------------------------------------------------------------------------------ */242/* 2. Company */243/* ------------------------------------------------------------------------------------------------------------------ */244const LOGO_TIMEOUT_MS = 2500;245const LOGO_MAX_BYTES = 1_500_000;246const LOGO_TYPES = new Set(['image/png', 'image/jpeg', 'image/svg+xml']);247248/** True for URLs the server may fetch: http(s), public hostnames (no IP literals / localhost) — the API host excepted (mock logos in dev). */249function logoUrlAllowed(u: URL): boolean {250 if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;251 if (u.origin === new URL(API_URL).origin) return true;252 const h = u.hostname.toLowerCase();253 if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.localhost')) return false;254 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.startsWith('[') || h.includes(':')) return false;255 return true;256}257258/** Fetches the first loadable company logo as a data URI (Satori cannot load remote images reliably); null on any problem. */259export async function fetchLogoDataUri(candidates: string[]): Promise<string | null> {260 for (const c of candidates.slice(0, 3)) {261 try {262 const u = new URL(c);263 if (!logoUrlAllowed(u)) continue;264 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 } });265 if (!res.ok) continue;266 const type = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();267 if (!LOGO_TYPES.has(type)) continue;268 const len = Number(res.headers.get('content-length') ?? 0);269 if (len > LOGO_MAX_BYTES) continue;270 const buf = Buffer.from(await res.arrayBuffer());271 if (!buf.length || buf.length > LOGO_MAX_BYTES) continue;272 return `data:${type};base64,${buf.toString('base64')}`;273 } catch {274 /* try next candidate */275 }276 }277 return null;278}279280function LogoTile({ name, dataUri, px }: { name: string; dataUri: string | null; px: number }) {281 if (dataUri) {282 return (283 <div style={{ ...row({ justifyContent: 'center' }), width: px, height: px, borderRadius: 20, background: '#ffffff', border: `1px solid ${P.ruleStrong}`, padding: Math.round(px * 0.12), flexShrink: 0 }}>284 {/* eslint-disable-next-line @next/next/no-img-element */}285 <img src={dataUri} alt="" width={px * 0.76} height={px * 0.76} style={{ width: px * 0.76, height: px * 0.76, objectFit: 'contain' }} />286 </div>287 );288 }289 return (290 <div style={{ ...row({ justifyContent: 'center' }), width: px, height: px, borderRadius: 20, background: P.surface2, border: `1px solid ${P.ruleStrong}`, color: P.ink, fontFamily: MONO, fontWeight: 600, fontSize: Math.round(px * 0.4), letterSpacing: -1, flexShrink: 0 }}>291 <span>{monogram(name)}</span>292 </div>293 );294}295296function Fact({ label, value }: { label: string; value: string }) {297 return (298 <div style={col({ gap: 6 })}>299 <Eyebrow>{label}</Eyebrow>300 <span style={{ fontSize: 22, color: P.ink, fontWeight: 500, letterSpacing: -0.3 }}>{value}</span>301 </div>302 );303}304305function MetricTile({ label, value, hint, tone }: { label: string; value: string; hint: string; tone?: string }) {306 return (307 <div style={{ ...col({ gap: 8 }), flex: 1, padding: '18px 22px', borderRadius: 12, background: 'rgba(17,21,28,0.85)', border: `1px solid ${P.rule}` }}>308 <Eyebrow>{label}</Eyebrow>309 <span style={{ fontSize: 40, fontWeight: 600, fontFamily: MONO, letterSpacing: -1.5, lineHeight: 1, color: tone ?? P.ink }}>{value}</span>310 <span style={{ fontSize: 14, color: P.ink3 }}>{hint}</span>311 </div>312 );313}314315function clampName(n: string): { text: string; size: number } {316 const text = n.length > 42 ? `${n.slice(0, 41).trimEnd()}…` : n;317 return { text, size: text.length > 28 ? 42 : text.length > 18 ? 52 : 64 };318}319320export async function renderCompanyOg(c: CompanyDetail): Promise<ImageResponse> {321 const p = c.profile ?? null;322 const logo = await fetchLogoDataUri(logoCandidates(c));323 const name = clampName(c.display_name);324 const country = c.country ? countryName(c.country) : null;325 const industry = c.industry_primary ?? c.industries[0] ?? null;326 const hqCity = p?.hq?.city ?? c.hq_city;327 const hq = [hqCity, p?.hq?.region ?? c.hq_region, country].filter(Boolean).join(', ');328 const founded = p?.founded_year ?? c.founded_year;329 const employees = p?.employees ? `${fmtInt(p.employees)}${p.employees_year ? ` (${p.employees_year})` : ''}` : c.employees_band ? c.employees_band : null;330 const facts: [string, string][] = [];331 if (founded) facts.push(['Founded', String(founded)]);332 if (employees) facts.push(['Employees', employees]);333 if (hq) facts.push(['Headquarters', hq]);334 const detail = new Map((c.metrics_detail ?? []).map((m) => [m.metric, m]));335 const tile = (metric: 'activity_score' | 'hiring_momentum_30d' | 'ai_adoption', label: string, pct = false) => {336 const v = num(c.metrics[metric]);337 const d = detail.get(metric);338 if (v === null) return { label, value: '—', hint: 'not enough monitored evidence' };339 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 };340 };341 const tiles = [tile('activity_score', 'Activity Score'), tile('hiring_momentum_30d', 'Hiring Momentum 30d', true), tile('ai_adoption', 'AI Adoption')];342 const chips = [c.canonical_domain, country, industry].filter((x): x is string => Boolean(x));343344 return respond(345 <Frame globe="corner">346 <div style={row({ justifyContent: 'space-between', width: '100%' })}>347 <Lockup markPx={40} fontSize={23} />348 <div style={row({ gap: 8, fontFamily: MONO, fontSize: 16, color: P.ink3 })}>349 <span>{fmtInt(c.counts.sensors)} sensors</span>350 <span>·</span>351 <span>{fmtInt(c.counts.events)} events</span>352 <span>·</span>353 <span>{fmtInt(c.counts.changes)} changes</span>354 </div>355 </div>356 <div style={col({ gap: 26, width: '100%' })}>357 <div style={row({ gap: 30 })}>358 <LogoTile name={c.display_name} dataUri={logo} px={124} />359 <div style={col({ gap: 12, flex: 1, minWidth: 0 })}>360 <span style={{ fontSize: name.size, fontWeight: 600, letterSpacing: -2, lineHeight: 1.02, color: P.ink }}>{name.text}</span>361 <div style={row({ gap: 10, flexWrap: 'wrap' })}>362 {chips.map((chip, i) => (363 <div key={i} style={{ ...row(), padding: '6px 12px', borderRadius: 6, border: `1px solid ${P.ruleStrong}`, background: 'rgba(17,21,28,0.7)', fontFamily: i === 0 ? MONO : SANS, fontSize: 18, color: P.ink2 }}>364 <span>{chip}</span>365 </div>366 ))}367 </div>368 </div>369 </div>370 {facts.length > 0 && (371 <div style={row({ gap: 48, borderTop: `1px solid ${P.rule}`, paddingTop: 20 })}>372 {facts.map(([l, v]) => (373 <Fact key={l} label={l} value={v} />374 ))}375 </div>376 )}377 <div style={row({ gap: 16, width: '100%' })}>378 {tiles.map((t) => (379 <MetricTile key={t.label} {...t} />380 ))}381 </div>382 </div>383 <Footer left={`${HOST}/company/${c.slug}`} />384 </Frame>,385 );386}387388/* ------------------------------------------------------------------------------------------------------------------ */389/* 3. Topic (industry / country) */390/* ------------------------------------------------------------------------------------------------------------------ */391export type TopicOg = {392 eyebrow: string;393 title: string;394 subtitle?: string | null;395 counters: [string, string][];396 /** Up to eight names shown as chips ("Monitored companies include …"). */397 names?: string[];398 path: string;399};400401export async function renderTopicOg(t: TopicOg): Promise<ImageResponse> {402 const title = t.title.length > 40 ? `${t.title.slice(0, 39).trimEnd()}…` : t.title;403 const names = (t.names ?? []).slice(0, 8);404 return respond(405 <Frame>406 <div style={row({ justifyContent: 'space-between', width: '100%' })}>407 <Lockup markPx={44} fontSize={25} />408 <LivePill />409 </div>410 <div style={col({ gap: 18, maxWidth: 820 })}>411 <Eyebrow color={P.accent}>{t.eyebrow}</Eyebrow>412 <span style={{ fontSize: title.length > 24 ? 56 : 72, fontWeight: 600, letterSpacing: -2.5, lineHeight: 1.02, color: P.ink }}>{title}</span>413 {t.subtitle && <span style={{ fontSize: 22, color: P.ink2, lineHeight: 1.35, maxWidth: 760 }}>{t.subtitle.length > 160 ? `${t.subtitle.slice(0, 159).trimEnd()}…` : t.subtitle}</span>}414 {names.length > 0 && (415 <div style={row({ gap: 8, flexWrap: 'wrap', maxWidth: 800 })}>416 {names.map((n) => (417 <div key={n} style={{ ...row(), padding: '5px 10px', borderRadius: 6, border: `1px solid ${P.rule}`, background: 'rgba(17,21,28,0.7)', fontSize: 16, color: P.ink2 }}>418 <span>{n}</span>419 </div>420 ))}421 </div>422 )}423 </div>424 <div style={col({ gap: 28, width: '100%' })}>425 {t.counters.length > 0 && (426 <div style={row({ gap: 56, borderTop: `1px solid ${P.ruleStrong}`, paddingTop: 24 })}>427 {t.counters.map(([label, value]) => (428 <Counter key={label} label={label} value={value} />429 ))}430 </div>431 )}432 <Footer left={`${HOST}${t.path}`} />433 </div>434 </Frame>,435 );436}437