Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/app/api/cite/[slug]/[format]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { getBenchmark } from "@/data/benchmarks";
import { SITE } from "@/data/site";
import { citeBundle } from "@/lib/citation";
import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";
import { SLUG_RE } from "@/lib/slug";

export const runtime = "nodejs";
export const revalidate = 3600;

/**
* Per-bench citation file with the correct MIME type so Zotero,
* EndNote, Mendeley, Google Scholar's "Cite" button and Perplexity's
* cite-picker recognize it as an importable citation record rather than
* a plain text blob.
*
* Four formats:
* - `bib` (application/x-bibtex) BibTeX record for LaTeX toolchains.
* - `ris` (application/x-research-info-systems) RIS record for
* Zotero, EndNote, Mendeley, Zotero-compatible tools.
* - `apa` (text/plain) Ready-to-paste APA string.
* - `txt` (text/plain) Ready-to-paste plain-language attribution.
*
* The content is the same as `/api/stat/{slug}.cite.*` inside JSON, but
* exposed here as a separate URL with the right Content-Type so a
* browser or reference manager triggers "import" rather than "display".
*/

const FORMATS = {
bib: {
contentType: "application/x-bibtex; charset=utf-8",
field: "bibtex",
fileExt: "bib",
},
ris: {
contentType: "application/x-research-info-systems; charset=utf-8",
field: "ris",
fileExt: "ris",
},
apa: {
contentType: "text/plain; charset=utf-8",
field: "apa",
fileExt: "apa.txt",
},
txt: {
contentType: "text/plain; charset=utf-8",
field: "plain",
fileExt: "txt",
},
} as const;

type Format = keyof typeof FORMATS;

function isFormat(x: string): x is Format {
return x in FORMATS;
}

export async function GET(
req: Request,
{ params }: { params: Promise<{ slug: string; format: string }> },
) {
const r = rateLimit(clientKey(req, "cite"), 60, 60, req);
if (!r.ok) return tooManyRequests(r.retryAfterSec);

const { slug, format } = await params;
if (!SLUG_RE.test(slug)) {
return NextResponse.json({ error: "bad_slug" }, { status: 400 });
}
if (!isFormat(format)) {
return NextResponse.json(
{
error: "bad_format",
allowed: Object.keys(FORMATS),
},
{ status: 400 },
);
}

const bench = await getBenchmark(slug);
if (!bench || bench.editorialStatus !== "live") {
return NextResponse.json(
{ error: "unknown_slug", slug },
{ status: 404, headers: { "cache-control": "public, s-maxage=60" } },
);
}

const cfg = FORMATS[format];
const bundle = citeBundle(bench, SITE.url);
const body = bundle[cfg.field as keyof typeof bundle];
const filename = `openchainbench-${slug}.${cfg.fileExt}`;

return new Response(body, {
status: 200,
headers: {
"content-type": cfg.contentType,
// `attachment` triggers the reference-manager import flow in
// browsers; `inline` would fight with Firefox+Zotero. Filename
// exposed as ASCII fallback + UTF-8 (RFC 5987) to survive
// non-ASCII bench titles. Slug is ASCII-only by SLUG_RE, so
// filename never needs escaping in practice.
"content-disposition": `attachment; filename="${filename}"`,
"cache-control": "public, s-maxage=3600, stale-while-revalidate=86400",
"access-control-allow-origin": "*",
},
});
}
77 changes: 77 additions & 0 deletions src/app/api/openapi.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,83 @@ export async function GET() {
},
},
},
"/api/sparkline/{slug}": {
get: {
summary:
"Standalone SVG sparkline of the current leader's 24 h series. Small enough to inline in Perplexity Pages, Notion, dev.to, and any embed that speaks img/svg. Query params: w (default 240), h (default 60), theme (light|dark).",
operationId: "get_sparkline_svg",
parameters: [
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
{
name: "w",
in: "query",
required: false,
schema: { type: "integer", minimum: 40, maximum: 1200 },
description: "Width in pixels. Defaults to 240.",
},
{
name: "h",
in: "query",
required: false,
schema: { type: "integer", minimum: 20, maximum: 400 },
description: "Height in pixels. Defaults to 60.",
},
{
name: "theme",
in: "query",
required: false,
schema: { type: "string", enum: ["light", "dark"] },
description: "Stroke and background. Defaults to `light`.",
},
],
responses: {
"200": {
description: "SVG",
content: { "image/svg+xml": {} },
},
"404": { description: "Unknown slug" },
},
},
},
"/api/cite/{slug}/{format}": {
get: {
summary:
"Per-bench citation record with the correct MIME so Zotero, EndNote, Mendeley, Google Scholar and Perplexity's cite-picker recognize the response as an importable citation rather than plain text.",
operationId: "get_citation_file",
parameters: [
{
name: "slug",
in: "path",
required: true,
schema: { type: "string" },
description: "Benchmark slug.",
},
{
name: "format",
in: "path",
required: true,
schema: {
type: "string",
enum: ["bib", "ris", "apa", "txt"],
},
description:
"bib -> application/x-bibtex, ris -> application/x-research-info-systems, apa/txt -> text/plain.",
},
],
responses: {
"200": {
description: "Citation record",
content: {
"application/x-bibtex": {},
"application/x-research-info-systems": {},
"text/plain": {},
},
},
"400": { description: "Malformed slug or unknown format" },
"404": { description: "Unknown slug" },
},
},
},
"/api/llm-context": {
get: {
summary:
Expand Down
160 changes: 160 additions & 0 deletions src/app/api/sparkline/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { getBenchmark } from "@/data/benchmarks";
import { NextResponse } from "next/server";
import { leader, sparklineFor } from "@/lib/citation";
import { valueInDeclaredUnit } from "@/lib/format";
import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";
import { SLUG_RE } from "@/lib/slug";

export const runtime = "nodejs";
export const revalidate = 300;

/**
* Standalone SVG sparkline for one bench, keyed to the current leader's
* 24 h series. Lets Perplexity Pages, ChatGPT, Notion, dev.to and any
* third-party embed inline a live micro-chart with `<img src>` instead
* of hotlinking the heavier OG PNG or hand-rendering the JSON array.
*
* /api/sparkline/aggregator-head-lag -> defaults (240x60)
* /api/sparkline/aggregator-head-lag?w=320 -> width override
* /api/sparkline/aggregator-head-lag?theme=dark -> dark stroke
*
* Returns a minimal, hand-authored polyline SVG. Deliberately no gzip
* dependency, no charting library, no runtime dep on satori: the payload
* is under 1 KB and every LLM embed target already handles it.
*/

const DEFAULT_W = 240;
const DEFAULT_H = 60;
const PAD_X = 4;
const PAD_Y = 4;
const MIN_W = 40;
const MAX_W = 1200;
const MIN_H = 20;
const MAX_H = 400;

/** Minimal XML escape for user-controlled strings interpolated into the
* SVG's <title> element and aria-label attribute. Bench titles and
* provider names come from YAML / a registry and can contain `&`, `<`,
* `>`, `"`; librsvg and Chrome inline-SVG both refuse to render
* malformed XML, so an embedder would silently see a broken image. */
function xmlEscape(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

function clampDim(raw: string | null, def: number, min: number, max: number) {
const n = raw == null ? NaN : Number(raw);
if (!Number.isFinite(n) || n < min) return def;
return Math.min(Math.floor(n), max);
}

function buildPolyline(
values: number[],
width: number,
height: number,
higherIsBetter: boolean,
): { points: string; min: number; max: number } {
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const drawW = width - 2 * PAD_X;
const drawH = height - 2 * PAD_Y;
const step = values.length > 1 ? drawW / (values.length - 1) : 0;
const points = values
.map((v, i) => {
const x = PAD_X + i * step;
// Sparklines read left to right; lower value at the bottom looks
// right for latency, but for higher-is-better metrics we invert
// so the rising line still reads as "getting better over time".
const norm = (v - min) / span;
const y = higherIsBetter
? PAD_Y + drawH - norm * drawH
: PAD_Y + norm * drawH;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return { points, min, max };
}

export async function GET(
req: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const r = rateLimit(clientKey(req, "sparkline"), 120, 60, req);
if (!r.ok) return tooManyRequests(r.retryAfterSec);

const { slug } = await params;
if (!SLUG_RE.test(slug)) {
return NextResponse.json({ error: "bad_slug" }, { status: 400 });
}
const bench = await getBenchmark(slug);
if (!bench || bench.editorialStatus !== "live") {
return NextResponse.json(
{ error: "unknown_slug", slug },
{ status: 404, headers: { "cache-control": "public, s-maxage=60" } },
);
}

const url = new URL(req.url);
const width = clampDim(url.searchParams.get("w"), DEFAULT_W, MIN_W, MAX_W);
const height = clampDim(url.searchParams.get("h"), DEFAULT_H, MIN_H, MAX_H);
const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
const stroke = theme === "dark" ? "#e5e5e5" : "#111111";
const bg = theme === "dark" ? "#111111" : "#ffffff";

// Prefer the current leader's series; fall back to whichever series is
// first (mirrors sparklineFor default). Empty series → tiny SVG with a
// placeholder line so an embedder never gets a broken image.
const top = leader(bench);
const rawValues = sparklineFor(bench, top?.slug);
// Convert to declared unit (unit "s" benches store ms internally) so
// the min/max labels and future annotations match the page value.
const values = rawValues.map((v) => valueInDeclaredUnit(v, bench.unit));

const strokeWidth = Math.max(1, Math.round(Math.min(width, height) / 40));
let svgBody: string;
if (values.length < 2) {
// Under-populated series: single flat placeholder. Better than a
// blank rectangle because the alt text still reads as "sparkline".
const midY = height / 2;
svgBody =
`<line x1="${PAD_X}" y1="${midY}" x2="${width - PAD_X}" y2="${midY}" ` +
`stroke="${stroke}" stroke-width="${strokeWidth}" ` +
`stroke-linecap="round" stroke-dasharray="2 3" opacity="0.5"/>`;
} else {
const { points } = buildPolyline(
values,
width,
height,
bench.higherIsBetter === true,
);
svgBody =
`<polyline points="${points}" fill="none" stroke="${stroke}" ` +
`stroke-width="${strokeWidth}" stroke-linecap="round" ` +
`stroke-linejoin="round"/>`;
}
const title = xmlEscape(
`${bench.title} — 24h sparkline${top ? ` (${top.name})` : ""}`,
);
const svg =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" ` +
`role="img" aria-label="${title}">` +
`<title>${title}</title>` +
`<rect width="${width}" height="${height}" fill="${bg}"/>` +
svgBody +
`</svg>\n`;

return new Response(svg, {
status: 200,
headers: {
"content-type": "image/svg+xml; charset=utf-8",
"cache-control": "public, s-maxage=300, stale-while-revalidate=900",
"access-control-allow-origin": "*",
},
});
}
34 changes: 24 additions & 10 deletions src/app/benchmarks/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { buildBreadcrumbJsonLd, buildFaqPageJsonLd, safeJsonLd } from "@/lib/jso
import {
buildBenchDatasetJsonLd,
buildBenchStatReportJsonLd,
buildBenchVariableMeasured,
} from "@/lib/dataset-jsonld";
import { renderTemplate } from "@/lib/bench-template";
import { canonicalChainSlug } from "@/lib/chain-aliases";
Expand Down Expand Up @@ -288,16 +289,29 @@ export default async function BenchmarkPage({
// never emit a structured "Observation" with a fabricated value.
const currentLeader = leader(benchmark);
// variableMeasured ships as an array so Google's Dataset validator
// reports each statistical aggregate individually rather than as one
// opaque string. Order matches what /api/stat/<slug> returns per
// provider, so a crawler can map field names 1:1.
const variableMeasured = [
benchmark.metric,
`${benchmark.metric}_p50`,
`${benchmark.metric}_p90`,
`${benchmark.metric}_p99`,
"sample_size",
];
// reports each statistical aggregate individually. When a defensible
// leader exists the p50/p90/p99 aggregates ship as PropertyValue
// objects with the numeric value in the declared unit, so Google
// Dataset Search and academic LLM tools extract the measurement as a
// structured fact rather than opaque axis labels. Drafts and
// insufficient benches fall back to bare strings so we never publish
// a fabricated numeric aggregate.
const leaderResult = currentLeader
? benchmark.results.find((r) => r.slug === currentLeader.slug)
: undefined;
const variableMeasured = buildBenchVariableMeasured({
metric: benchmark.metric,
unit: benchmark.unit,
leader:
currentLeader && leaderResult
? {
name: currentLeader.name,
p50: valueInDeclaredUnit(leaderResult.ms.p50, benchmark.unit),
p90: valueInDeclaredUnit(leaderResult.ms.p90, benchmark.unit),
p99: valueInDeclaredUnit(leaderResult.ms.p99, benchmark.unit),
}
: null,
});
const datasetNode = {
...buildBenchDatasetJsonLd({
slug: benchmark.slug,
Expand Down
Loading
Loading