diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index c165674d..6a9f8b55 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -10,7 +10,6 @@ import { BenchmarkBodySkeleton } from "@/components/benchmark-body-skeleton"; import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { Breadcrumb } from "@/components/breadcrumb"; import { ChainHeadingsSummary } from "@/components/chain-headings-summary"; -import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { CitationBar } from "@/components/citation-bar"; import { LiveIndicator } from "@/components/live-indicator"; import { ShareSection } from "@/components/share-section"; diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts new file mode 100644 index 00000000..1af7fd10 --- /dev/null +++ b/src/app/rss.xml/route.ts @@ -0,0 +1,117 @@ +/** + * RSS 2.0 feed listing every live benchmark. + * + * Consumed by crypto news aggregators and journalists who watch for + * new benchmark releases (DefiLlama news pipeline, Wu Blockchain + * curation lists, custom Slack RSS-to-channel bots, etc). One entry + * per live bench, ordered by `datePublished` descending, with the + * canonical /benchmarks/ link and an auto-generated headline + * sentence as the item description. + * + * Why RSS 2.0 and not Atom: Atom is technically cleaner but every + * legacy aggregator on the planet speaks RSS 2.0 and most fall back + * to title-only if the feed declares Atom. RSS 2.0 maximises pickup. + * + * Freshness model: + * - `pubDate` per item = the bench's first commit (stable, from + * `bench-dates.ts`). + * - `lastBuildDate` on the channel = the most recent bench's pubDate. + * Aggregators use this to decide whether to refetch the body. + * + * Cache: 5 min edge TTL so a brand new bench surfaces in aggregator + * polls within minutes of being merged, without hammering the data + * layer on every poll. + */ + +import { NextResponse } from "next/server"; +import { loadAllBenchmarks } from "@/lib/spec"; +import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; +import { headlineSentence } from "@/lib/citation"; +import { SITE } from "@/data/site"; +import type { Benchmark } from "@/types/benchmark"; + +export const revalidate = 300; + +const FEED_TITLE = "OpenChainBench benchmark releases"; +const FEED_DESCRIPTION = + "Live measurements for crypto infrastructure: RPCs, oracles, aggregators, bridges, prediction markets. One entry per public benchmark."; + +function escapeXml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toRfc822(d: Date): string { + // RSS 2.0 dates are RFC 822 with a 4-digit year. Native `toUTCString` + // returns the same shape; only swap the 2-letter "GMT" trailer for + // "+0000" which a few stricter parsers prefer. + return d.toUTCString().replace("GMT", "+0000"); +} + +function itemDescription(b: Benchmark): string { + const sentence = headlineSentence(b); + if (sentence) return `${sentence} ${b.subtitle}`.trim(); + return b.subtitle; +} + +export async function GET() { + const all = await loadAllBenchmarks(); + const live = all.filter((b) => b.editorialStatus === "live"); + + const items = live + .map((b) => ({ + bench: b, + pubDate: getBenchCreatedAt(b.slug), + })) + .sort((a, c) => c.pubDate.getTime() - a.pubDate.getTime()); + + const latest = items[0]?.pubDate ?? new Date(); + const self = `${SITE.url}/rss.xml`; + const lines: string[] = []; + lines.push(''); + lines.push( + '', + ); + lines.push(" "); + lines.push(` ${escapeXml(FEED_TITLE)}`); + lines.push(` ${SITE.url}`); + lines.push(` ${escapeXml(FEED_DESCRIPTION)}`); + lines.push(" en"); + lines.push(` ${toRfc822(latest)}`); + lines.push( + ` `, + ); + + for (const { bench, pubDate } of items) { + const link = `${SITE.url}/benchmarks/${bench.slug}`; + const title = bench.seoTitle ?? bench.title; + lines.push(" "); + lines.push(` ${escapeXml(title)}`); + lines.push(` ${link}`); + lines.push(` ${link}`); + lines.push(` ${toRfc822(pubDate)}`); + lines.push(` ${escapeXml(bench.category)}`); + lines.push( + ` ${escapeXml(itemDescription(bench))}`, + ); + lines.push(" "); + } + + lines.push(" "); + lines.push(""); + + return new NextResponse(lines.join("\n"), { + status: 200, + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + // 5 min CDN cache + 1 h SWR so a brand-new bench reaches a polling + // aggregator within minutes, but a flood of polls during quiet hours + // doesn't re-render the route. + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", + }, + }); +}