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%

Brand: unique SVG mark (open ring C + graduated index I), header/footer wordmark, favicon (svg/ico/apple), web manifest, Open Graph / Twitter share images rendered server-side with site fonts (site default with live counts + cancer/gene/drug/biomarker/trial images)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 711d796

27 changed files +388 −4

added apps/web/public/brand/icon-192.png +0 −0

Binary file not shown.

added apps/web/public/brand/icon-512.png +0 −0

Binary file not shown.

added apps/web/public/brand/logo-mark-dark.png +0 −0

Binary file not shown.

added apps/web/public/brand/logo-mark-dark.svg +11 −0
@@ -0,0 +1,11 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-labelledby="t">
2 + <title id="t">CancerIndex mark (for dark backgrounds)</title>
3 + <circle cx="30" cy="32" r="24" fill="none" stroke="#ecece6" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="104.7 150.8" transform="rotate(55 30 32)"/>
4 + <line x1="50" y1="14" x2="50" y2="50" stroke="#6cc3c6" stroke-width="4.5" stroke-linecap="round"/>
5 + <line x1="50" y1="16" x2="60" y2="16" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/>
6 + <line x1="50" y1="24" x2="56" y2="24" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/>
7 + <line x1="50" y1="32" x2="60" y2="32" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/>
8 + <line x1="50" y1="40" x2="56" y2="40" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/>
9 + <line x1="50" y1="48" x2="60" y2="48" stroke="#6cc3c6" stroke-width="3" stroke-linecap="round"/>
10 + <circle cx="30" cy="32" r="4.5" fill="#ecece6"/>
11 +</svg>
added apps/web/public/brand/logo-mark.png +0 −0

Binary file not shown.

added apps/web/public/brand/logo-mark.svg +14 −0
@@ -0,0 +1,14 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-labelledby="t">
2 + <title id="t">CancerIndex mark — an open ring (C) and an index scale (I)</title>
3 + <!-- C: open ring, gap centred on the right -->
4 + <circle cx="30" cy="32" r="24" fill="none" stroke="#1c1c1a" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="104.7 150.8" transform="rotate(55 30 32)"/>
5 + <!-- I: index scale with graduated ticks -->
6 + <line x1="50" y1="14" x2="50" y2="50" stroke="#0f5f63" stroke-width="4.5" stroke-linecap="round"/>
7 + <line x1="50" y1="16" x2="60" y2="16" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
8 + <line x1="50" y1="24" x2="56" y2="24" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
9 + <line x1="50" y1="32" x2="60" y2="32" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
10 + <line x1="50" y1="40" x2="56" y2="40" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
11 + <line x1="50" y1="48" x2="60" y2="48" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
12 + <!-- nucleus: the one data point inside the ring -->
13 + <circle cx="30" cy="32" r="4.5" fill="#1c1c1a"/>
14 +</svg>
added apps/web/public/brand/logo.svg +14 −0
@@ -0,0 +1,14 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 64" width="300" height="64" role="img" aria-labelledby="t">
2 + <title id="t">CancerIndex — the global index of cancer</title>
3 + <g>
4 + <circle cx="30" cy="32" r="24" fill="none" stroke="#1c1c1a" stroke-width="4.5" stroke-linecap="round" stroke-dasharray="104.7 150.8" transform="rotate(55 30 32)"/>
5 + <line x1="50" y1="14" x2="50" y2="50" stroke="#0f5f63" stroke-width="4.5" stroke-linecap="round"/>
6 + <line x1="50" y1="16" x2="60" y2="16" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
7 + <line x1="50" y1="24" x2="56" y2="24" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
8 + <line x1="50" y1="32" x2="60" y2="32" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
9 + <line x1="50" y1="40" x2="56" y2="40" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
10 + <line x1="50" y1="48" x2="60" y2="48" stroke="#0f5f63" stroke-width="3" stroke-linecap="round"/>
11 + <circle cx="30" cy="32" r="4.5" fill="#1c1c1a"/>
12 + </g>
13 + <text x="76" y="43" font-family="Newsreader, 'Iowan Old Style', Georgia, serif" font-size="34" font-weight="500" letter-spacing="-0.4" fill="#1c1c1a">Cancer<tspan fill="#0f5f63">Index</tspan></text>
14 +</svg>
added apps/web/src/app/apple-icon.png +0 −0

Binary file not shown.

added apps/web/src/app/biomarker/[slug]/opengraph-image.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og';
2 +import { getBiomarkerBySlug } from '@/lib/queries/biomarkers';
3 +import { humanize } from '@/lib/format';
4 +
5 +export const alt = 'CancerIndex biomarker page';
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const b = await getBiomarkerBySlug(slug);
13 + if (!b) return ogImage({ kicker: 'Biomarker', title: 'Biomarker not found', subtitle: slug });
14 + const genes = (b.gene_symbols ?? []).slice(0, 4).join(', ');
15 + return ogImage({
16 + kicker: `Biomarker · ${humanize(b.kind)}${b.ncit_code ? ` · NCIt ${b.ncit_code}` : ''}`,
17 + title: b.name,
18 + subtitle: b.description ?? (genes ? `Anchor gene${genes.includes(',') ? 's' : ''}: ${genes}` : 'Curated biomarker with derived links to cancers, drugs, approvals and trials.'),
19 + facts: genes ? [{ label: 'anchor genes', value: genes }] : [],
20 + sourcesNote: 'NCIt (EVS) · CIViC · openFDA / Health Canada / EMA · ClinicalTrials.gov',
21 + });
22 +}
added apps/web/src/app/cancer/[slug]/opengraph-image.tsx +29 −0
@@ -0,0 +1,29 @@
1 +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og';
2 +import { getCancerBySlug, getCounters } from '@/lib/queries/cancers';
3 +import { fmtInt, humanize } from '@/lib/format';
4 +
5 +export const alt = 'CancerIndex cancer profile';
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +/** Share image of a cancer page: name, entity type, NCIt code and the deterministic counters (never estimates). */
11 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const c = await getCancerBySlug(slug);
14 + if (!c) return ogImage({ kicker: 'Cancer', title: 'Entity not found', subtitle: slug });
15 + const k = await getCounters(c.id);
16 + const facts = [
17 + k?.active_trial_count ? { label: 'active trials', value: fmtInt(k.active_trial_count) } : null,
18 + k?.approved_drug_count ? { label: 'approved drugs', value: fmtInt(k.approved_drug_count) } : null,
19 + k?.evidence_count ? { label: 'evidence items', value: fmtInt(k.evidence_count) } : null,
20 + k?.publication_count_5y ? { label: 'publications · 5 y', value: fmtInt(k.publication_count_5y) } : null,
21 + ].filter((f): f is { label: string; value: string } => !!f);
22 + return ogImage({
23 + kicker: `${humanize(c.entity_type)}${c.primary_ncit_code ? ` · NCIt ${c.primary_ncit_code}` : ''}`,
24 + title: c.canonical_name,
25 + subtitle: c.description ?? (c.short_name && c.short_name !== c.canonical_name ? c.short_name : `Taxonomy, statistics, genomics, evidence, drugs, trials and rankings for ${c.canonical_name}.`),
26 + facts,
27 + sourcesNote: facts.length ? 'ClinicalTrials.gov · openFDA / Health Canada / EMA · CIViC · PubMed' : null,
28 + });
29 +}
added apps/web/src/app/drug/[slug]/opengraph-image.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og';
2 +import { getDrugBySlug } from '@/lib/queries/drugs';
3 +import { fmtInt, humanize } from '@/lib/format';
4 +
5 +export const alt = 'CancerIndex drug page';
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const d = await getDrugBySlug(slug);
13 + if (!d) return ogImage({ kicker: 'Drug', title: 'Drug not found', subtitle: slug });
14 + const facts = [
15 + d.approval_count ? { label: 'approval records', value: fmtInt(d.approval_count) } : null,
16 + d.trial_count ? { label: 'registered trials', value: fmtInt(d.trial_count) } : null,
17 + d.evidence_count ? { label: 'curated evidence items', value: fmtInt(d.evidence_count) } : null,
18 + ].filter((f): f is { label: string; value: string } => !!f);
19 + return ogImage({
20 + kicker: `Drug${d.kind ? ` · ${humanize(d.kind)}` : ''}${d.chembl_id ? ` · ${d.chembl_id}` : ''}`,
21 + title: d.name,
22 + subtitle: d.mechanism ?? d.description ?? 'Jurisdiction-aware approvals, curated evidence, development pipeline and clinical trials.',
23 + facts,
24 + sourcesNote: 'openFDA · Health Canada · EMA · CIViC · ClinicalTrials.gov',
25 + });
26 +}
added apps/web/src/app/favicon.ico +0 −0

Binary file not shown.

added apps/web/src/app/gene/[symbol]/opengraph-image.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og';
2 +import { getGeneBySymbol } from '@/lib/queries/genomics';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +export const alt = 'CancerIndex gene page';
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ symbol: string }> }) {
11 + const { symbol } = await params;
12 + const g = await getGeneBySymbol(symbol);
13 + if (!g) return ogImage({ kicker: 'Gene', title: 'Gene not found', subtitle: symbol });
14 + const facts = [
15 + g.evidence_count ? { label: 'curated evidence items', value: fmtInt(g.evidence_count) } : null,
16 + g.variant_count ? { label: 'variants', value: fmtInt(g.variant_count) } : null,
17 + g.cohort_count ? { label: 'genomic cohorts', value: fmtInt(g.cohort_count) } : null,
18 + ].filter((f): f is { label: string; value: string } => !!f);
19 + return ogImage({
20 + kicker: `Gene${g.hgnc_id ? ` · ${g.hgnc_id}` : ''}${g.is_cancer_gene ? ' · cancer gene' : ''}`,
21 + title: g.symbol,
22 + subtitle: g.name ?? 'Curated cancer evidence, variants and cohort alteration frequencies.',
23 + facts,
24 + sourcesNote: 'HGNC · CIViC · ClinVar · GDC · cBioPortal',
25 + });
26 +}
added apps/web/src/app/icon.svg +11 −0
@@ -0,0 +1,11 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 + <rect width="64" height="64" rx="12" fill="#fafaf7"/>
3 + <circle cx="29" cy="32" r="21" fill="none" stroke="#1c1c1a" stroke-width="5" stroke-linecap="round" stroke-dasharray="91.6 131.9" transform="rotate(55 29 32)"/>
4 + <line x1="48" y1="15" x2="48" y2="49" stroke="#0f5f63" stroke-width="5" stroke-linecap="round"/>
5 + <line x1="48" y1="17" x2="57" y2="17" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/>
6 + <line x1="48" y1="24.5" x2="54" y2="24.5" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/>
7 + <line x1="48" y1="32" x2="57" y2="32" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/>
8 + <line x1="48" y1="39.5" x2="54" y2="39.5" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/>
9 + <line x1="48" y1="47" x2="57" y2="47" stroke="#0f5f63" stroke-width="3.5" stroke-linecap="round"/>
10 + <circle cx="29" cy="32" r="4.5" fill="#1c1c1a"/>
11 +</svg>
modified apps/web/src/app/layout.tsx +1 −1
@@ -18,7 +18,7 @@ export const metadata: Metadata = {
18 18 creator: AUTHOR_NAME,
19 19 robots: { index: true, follow: true },
20 20 openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: 'Provenance-first oncology intelligence: every number linked to its source.' },
21 twitter: { card: 'summary', title: SITE_NAME, description: TAGLINE },
21 + twitter: { card: 'summary_large_image', title: SITE_NAME, description: TAGLINE, creator: '@spboucher' },
22 22 };
23 23
24 24 export const viewport: Viewport = {
added apps/web/src/app/manifest.ts +19 −0
@@ -0,0 +1,19 @@
1 +import type { MetadataRoute } from 'next';
2 +import { SITE_NAME, TAGLINE } from '@/lib/site';
3 +
4 +export default function manifest(): MetadataRoute.Manifest {
5 + return {
6 + name: `${SITE_NAME} — ${TAGLINE}`,
7 + short_name: SITE_NAME,
8 + description: 'Provenance-first oncology intelligence: epidemiology, genomics, biomarkers, drugs, approvals, trials and research across every recognized cancer entity.',
9 + start_url: '/',
10 + display: 'standalone',
11 + background_color: '#fafaf7',
12 + theme_color: '#0f5f63',
13 + icons: [
14 + { src: '/brand/icon-192.png', sizes: '192x192', type: 'image/png' },
15 + { src: '/brand/icon-512.png', sizes: '512x512', type: 'image/png' },
16 + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' },
17 + ],
18 + };
19 +}
added apps/web/src/app/opengraph-image.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og';
2 +import { getSiteCounts } from '@/lib/queries/stats';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +export const alt = 'CancerIndex — the global index of cancer';
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +/** Site-wide share image: live database counts (never estimates), refreshed hourly. */
11 +export default async function Image() {
12 + const c = await getSiteCounts();
13 + const facts = [
14 + c.cancers ? { label: 'cancers indexed', value: fmtInt(c.cancers) } : null,
15 + c.activeTrials ? { label: 'active trials', value: fmtInt(c.activeTrials) } : null,
16 + c.approvedDrugs ? { label: 'approved drugs', value: fmtInt(c.approvedDrugs) } : null,
17 + c.genes ? { label: 'genes', value: fmtInt(c.genes) } : null,
18 + ].filter((f): f is { label: string; value: string } => !!f);
19 + return ogImage({
20 + kicker: 'Global cancer intelligence',
21 + title: 'Understand cancer through data.',
22 + subtitle: 'Epidemiology, genomics, biomarkers, drugs, approvals, trials and research, cross-linked across every recognized cancer entity.',
23 + facts,
24 + sourcesNote: 'live database counts',
25 + });
26 +}
added apps/web/src/app/trial/[nct]/opengraph-image.tsx +27 −0
@@ -0,0 +1,27 @@
1 +import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from '@/lib/og';
2 +import { getTrialByNct } from '@/lib/queries/trials';
3 +import { fmtInt, humanize, phaseLabel } from '@/lib/format';
4 +
5 +export const alt = 'CancerIndex clinical trial page';
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +export const revalidate = 3600;
9 +
10 +export default async function Image({ params }: { params: Promise<{ nct: string }> }) {
11 + const { nct } = await params;
12 + const t = await getTrialByNct(nct);
13 + if (!t) return ogImage({ kicker: 'Clinical trial', title: 'Study not found', subtitle: nct });
14 + const facts = [
15 + t.phases.length ? { label: 'phase', value: t.phases.map(phaseLabel).join(' / ') } : null,
16 + t.overall_status ? { label: 'status', value: humanize(t.overall_status) } : null,
17 + t.enrollment_count != null ? { label: 'enrollment (n)', value: fmtInt(t.enrollment_count) } : null,
18 + t.countries.length ? { label: 'countries', value: fmtInt(t.countries.length) } : null,
19 + ].filter((f): f is { label: string; value: string } => !!f);
20 + return ogImage({
21 + kicker: `Clinical trial · ${t.nct_id}${t.study_type ? ` · ${humanize(t.study_type)}` : ''}`,
22 + title: t.brief_title,
23 + subtitle: t.lead_sponsor ? `Lead sponsor: ${t.lead_sponsor}${t.lead_sponsor_class ? ` (${humanize(t.lead_sponsor_class)})` : ''}` : null,
24 + facts,
25 + sourcesNote: 'ClinicalTrials.gov · status and phase as posted',
26 + });
27 +}
added apps/web/src/assets/fonts/inter-400.woff +0 −0

Binary file not shown.

added apps/web/src/assets/fonts/inter-600.woff +0 −0

Binary file not shown.

added apps/web/src/assets/fonts/newsreader-500.woff +0 −0

Binary file not shown.

added apps/web/src/assets/fonts/newsreader-500i.woff +0 −0

Binary file not shown.

added apps/web/src/assets/fonts/plexmono-400.woff +0 −0

Binary file not shown.

added apps/web/src/components/layout/brand-mark.tsx +19 −0
@@ -0,0 +1,19 @@
1 +/**
2 + * CancerIndex mark: an open ring (the "C", a cell) and a graduated index scale (the "I"). The ring
3 + * and nucleus follow the text colour so the mark adapts to light and dark themes; the scale uses
4 + * the accent token. Static SVG assets for external use live in /public/brand.
5 + */
6 +export function BrandMark({ size = 28, className = '', title = 'CancerIndex' }: { size?: number; className?: string; title?: string }) {
7 + return (
8 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width={size} height={size} role="img" aria-label={title} className={className}>
9 + <circle cx="30" cy="32" r="24" fill="none" stroke="currentColor" strokeWidth="4.5" strokeLinecap="round" strokeDasharray="104.7 150.8" transform="rotate(55 30 32)" />
10 + <line x1="50" y1="14" x2="50" y2="50" stroke="var(--color-accent)" strokeWidth="4.5" strokeLinecap="round" />
11 + <line x1="50" y1="16" x2="60" y2="16" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" />
12 + <line x1="50" y1="24" x2="56" y2="24" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" />
13 + <line x1="50" y1="32" x2="60" y2="32" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" />
14 + <line x1="50" y1="40" x2="56" y2="40" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" />
15 + <line x1="50" y1="48" x2="60" y2="48" stroke="var(--color-accent)" strokeWidth="3" strokeLinecap="round" />
16 + <circle cx="30" cy="32" r="4.5" fill="currentColor" />
17 + </svg>
18 + );
19 +}
modified apps/web/src/components/layout/site-footer.tsx +5 −1
@@ -1,4 +1,5 @@
1 1 import Link from 'next/link';
2 +import { BrandMark } from './brand-mark';
2 3 import { DISCLAIMER, FOOTER_NAV, CONTACT_EMAIL, AUTHOR_NAME, AUTHOR_URL, HOSTING_NAME, HOSTING_URL } from '@/lib/site';
3 4
4 5 export function SiteFooter() {
@@ -7,7 +8,10 @@ export function SiteFooter() {
7 8 <div className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6">
8 9 <div className="grid gap-6 md:grid-cols-[1.4fr_1fr]">
9 10 <div>
10 <p className="font-display text-lg">CancerIndex</p>
11 + <p className="flex items-center gap-2 font-display text-lg">
12 + <BrandMark size={22} className="text-ink" />
13 + Cancer<span className="text-accent">Index</span>
14 + </p>
11 15 <p className="mt-2 max-w-2xl text-[13px] leading-relaxed text-ink-2">{DISCLAIMER}</p>
12 16 <p className="mt-2 max-w-2xl text-[12.5px] leading-relaxed text-ink-3">
13 17 Every number links to its source and retrieval date. Derived metrics carry a formula version. AI-generated synthesis is not enabled in this phase; all content is database-grounded.
modified apps/web/src/components/layout/site-header.tsx +6 −2
@@ -3,13 +3,17 @@ import { NAV } from '@/lib/site';
3 3 import { CommandPalette } from './command-palette';
4 4 import { MobileNav } from './mobile-nav';
5 5 import { ThemeToggle } from './theme-toggle';
6 +import { BrandMark } from './brand-mark';
6 7
7 8 export function SiteHeader() {
8 9 return (
9 10 <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur-sm" style={{ height: 'var(--ci-header-h)' }}>
10 11 <div className="mx-auto flex h-full max-w-[1400px] items-center gap-4 px-4 sm:px-6">
11 <Link href="/" className="flex items-baseline gap-2 no-underline">
12 <span className="font-display text-[20px] font-medium tracking-tight text-ink">CancerIndex</span>
12 + <Link href="/" className="flex items-center gap-2.5 no-underline" aria-label="CancerIndex home">
13 + <BrandMark size={26} className="shrink-0 text-ink" />
14 + <span className="font-display text-[20px] font-medium tracking-tight text-ink">
15 + Cancer<span className="text-accent">Index</span>
16 + </span>
13 17 <span className="hidden text-[11px] uppercase tracking-[0.08em] text-ink-3 md:inline">the global index of cancer</span>
14 18 </Link>
15 19 <nav aria-label="Primary" className="ml-2 hidden flex-1 items-center gap-4 lg:flex">
added apps/web/src/lib/og.tsx +132 −0
@@ -0,0 +1,132 @@
1 +import { readFile } from 'node:fs/promises';
2 +import path from 'node:path';
3 +import { ImageResponse } from 'next/og';
4 +
5 +/**
6 + * Social-share images (Open Graph / Twitter cards), 1200 × 630, rendered server-side with the site
7 + * 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 facts
9 + * passed in by the caller — never estimates.
10 + */
11 +
12 +export const OG_SIZE = { width: 1200, height: 630 } as const;
13 +export const OG_CONTENT_TYPE = 'image/png';
14 +
15 +const PAPER = '#fafaf7';
16 +const INK = '#1c1c1a';
17 +const INK2 = '#4a4a46';
18 +const INK3 = '#7b7b75';
19 +const RULE = '#dcdcd4';
20 +const ACCENT = '#0f5f63';
21 +const ACCENT_SOFT = '#e2eeee';
22 +
23 +let fontCache: Promise<Array<{ name: string; data: ArrayBuffer; weight: 400 | 500 | 600; style: 'normal' | 'italic' }>> | null = null;
24 +let markCache: Promise<string> | null = null;
25 +
26 +function assetDir(): string {
27 + return path.join(process.cwd(), 'src', 'assets', 'fonts');
28 +}
29 +
30 +async 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 +}
48 +
49 +/** The brand mark as a PNG data URI (satori renders <img>; SVG dash arrays are not supported). */
50 +async 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 +}
56 +
57 +export interface OgFact {
58 + label: string;
59 + value: string;
60 +}
61 +
62 +export 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 +}
73 +
74 +function 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 +}
79 +
80 +export 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>
101 +
102 + {/* 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>
107 +
108 + {/* 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}
119 +
120 + {/* 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