SPB Git

spb/cancerindex

Public

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 (Sep 11, 2026) parent 711d796 Browse files

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 creator: AUTHOR_NAME, 18 creator: AUTHOR_NAME,
19 robots: { index: true, follow: true }, 19 robots: { index: true, follow: true },
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.' }, 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 export const viewport: Viewport = { 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 import Link from 'next/link'; 1 import Link from 'next/link';
2+import { BrandMark } from './brand-mark';
2 import { DISCLAIMER, FOOTER_NAV, CONTACT_EMAIL, AUTHOR_NAME, AUTHOR_URL, HOSTING_NAME, HOSTING_URL } from '@/lib/site'; 3 import { DISCLAIMER, FOOTER_NAV, CONTACT_EMAIL, AUTHOR_NAME, AUTHOR_URL, HOSTING_NAME, HOSTING_URL } from '@/lib/site';
3 4
4 export function SiteFooter() { 5 export function SiteFooter() {
@@ -7,7 +8,10 @@ export function SiteFooter() {
7 <div className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6"> 8 <div className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6">
8 <div className="grid gap-6 md:grid-cols-[1.4fr_1fr]"> 9 <div className="grid gap-6 md:grid-cols-[1.4fr_1fr]">
9 <div> 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 <p className="mt-2 max-w-2xl text-[13px] leading-relaxed text-ink-2">{DISCLAIMER}</p> 15 <p className="mt-2 max-w-2xl text-[13px] leading-relaxed text-ink-2">{DISCLAIMER}</p>
12 <p className="mt-2 max-w-2xl text-[12.5px] leading-relaxed text-ink-3"> 16 <p className="mt-2 max-w-2xl text-[12.5px] leading-relaxed text-ink-3">
13 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. 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 import { CommandPalette } from './command-palette'; 3 import { CommandPalette } from './command-palette';
4 import { MobileNav } from './mobile-nav'; 4 import { MobileNav } from './mobile-nav';
5 import { ThemeToggle } from './theme-toggle'; 5 import { ThemeToggle } from './theme-toggle';
6+import { BrandMark } from './brand-mark';
6 7
7 export function SiteHeader() { 8 export function SiteHeader() {
8 return ( 9 return (
9 <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur-sm" style={{ height: 'var(--ci-header-h)' }}> 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 <div className="mx-auto flex h-full max-w-[1400px] items-center gap-4 px-4 sm:px-6"> 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+ <Link href="/" className="flex items-center gap-2.5 no-underline" aria-label="CancerIndex home">
12− <span className="font-display text-[20px] font-medium tracking-tight text-ink">CancerIndex</span> 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 <span className="hidden text-[11px] uppercase tracking-[0.08em] text-ink-3 md:inline">the global index of cancer</span> 17 <span className="hidden text-[11px] uppercase tracking-[0.08em] text-ink-3 md:inline">the global index of cancer</span>
14 </Link> 18 </Link>
15 <nav aria-label="Primary" className="ml-2 hidden flex-1 items-center gap-4 lg:flex"> 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
Menu

Theme