From cd236a495b3e2e50701d33d8780e84e933e9efb6 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 23:06:28 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Wave=201=20P2=20batch=20=E2=80=94?= =?UTF-8?q?=20.bib/.ris/apa=20MIME,=20sparkline=20SVG,=20Dataset=20Propert?= =?UTF-8?q?yValue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three GEO surface additions the audit downgraded to P2. Each one is duplicative with an existing channel (cite.bibtex string inside /api/stat, OG image at /api/og, StatisticalReport measuredValue) but covers a specific niche the existing channel does not reach cleanly: academic import-in-one-click, LLM inline embeddable chart, Google Dataset Search structured value extraction. Ship all three now so the GEO surface is complete before starting outreach; each is small enough to not warrant its own PR. ## /api/cite/{slug}/{format} Per-bench citation with the correct MIME type. Four formats: - bib -> application/x-bibtex (Zotero, LaTeX) - ris -> application/x-research-info-systems (EndNote, Mendeley) - apa -> text/plain (ready-to-paste APA string) - txt -> text/plain (ready-to-paste plain attribution) Content is the same as the cite.* fields already inside /api/stat JSON, but exposed with the right Content-Type + `Content-Disposition: attachment; filename=` so a browser or reference manager triggers the "import" flow rather than displaying the string. Added RIS to CiteBundle in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2 / ER fields, CRLF line endings per RFC convention). ## /api/sparkline/{slug} Standalone SVG sparkline of the current leader's 24 h series. Hand authored polyline, no charting library, no satori dependency; under 1 KB per response. Query params: `w` (40..1200, default 240), `h` (20..400, default 60), `theme=light|dark`. Higher-is-better metrics flip the y axis so a rising line always reads as "getting better over time". Under-populated series render a dashed placeholder line so embedders never get a broken image. Cases this reaches that /api/og and the raw JSON sparkline array do not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to posts, Notion pages. OG PNG at /api/og covers social preview cards but is too heavy (300 KB+) for inline chart embeds. ## Dataset.variableMeasured -> PropertyValue Bare-string array in Dataset JSON-LD upgraded to include structured PropertyValue objects when the current leader has real numeric aggregates. Each PropertyValue carries `name`, `propertyID` (machine matcher), `value` (leader p50/p90/p99 in the declared unit), `unitText`. sample_size stays a bare string — it labels an axis, not a measurement. Falls back to the legacy all-strings shape for drafts and insufficient benches so we never publish a fabricated numeric value. New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts. BenchDatasetInput.variableMeasured now accepts Array. `keywords` extraction pulls the `.name` off PropertyValue entries so keywords stay flat strings for indexer compat. ## openapi.json Both new endpoints registered under paths, so agent frameworks discovering the API through OpenAPI can call them. --- src/app/api/cite/[slug]/[format]/route.ts | 106 ++++++++++++++++ src/app/api/openapi.json/route.ts | 77 ++++++++++++ src/app/api/sparkline/[slug]/route.ts | 145 ++++++++++++++++++++++ src/app/benchmarks/[slug]/page.tsx | 34 +++-- src/lib/citation.ts | 18 +++ src/lib/dataset-jsonld.ts | 78 +++++++++++- 6 files changed, 443 insertions(+), 15 deletions(-) create mode 100644 src/app/api/cite/[slug]/[format]/route.ts create mode 100644 src/app/api/sparkline/[slug]/route.ts diff --git a/src/app/api/cite/[slug]/[format]/route.ts b/src/app/api/cite/[slug]/[format]/route.ts new file mode 100644 index 00000000..f09a44a9 --- /dev/null +++ b/src/app/api/cite/[slug]/[format]/route.ts @@ -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": "*", + }, + }); +} diff --git a/src/app/api/openapi.json/route.ts b/src/app/api/openapi.json/route.ts index c609492c..dfe847d2 100644 --- a/src/app/api/openapi.json/route.ts +++ b/src/app/api/openapi.json/route.ts @@ -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: diff --git a/src/app/api/sparkline/[slug]/route.ts b/src/app/api/sparkline/[slug]/route.ts new file mode 100644 index 00000000..f87cee8d --- /dev/null +++ b/src/app/api/sparkline/[slug]/route.ts @@ -0,0 +1,145 @@ +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 `` 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; + +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 = + ``; + } else { + const { points } = buildPolyline( + values, + width, + height, + bench.higherIsBetter === true, + ); + svgBody = + ``; + } + const title = `${bench.title} — 24h sparkline${top ? ` (${top.name})` : ""}`; + const svg = + `\n` + + `` + + `${title}` + + `` + + svgBody + + `\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": "*", + }, + }); +} diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 3d63745c..14215e18 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -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"; @@ -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/ 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, diff --git a/src/lib/citation.ts b/src/lib/citation.ts index 2b73b70a..ab2ed58c 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -169,6 +169,13 @@ export type CiteBundle = { plain: string; bibtex: string; apa: string; + /** RIS record (Research Information Systems) for Zotero, EndNote, + * Mendeley and every academic reference manager that speaks the + * format. Same fields as bibtex, RIS field codes: TY (type), + * AU (author), TI (title), PY (year), UR (url), Y2 (retrieved + * date), ER (end record). Type GEN is the neutral fallback for a + * dataset citation — RIS has no "benchmark" type. */ + ris: string; }; const MONTHS = [ @@ -202,6 +209,17 @@ export function citeBundle( plain: `OpenChainBench. "${b.title}". Retrieved ${isoDate}. ${url}`, bibtex: `@misc{${bibKey},\n author = {OpenChainBench},\n title = {${b.title}},\n year = {${yyyy}},\n url = {${url}},\n note = {Retrieved ${isoDate}}\n}`, apa: `OpenChainBench. (${yyyy}). ${b.title}. Retrieved ${longDate}, from ${url}`, + // RIS records use CRLF line endings by convention (RFC-style), + // which every consumer we've tested (Zotero 6+, EndNote 20+, + // Mendeley) accepts either way — using \r\n stays safe. + ris: + `TY - GEN\r\n` + + `AU - OpenChainBench\r\n` + + `TI - ${b.title}\r\n` + + `PY - ${yyyy}\r\n` + + `UR - ${url}\r\n` + + `Y2 - ${isoDate}\r\n` + + `ER - \r\n`, }; } diff --git a/src/lib/dataset-jsonld.ts b/src/lib/dataset-jsonld.ts index 567080b1..951cc20c 100644 --- a/src/lib/dataset-jsonld.ts +++ b/src/lib/dataset-jsonld.ts @@ -117,22 +117,85 @@ export const GLOBAL_DATASET_JSONLD = { /** Inputs for a per-bench Dataset entry. Keeps the call site at * /benchmarks/[slug] decoupled from the Benchmark type so the helper is * trivially reusable from the per-chain page and future variants. */ +/** Schema.org PropertyValue node used inside variableMeasured to publish + * a measured aggregate with its numeric value. This is the shape Google + * Dataset Search + academic LLM tools extract as a structured fact, + * unlike a bare label string which is opaque to a crawler. + * + * `value` is the current leader's p50 (or leader's p90/p99) expressed in + * the same unit as `unitText`; downstream consumers can key on + * `propertyID` for machine matching and on `name` for display. */ +export type VariableMeasuredValue = { + "@type": "PropertyValue"; + propertyID: string; + name: string; + value: number; + unitText: string; +}; + export type BenchDatasetInput = { slug: string; name: string; alternateName?: string; description: string; url: string; - /** Schema.org Dataset accepts variableMeasured as a string or array. - * We pass an array of metric labels (p50, p90, p99, sample_size, ...) - * so Google's validator reports each metric individually. */ - variableMeasured: string[]; + /** Schema.org Dataset accepts variableMeasured as a string, a + * PropertyValue, or an array mixing both. Pass PropertyValue objects + * when the current leader's numeric aggregates are available (Google + * Dataset Search / academic LLM tools index the numeric `value` + * directly); fall back to bare label strings for drafts or when the + * aggregate is `sample_size`-shaped rather than a per-percentile + * measurement. */ + variableMeasured: Array; category: string; datePublished: string; dateModified?: string; measurementTechnique?: string; }; +/** Build the `variableMeasured` array for a bench Dataset node. + * - When the leader carries a real numeric p50 (and unit), the p50 / + * p90 / p99 percentiles ship as PropertyValue objects so Google + * Dataset Search and academic LLM tools extract the numeric fact + * without parsing prose. + * - `sample_size` and the bare metric label stay as strings — they + * describe axes, not measured values. + * - When no defensible leader exists (draft, insufficient) the array + * degrades to the legacy string-only shape so we never publish a + * fabricated PropertyValue with a zero-fallback value. */ +export function buildBenchVariableMeasured(input: { + metric: string; + unit: string; + leader: { name: string; p50: number; p90: number; p99: number } | null; +}): Array { + if (!input.leader || input.leader.p50 <= 0) { + return [ + input.metric, + `${input.metric}_p50`, + `${input.metric}_p90`, + `${input.metric}_p99`, + "sample_size", + ]; + } + const mk = ( + suffix: "p50" | "p90" | "p99", + value: number, + ): VariableMeasuredValue => ({ + "@type": "PropertyValue", + propertyID: `${input.metric.toLowerCase().replace(/\s+/g, "_")}_${suffix}`, + name: `${input.metric} ${suffix}`, + value, + unitText: input.unit, + }); + return [ + input.metric, + mk("p50", input.leader.p50), + mk("p90", input.leader.p90), + mk("p99", input.leader.p99), + "sample_size", + ]; +} + /** Inputs for the per-bench StatisticalReport companion node. Wrapping * the single-leader claim as `StatisticalReport` + inline `Observation` * is the shape Google Dataset Search and Perplexity's grounding pipe @@ -220,7 +283,12 @@ export function buildBenchDatasetJsonLd( keywords: [ input.category, ...KEYWORDS, - ...input.variableMeasured, + // Keywords should stay flat strings for indexer compat, so pull + // the display name off any PropertyValue entries rather than + // leaking `[object Object]` into the graph. + ...input.variableMeasured.map((v) => + typeof v === "string" ? v : v.name, + ), ], creator: CREATOR_PUBLISHER, publisher: CREATOR_PUBLISHER, From 8bf963d87b5fc80ae305b685c2904e20f8945e5c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 13 Jul 2026 23:15:26 +0200 Subject: [PATCH 2/2] sparkline: XML-escape bench title + provider name in SVG --- src/app/api/sparkline/[slug]/route.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/app/api/sparkline/[slug]/route.ts b/src/app/api/sparkline/[slug]/route.ts index f87cee8d..49fbbf0a 100644 --- a/src/app/api/sparkline/[slug]/route.ts +++ b/src/app/api/sparkline/[slug]/route.ts @@ -32,6 +32,19 @@ const MAX_W = 1200; const MIN_H = 20; const MAX_H = 400; +/** Minimal XML escape for user-controlled strings interpolated into the + * SVG's 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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + 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; @@ -123,7 +136,9 @@ export async function GET( `stroke-width="${strokeWidth}" stroke-linecap="round" ` + `stroke-linejoin="round"/>`; } - const title = `${bench.title} — 24h sparkline${top ? ` (${top.name})` : ""}`; + 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" ` +