From efad43cbca8dc3727a71039a6f0d747357ec37a2 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:42:31 +0200 Subject: [PATCH 1/3] jsonld batch A: inline remaining cross-doc Dataset refs + FAQ tweaks (#1485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the isPartOf/isBasedOn fixes (PRs 1429, 1442, 1443, 1450, 1451) to every remaining page that emitted @id-only or bare-URL refs Google validates as standalone incomplete Datasets: - compare/[slug]: - isBasedOn: [urls] → [Dataset nodes with name+description+creator+ license+isAccessibleForFree] per shared bench - variableMeasured.PropertyValue gains numeric `value` when the winner has a positive p50 (Google Dataset Search extracts value) - FAQPage node gets an @id so it stops floating unmoored - alternatives/[slug]: isBasedOn: url → inline Dataset - answers/[slug]: isBasedOn: url → inline Dataset; Answer nodes (acceptedAnswer + suggestedAnswer) gain a `name` field - products/[slug]: subjectOf Datasets gain @id + identifier + variableMeasured + datePublished so they align with the bench-page Dataset shape and stop reading as 'Unnamed item' in RRT Co-authored-by: Florent Tapponnier --- src/app/alternatives/[slug]/page.tsx | 14 ++++++++++- src/app/answers/[slug]/page.tsx | 15 +++++++++++- src/app/compare/[slug]/page.tsx | 35 ++++++++++++++++++++++++---- src/app/products/[slug]/page.tsx | 10 ++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index f58b3563..03726fe1 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -128,7 +128,19 @@ export default async function AlternativePage({ datePublished: getBenchCreatedAt(bench.slug).toISOString(), ...(citableAsOf(bench) ? { dateModified: bench.lastRunAt } : {}), variableMeasured: bench.metric, - isBasedOn: benchUrl, + // Cross-doc @id-only refs get validated as standalone + // incomplete Datasets by Google (same failure mode as PR #1442's + // isBasedOn fix). Inline the required fields per bench. + isBasedOn: { + "@type": "Dataset" as const, + "@id": `${benchUrl}#dataset`, + name: bench.title, + description: bench.subtitle, + url: benchUrl, + creator: CREATOR_PUBLISHER, + license: DATASET_LICENSE, + isAccessibleForFree: true, + }, distribution: [ { "@type": "DataDownload", diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index f53b22bc..ecf424c5 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -182,6 +182,9 @@ export default async function AnswerPage({ text: ans.question, acceptedAnswer: { "@type": "Answer", + // Google's Rich Results validator flags Answer nodes without a + // `name` field (parity with the compare-page FAQPage builder). + name: ans.question, text: capDescription(shortAnswer, 990), url, author: { "@id": `${SITE.url}/#org` }, @@ -190,6 +193,7 @@ export default async function AnswerPage({ if (expertTake) { questionNode.suggestedAnswer = { "@type": "Answer", + name: ans.question, text: capDescription(expertTake, 990), url, author: { "@id": PERSON_ID }, @@ -218,7 +222,16 @@ export default async function AnswerPage({ author: { "@id": `${SITE.url}/#org` }, publisher: { "@id": `${SITE.url}/#org` }, image: `${SITE.url}/api/og/${bench.slug}`, - isBasedOn: benchUrl, + // Inline the referenced bench Dataset (bare URL ref gets + // validated as a standalone incomplete Dataset by Google, same + // failure mode as PR #1442's isBasedOn fix). + isBasedOn: { + "@type": "Dataset", + "@id": `${benchUrl}#dataset`, + name: bench.title, + description: bench.subtitle, + url: benchUrl, + }, }, buildBreadcrumbJsonLd([ { name: "Home", item: SITE.url }, diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 6db7cb9c..12659efd 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -717,12 +717,36 @@ export default async function ComparePage({ isAccessibleForFree: true, license: DATASET_LICENSE, measurementTechnique: `${SITE.url}/methodology`, - variableMeasured: shared.map((s) => ({ - "@type": "PropertyValue", + // PropertyValue in a Dataset variableMeasured needs a numeric `value` + // for Google Dataset Search + academic LLM tools to extract the + // structured fact. Fallback to the aggregate winner's p50 in the + // shared unit; skip PropertyValue.value entirely when neither side + // returned data so we never publish a fabricated zero. + variableMeasured: shared.map((s) => { + const winner = s.aggregateWinner === "a" ? s.aResult : s.bResult; + const v = winner?.p50; + return { + "@type": "PropertyValue" as const, + name: s.title, + unitText: s.unit, + ...(typeof v === "number" && v > 0 ? { value: v } : {}), + }; + }), + // isBasedOn is a cross-doc reference to each bench Dataset. Google + // validates each Dataset in isolation and does not stitch bare-URL + // refs, so previously flagged them as standalone Datasets without + // name/description (same failure mode as PR #1442's isBasedOn fix + // in dataset-jsonld.ts). Inline the required fields per bench. + isBasedOn: shared.map((s) => ({ + "@type": "Dataset" as const, + "@id": `${SITE.url}/benchmarks/${s.slug}#dataset`, name: s.title, - unitText: s.unit, + description: `${s.metric} (${s.unit}) benchmark on OpenChainBench: live measurements for ${a.name} and ${b.name}.`, + url: `${SITE.url}/benchmarks/${s.slug}`, + creator: CREATOR_PUBLISHER, + license: DATASET_LICENSE, + isAccessibleForFree: true, })), - isBasedOn: shared.map((s) => `${SITE.url}/benchmarks/${s.slug}`), distribution: shared.map((s) => ({ "@type": "DataDownload", encodingFormat: "application/json", @@ -772,6 +796,9 @@ export default async function ComparePage({ const faqJsonLd = { "@context": "https://schema.org", "@type": "FAQPage", + // GSC's Rich Results tester flags FAQPage nodes without an `@id` + // (every other FAQPage on the site emits one via the shared builder). + "@id": `${url}#faq`, // Google's Rich Results validator flags missing `name` on the // FAQPage parent even when Question.name is set. Add here + on // Answer nodes so the compare page matches the shared FAQ builder diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 793c4fdc..55dc11f6 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -16,6 +16,7 @@ import { import { Breadcrumb } from "@/components/breadcrumb"; import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { CREATOR_PUBLISHER, CITABLE_JSON_URL, DATASET_LICENSE } from "@/lib/dataset-jsonld"; +import { getBenchCreatedAt } from "@/lib/seo/bench-dates"; import { fetchHlBuilderStats, isHlBuilderSlug, @@ -446,9 +447,18 @@ export default async function ProviderPage({ ...(sameAs.length > 0 ? { sameAs } : {}), subjectOf: sorted.map((a) => ({ "@type": "Dataset", + // GSC + Google Dataset Search flag anonymous Datasets + // ("Unnamed item" with recommended fields missing) when the + // @id + datePublished are absent (audit 2026-07-26). Match + // the shape used by the bench page's own Dataset node so + // cross-page identifiers align. + "@id": `${SITE.url}/benchmarks/${a.benchmark.slug}#dataset`, name: a.benchmark.title, description: capDescription(a.benchmark.subtitle, 990), url: `${SITE.url}/benchmarks/${a.benchmark.slug}`, + identifier: a.benchmark.slug, + variableMeasured: a.benchmark.metric, + datePublished: getBenchCreatedAt(a.benchmark.slug).toISOString(), creator: CREATOR_PUBLISHER, publisher: CREATOR_PUBLISHER, isAccessibleForFree: true, From 8700b8ed60f419f075ce47c65e15e5388e98affe Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:45:08 +0200 Subject: [PATCH 2/3] seo batch B: per-bench OG on answers + visible FAQ on compare + grounding-first on bench pages (#1486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three AI-citation surface fixes flagged by the GEO audit: - answers/[slug]: og:image + twitter:image swapped from the site-wide /opengraph-image to the referenced bench's card (/api/og/). Perplexity / ChatGPT / Claude Deep Research scrape og:image alongside the answer text; a generic homepage tile costs a real cite. - compare/[slug]: added a visible
-based FAQ section rendering the same faqEntries the FAQPage JSON-LD emits. Google FAQ policy requires visible parity or it silently drops the rich result — the compare pages had jsonld without any visible questions. - benchmarks/[slug]: promoted the current-leader headline sentence to the first visible paragraph under H1, blended with the subtitle. AI answer engines quote the first prose sentence verbatim; a concrete provider + number + unit citable claim beats the methodology-flavoured subtitle for extraction. Co-authored-by: Florent Tapponnier --- src/app/answers/[slug]/page.tsx | 15 +++++++++++++-- src/app/benchmarks/[slug]/page.tsx | 21 ++++++++++++++++++--- src/app/compare/[slug]/page.tsx | 26 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index ecf424c5..28626486 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -75,14 +75,25 @@ export async function generateMetadata({ type: "article", url, siteName: SITE.name, - images: [{ url: `${SITE.url}/opengraph-image`, width: 1200, height: 630 }], + // Use the referenced bench's OG card instead of the site-wide + // default. Perplexity / ChatGPT / Claude Deep Research scrape the + // og:image alongside the answer text; showing the bench-specific + // leader card gives the AI a real datapoint next to the answer + // rather than the generic homepage tile. + images: [ + { + url: `${SITE.url}/api/og/${ans.bench.slug}`, + width: 1200, + height: 630, + }, + ], }, twitter: { card: "summary_large_image", site: SITE.twitter, title, description, - images: [`${SITE.url}/twitter-image`], + images: [`${SITE.url}/api/og/${ans.bench.slug}`], }, }; } diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 6aa8ba96..2bf70675 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -507,9 +507,24 @@ export default async function BenchmarkPage({

{benchmark.title}

-

- {benchmark.subtitle} -

+ {/* Grounding-line first-under-H1: Perplexity / ChatGPT search / + Claude Deep Research scrape the first prose sentence after the + H1 as the "citable claim." Previously the methodology-flavoured + subtitle sat here; the current-leader sentence carries a + concrete provider + number + unit that AI answer engines quote + verbatim ("Codex leads head lag at 0.8 s (p50, 24h)..."). Fall + back to the subtitle when no defensible leader exists (draft, + insufficient, cold start). */} + {sentence ? ( +

+ {sentence}{" "} + {benchmark.subtitle} +

+ ) : ( +

+ {benchmark.subtitle} +

+ )} {/* Compact "At a glance" card. Rendered as a native
element so it collapses to a one line summary on click while diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 12659efd..a3d28e45 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -912,6 +912,32 @@ export default async function ComparePage({ + {/* Visible FAQ parity with the FAQPage JSON-LD above. Google + requires visible Q&A text matching the schema entries or it + silently drops the rich result — the compare pages had FAQ + jsonld without any visible questions, so the FAQPage never + rendered in the SERP snippet. */} +
+

+ Frequently asked questions +

+
+ {faqEntries.map((e) => ( +
+ + {e.q} + +

+ {e.a} +

+
+ ))} +
+
+

How this pair was selected From 5cf75d125518d051ecb83bf1121ed70597f3a90f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:46:21 +0200 Subject: [PATCH 3/3] citation: change citation_author from Org to Person name (#1487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highwire Press citation_author expects a person name; Google Scholar routinely rejects records where the author field reads as a publisher organisation. Match the Person already anchored in the JSON-LD Dataset creator + StatisticalReport contributor so the two signals agree. Notes on other audit items skipped: - SITE.github (ChainBench vs Flotapponnier): both URLs return 200, ChainBench is the real org (we PR to it all day). Agent was wrong. - JSON-LD body→head migration: Google + Bing + major AI crawlers all scan the full document for ld+json. Refactor cost > payoff. Co-authored-by: Florent Tapponnier --- src/app/benchmarks/[slug]/page.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 2bf70675..f2f72fd7 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -165,7 +165,12 @@ export async function generateMetadata({ }, other: { citation_title: metaTitle, - citation_author: "OpenChainBench", + // Highwire Press citation_author expects a person name (Scholar + // routinely rejects records where author reads as a publisher + // organisation). The Person is also anchored in the JSON-LD + // Dataset creator + StatisticalReport contributor, so the two + // signals agree. + citation_author: "Florent Tapponnier", citation_publisher: "OpenChainBench", ...(isoPubDate ? { citation_publication_date: isoPubDate } : {}), citation_doi: "10.5281/zenodo.20800312",