SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
21.6 KB · 437 lines tsx
Raw Blame History
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