diff --git a/src/app/reports/[category]/[slug]/page.tsx b/src/app/reports/[category]/[slug]/page.tsx new file mode 100644 index 00000000..e6c21e83 --- /dev/null +++ b/src/app/reports/[category]/[slug]/page.tsx @@ -0,0 +1,432 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { MDXRemote } from "next-mdx-remote/rsc"; +import remarkGfm from "remark-gfm"; +import { StatTable } from "@/components/reports/stat-table"; +import { RegionWinners } from "@/components/reports/region-winners"; +import { + getAllReports, + getReport, + REPORT_CATEGORY_META, +} from "@/lib/reports/loader"; +import { safeJsonLd } from "@/lib/jsonld"; +import { PERSON_ID } from "@/lib/hub-jsonld"; +import { SITE } from "@/data/site"; + +export const revalidate = 3600; + +const SERIF: React.CSSProperties = { fontFamily: "var(--font-serif)" }; + +type Props = { params: Promise<{ category: string; slug: string }> }; + +export async function generateStaticParams() { + const reports = getAllReports(); + return reports.map((r) => ({ category: r.categorySlug, slug: r.slug })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { category, slug } = await params; + const report = getReport(category, slug); + if (!report) return {}; + + const canonical = `${SITE.url}/reports/${category}/${slug}`; + const ogImage = report.ogImage ?? `${SITE.url}/opengraph-image`; + const social = `${report.title} · OpenChainBench`; + + return { + title: report.title, + description: report.summary, + alternates: { canonical }, + openGraph: { + title: social, + description: report.summary, + url: canonical, + type: "article", + siteName: "OpenChainBench", + publishedTime: new Date(report.publishedAt).toISOString(), + authors: [report.author], + images: [{ url: ogImage, width: 1200, height: 630 }], + }, + twitter: { + card: "summary_large_image", + title: social, + description: report.summary, + site: "@OpenChainBench", + images: [ogImage], + }, + }; +} + +function slugify(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); +} + +const MDX_COMPONENTS = { + StatTable, + RegionWinners, + + // Table of contents — pipe-separated section names, auto-generates anchor hrefs + TOC: ({ sections }: { sections: string }) => { + const items = sections.split("|").map((s) => s.trim()); + return ( + + ); + }, + + // Summary box — key findings list at article top + Summary: ({ children }: { children: React.ReactNode }) => ( + + ), + + // Orange highlighter on key sentences + Mark: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + + // Decision Framework card — used explicitly in MDX + UseCase: ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+

+ {label} +

+
+ {children} +
+
+ ), + + // Economist-style section header with scroll-target ID for TOC anchors + h2: (props: React.HTMLAttributes) => { + const text = typeof props.children === "string" ? props.children : ""; + return ( +
+

+ {props.children} +

+
+ ); + }, + + h3: (props: React.HTMLAttributes) => ( +

+ ), + + p: (props: React.HTMLAttributes) => ( +

+ ), + + ul: (props: React.HTMLAttributes) => ( +