SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
6.3 KB · 133 lines tsx
Raw Blame History
1import { readFile } from 'node:fs/promises';2import path from 'node:path';3import { ImageResponse } from 'next/og';45/**6 * Social-share images (Open Graph / Twitter cards), 1200 × 630, rendered server-side with the site7 * fonts (WOFF copies in src/assets/fonts). Shared by the site default (`app/opengraph-image.tsx`)8 * and the entity images (cancer, gene, drug, biomarker, trial). Content is limited to sourced facts9 * passed in by the caller — never estimates.10 */1112export const OG_SIZE = { width: 1200, height: 630 } as const;13export const OG_CONTENT_TYPE = 'image/png';1415const PAPER = '#fafaf7';16const INK = '#1c1c1a';17const INK2 = '#4a4a46';18const INK3 = '#7b7b75';19const RULE = '#dcdcd4';20const ACCENT = '#0f5f63';21const ACCENT_SOFT = '#e2eeee';2223let fontCache: Promise<Array<{ name: string; data: ArrayBuffer; weight: 400 | 500 | 600; style: 'normal' | 'italic' }>> | null = null;24let markCache: Promise<string> | null = null;2526function assetDir(): string {27  return path.join(process.cwd(), 'src', 'assets', 'fonts');28}2930async function loadFonts() {31  if (!fontCache) {32    fontCache = (async () => {33      const dir = assetDir();34      const read = async (f: string) => {35        const b = await readFile(path.join(dir, f));36        return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer;37      };38      return [39        { name: 'Newsreader', data: await read('newsreader-500.woff'), weight: 500 as const, style: 'normal' as const },40        { name: 'Newsreader', data: await read('newsreader-500i.woff'), weight: 500 as const, style: 'italic' as const },41        { name: 'Inter', data: await read('inter-400.woff'), weight: 400 as const, style: 'normal' as const },42        { name: 'Inter', data: await read('inter-600.woff'), weight: 600 as const, style: 'normal' as const },43      ];44    })();45  }46  return fontCache;47}4849/** The brand mark as a PNG data URI (satori renders <img>; SVG dash arrays are not supported). */50async function loadMark(): Promise<string> {51  if (!markCache) {52    markCache = readFile(path.join(process.cwd(), 'public', 'brand', 'logo-mark.png')).then((b) => `data:image/png;base64,${b.toString('base64')}`);53  }54  return markCache;55}5657export interface OgFact {58  label: string;59  value: string;60}6162export interface OgProps {63  /** Small upper label: entity kind or page family ("Cancer · NCIt C9005", "Gene", "Clinical trial"). */64  kicker: string;65  title: string;66  /** One line under the title (canonical name, mechanism, sponsor…). */67  subtitle?: string | null;68  /** Up to four sourced figures shown as chips (label + value). */69  facts?: OgFact[];70  /** Footer right-hand note, e.g. the sources behind the facts. */71  sourcesNote?: string | null;72}7374function clampTitle(t: string): { text: string; size: number } {75  const text = t.length > 110 ? `${t.slice(0, 107).trimEnd()}…` : t;76  const size = text.length <= 28 ? 84 : text.length <= 48 ? 68 : text.length <= 72 ? 56 : 46;77  return { text, size };78}7980export async function ogImage(props: OgProps): Promise<ImageResponse> {81  const [fonts, mark] = await Promise.all([loadFonts(), loadMark()]);82  const { text, size } = clampTitle(props.title);83  const facts = (props.facts ?? []).slice(0, 4);84  return new ImageResponse(85    (86      <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: PAPER, color: INK, fontFamily: 'Inter', padding: '56px 64px 48px' }}>87        {/* top row: brand */}88        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>89          <div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>90            <img src={mark} width={64} height={64} alt="" />91            <div style={{ display: 'flex', flexDirection: 'column' }}>92              <div style={{ display: 'flex', fontFamily: 'Newsreader', fontWeight: 500, fontSize: 40, letterSpacing: -0.5, lineHeight: 1 }}>93                <span>Cancer</span>94                <span style={{ color: ACCENT }}>Index</span>95              </div>96              <div style={{ fontSize: 15, letterSpacing: 2.2, textTransform: 'uppercase', color: INK3, marginTop: 6 }}>The global index of cancer</div>97            </div>98          </div>99          <div style={{ fontSize: 18, color: INK3, textTransform: 'uppercase', letterSpacing: 2 }}>{props.kicker}</div>100        </div>101102        {/* title block */}103        <div style={{ display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center', paddingTop: 24, paddingBottom: 16 }}>104          <div style={{ fontFamily: 'Newsreader', fontWeight: 500, fontSize: size, lineHeight: 1.05, letterSpacing: -1, color: INK, maxWidth: 1072, display: 'flex' }}>{text}</div>105          {props.subtitle ? <div style={{ marginTop: 18, fontSize: 26, color: INK2, lineHeight: 1.3, maxWidth: 1040, display: 'flex' }}>{props.subtitle.length > 140 ? `${props.subtitle.slice(0, 137).trimEnd()}…` : props.subtitle}</div> : null}106        </div>107108        {/* facts */}109        {facts.length ? (110          <div style={{ display: 'flex', gap: 14, marginBottom: 26 }}>111            {facts.map((f) => (112              <div key={f.label} style={{ display: 'flex', flexDirection: 'column', background: ACCENT_SOFT, border: `1px solid ${RULE}`, borderRadius: 4, padding: '12px 18px', minWidth: 160 }}>113                <div style={{ fontSize: 30, fontWeight: 600, color: INK, lineHeight: 1.1 }}>{f.value}</div>114                <div style={{ fontSize: 15, color: INK2, marginTop: 4, textTransform: 'uppercase', letterSpacing: 1.2 }}>{f.label}</div>115              </div>116            ))}117          </div>118        ) : null}119120        {/* footer */}121        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 32, borderTop: `2px solid ${RULE}`, paddingTop: 18, fontSize: 18, color: INK3 }}>122          <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexShrink: 0 }}>123            <div style={{ width: 10, height: 10, borderRadius: 5, background: ACCENT }} />124            <span>www.cancerindex.io · a source, a date and a formula version on every number</span>125          </div>126          {props.sourcesNote ? <span style={{ fontSize: 15, textAlign: 'right', maxWidth: 380, lineHeight: 1.25 }}>{props.sourcesNote.length > 90 ? `${props.sourcesNote.slice(0, 87).trimEnd()}…` : props.sourcesNote}</span> : null}127        </div>128      </div>129    ),130    { ...OG_SIZE, fonts },131  );132}133