spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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