From 1ed7f3bada5bd62ce2b3a81bc6670d5a43af1742 Mon Sep 17 00:00:00 2001 From: pranavsekhar Date: Thu, 17 Sep 2026 11:15:41 -0700 Subject: [PATCH 1/5] fix: clean JSON-LD page headlines --- src/components/PageHead.astro | 4 +++- .../__snapshots__/page-head.node.test.ts.snap | 2 +- src/util/page-head.node.test.ts | 22 ++++++++++++++++--- src/util/page-head.ts | 17 +++++++++++--- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro index 0cc5330eea2..aa083aa1b29 100644 --- a/src/components/PageHead.astro +++ b/src/components/PageHead.astro @@ -17,6 +17,7 @@ import { classifyContentType, inferContentType, resolveFavicon, + resolvePageHeadline, resolvePageTitle, resolveSocialImagePath, } from "~/util/page-head"; @@ -110,6 +111,7 @@ const fullTitle = resolvePageTitle({ titleSuffix, siteTitle: config.title, }); +const structuredHeadline = resolvePageHeadline({ title, titleOverride }); // Parity: no site-wide fallback — an absent description omits the tags. const resolvedDescription = description; @@ -175,7 +177,7 @@ const datePublished = meta?.datePublished const jsonLd = buildStructuredData({ schemaType: resolvedSchemaType, canonical, - fullTitle, + headline: structuredHeadline, description: structuredDescription, lang, ogImage, diff --git a/src/util/__snapshots__/page-head.node.test.ts.snap b/src/util/__snapshots__/page-head.node.test.ts.snap index 722513fd5e2..d2922ede024 100644 --- a/src/util/__snapshots__/page-head.node.test.ts.snap +++ b/src/util/__snapshots__/page-head.node.test.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`buildStructuredData > exact key order + full string (locks byte-for-byte parity) 1`] = `"{"@context":"https://schema.org","@type":"BlogPosting","@id":"https://developers.cloudflare.com/changelog/x/#page","headline":"X · Changelog","description":"desc","url":"https://developers.cloudflare.com/changelog/x/","inLanguage":"en","image":"https://developers.cloudflare.com/og.png","dateModified":"2025-02-02","datePublished":"2025-01-01","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["t1"]}"`; +exports[`buildStructuredData > exact key order + full string (locks byte-for-byte parity) 1`] = `"{"@context":"https://schema.org","@type":"BlogPosting","@id":"https://developers.cloudflare.com/changelog/x/#page","headline":"X","description":"desc","url":"https://developers.cloudflare.com/changelog/x/","inLanguage":"en","image":"https://developers.cloudflare.com/og.png","dateModified":"2025-02-02","datePublished":"2025-01-01","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["t1"]}"`; diff --git a/src/util/page-head.node.test.ts b/src/util/page-head.node.test.ts index 8effb4d8867..1cfb2e9cd4b 100644 --- a/src/util/page-head.node.test.ts +++ b/src/util/page-head.node.test.ts @@ -5,6 +5,7 @@ import { classifyContentType, inferContentType, resolveFavicon, + resolvePageHeadline, resolvePageTitle, resolveSocialImagePath, } from "./page-head"; @@ -142,6 +143,21 @@ describe("resolvePageTitle", () => { }); }); +describe("resolvePageHeadline", () => { + test("uses the page title without a product/site suffix", () => { + expect(resolvePageHeadline({ title: "Get started" })).toBe("Get started"); + }); + + test("uses the clean base of a head-title override", () => { + expect( + resolvePageHeadline({ + title: "raw", + titleOverride: "Model X (Author) | Workers AI", + }), + ).toBe("Model X (Author)"); + }); +}); + describe("classifyContentType", () => { test("empty → (null, not changelog, TechArticle)", () => { expect(classifyContentType("")).toEqual({ @@ -181,7 +197,7 @@ describe("buildStructuredData", () => { const base = { schemaType: "TechArticle" as const, canonical: "https://developers.cloudflare.com/workers/", - fullTitle: "Get started · Workers", + headline: "Get started", lang: "en", isChangelog: false, }; @@ -245,7 +261,7 @@ describe("buildStructuredData", () => { test("escapes `<` to prevent breaking out of the script tag", () => { const out = buildStructuredData({ ...base, - fullTitle: "a b", + headline: "a b", })!; expect(out).not.toContain(""); expect(out).toContain("\\u003c/script>"); @@ -255,7 +271,7 @@ describe("buildStructuredData", () => { const out = buildStructuredData({ schemaType: "BlogPosting", canonical: "https://developers.cloudflare.com/changelog/x/", - fullTitle: "X · Changelog", + headline: "X", description: "desc", lang: "en", ogImage: "https://developers.cloudflare.com/og.png", diff --git a/src/util/page-head.ts b/src/util/page-head.ts index 594df64e021..46e73cba5d3 100644 --- a/src/util/page-head.ts +++ b/src/util/page-head.ts @@ -23,6 +23,17 @@ export function resolvePageTitle({ : (titleOverride ?? `${title} | ${siteTitle}`); } +/** JSON-LD headline: the clean page title without the product/site suffix. */ +export function resolvePageHeadline({ + title, + titleOverride, +}: { + title: string; + titleOverride?: string; +}): string { + return titleOverride ? titleOverride.split(" | ")[0] : title; +} + /** Favicon link: first of svg > ico > png that exists, else svg. */ export function resolveFavicon(exists: (file: string) => boolean): { file: string; @@ -104,7 +115,7 @@ export function classifyContentType( export interface StructuredDataInput { schemaType: SchemaType; canonical: string | null; - fullTitle: string; + headline: string; description?: string; lang: string; ogImage?: string | null; @@ -120,7 +131,7 @@ export interface StructuredDataInput { export function buildStructuredData({ schemaType, canonical, - fullTitle, + headline, description, lang, ogImage, @@ -134,7 +145,7 @@ export function buildStructuredData({ "@context": "https://schema.org", "@type": schemaType, "@id": `${canonical}#page`, - headline: fullTitle, + headline, ...(description ? { description } : {}), url: canonical, inLanguage: lang, From 1ced8926c1f86c9ea2a5faca796792c82d040776 Mon Sep 17 00:00:00 2001 From: pranavsekhar Date: Thu, 17 Sep 2026 15:35:46 -0700 Subject: [PATCH 2/5] refactor: share clean title resolution --- src/util/page-head.node.test.ts | 6 +++--- src/util/page-head.ts | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/util/page-head.node.test.ts b/src/util/page-head.node.test.ts index 1cfb2e9cd4b..fc1479a6b70 100644 --- a/src/util/page-head.node.test.ts +++ b/src/util/page-head.node.test.ts @@ -136,10 +136,10 @@ describe("resolvePageTitle", () => { expect( resolvePageTitle({ title: "raw", - titleOverride: "Custom Head Title", + titleOverride: "Custom Head Title | SEO context", siteTitle: "Cloudflare Docs", }), - ).toBe("Custom Head Title"); + ).toBe("Custom Head Title | SEO context"); }); }); @@ -148,7 +148,7 @@ describe("resolvePageHeadline", () => { expect(resolvePageHeadline({ title: "Get started" })).toBe("Get started"); }); - test("uses the clean base of a head-title override", () => { + test("uses the clean base even when `` preserves the raw override", () => { expect( resolvePageHeadline({ title: "raw", diff --git a/src/util/page-head.ts b/src/util/page-head.ts index 46e73cba5d3..89ada0d6ce3 100644 --- a/src/util/page-head.ts +++ b/src/util/page-head.ts @@ -4,6 +4,16 @@ import { formatContentType } from "./content-type"; export type SchemaType = "BlogPosting" | "WebPage" | "TechArticle"; +function resolveBaseTitle({ + title, + titleOverride, +}: { + title: string; + titleOverride?: string; +}): string { + return titleOverride ? titleOverride.split(" | ")[0] : title; +} + /** `<title>`: `${base} · ${suffix}` when a suffix applies, else a raw * head.title override or `${title} | ${siteTitle}`. */ export function resolvePageTitle({ @@ -17,13 +27,14 @@ export function resolvePageTitle({ titleSuffix?: string; siteTitle: string; }): string { - const baseTitle = titleOverride ? titleOverride.split(" | ")[0] : title; + const baseTitle = resolveBaseTitle({ title, titleOverride }); return titleSuffix ? `${baseTitle} · ${titleSuffix}` : (titleOverride ?? `${title} | ${siteTitle}`); } -/** JSON-LD headline: the clean page title without the product/site suffix. */ +/** JSON-LD headline: always the clean semantic page title without an SEO, + * product, or site suffix, even when `<title>` preserves a raw override. */ export function resolvePageHeadline({ title, titleOverride, @@ -31,7 +42,7 @@ export function resolvePageHeadline({ title: string; titleOverride?: string; }): string { - return titleOverride ? titleOverride.split(" | ")[0] : title; + return resolveBaseTitle({ title, titleOverride }); } /** Favicon link: first of svg > ico > png that exists, else svg. */ From 0e96abaa2f191c94ae4264fc53fc6db1588b0b4c Mon Sep 17 00:00:00 2001 From: mvm <vance@cloudflare.com> Date: Mon, 21 Sep 2026 11:57:48 -0500 Subject: [PATCH 3/5] fix: derive JSON-LD headline from the frontmatter title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline overrides use inconsistent separators (" - ", " — ", " · ") that occur inside real page titles in both orders, so splitting them is not reliable. The frontmatter title is the visible <h1>, which keeps the structured-data headline consistent with the page content (addresses CR-1dc86d7a5411 from the #33509 review). --- src/components/PageHead.astro | 2 +- src/util/page-head.node.test.ts | 15 +++++++-------- src/util/page-head.ts | 15 +++++---------- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro index aa083aa1b29..fe4d4cff985 100644 --- a/src/components/PageHead.astro +++ b/src/components/PageHead.astro @@ -111,7 +111,7 @@ const fullTitle = resolvePageTitle({ titleSuffix, siteTitle: config.title, }); -const structuredHeadline = resolvePageHeadline({ title, titleOverride }); +const structuredHeadline = resolvePageHeadline({ title }); // Parity: no site-wide fallback — an absent description omits the tags. const resolvedDescription = description; diff --git a/src/util/page-head.node.test.ts b/src/util/page-head.node.test.ts index fc1479a6b70..7e9d14bc235 100644 --- a/src/util/page-head.node.test.ts +++ b/src/util/page-head.node.test.ts @@ -144,17 +144,16 @@ describe("resolvePageTitle", () => { }); describe("resolvePageHeadline", () => { - test("uses the page title without a product/site suffix", () => { + test("uses the semantic page title", () => { expect(resolvePageHeadline({ title: "Get started" })).toBe("Get started"); }); - test("uses the clean base even when `<title>` preserves the raw override", () => { - expect( - resolvePageHeadline({ - title: "raw", - titleOverride: "Model X (Author) | Workers AI", - }), - ).toBe("Model X (Author)"); + test("ignores `<title>` overrides with section suffixes (e.g. 'Performance · Use cases')", () => { + expect(resolvePageHeadline({ title: "Performance" })).toBe("Performance"); + }); + + test("ignores `<title>` overrides with product suffixes (e.g. 'Plans — Cloudflare for SaaS')", () => { + expect(resolvePageHeadline({ title: "Plans" })).toBe("Plans"); }); }); diff --git a/src/util/page-head.ts b/src/util/page-head.ts index 89ada0d6ce3..84a97340a17 100644 --- a/src/util/page-head.ts +++ b/src/util/page-head.ts @@ -33,16 +33,11 @@ export function resolvePageTitle({ : (titleOverride ?? `${title} | ${siteTitle}`); } -/** JSON-LD headline: always the clean semantic page title without an SEO, - * product, or site suffix, even when `<title>` preserves a raw override. */ -export function resolvePageHeadline({ - title, - titleOverride, -}: { - title: string; - titleOverride?: string; -}): string { - return resolveBaseTitle({ title, titleOverride }); +/** JSON-LD headline: the semantic page title, matching the page's visible + * `<h1>` — never the `<title>` override or its section/product/site suffixes + * ("Performance · Use cases", "Plans — Cloudflare for SaaS"). */ +export function resolvePageHeadline({ title }: { title: string }): string { + return title; } /** Favicon link: first of svg > ico > png that exists, else svg. */ From 27c6e3463d11ac8a31215312f471d029e894f63e Mon Sep 17 00:00:00 2001 From: mvm <vance@cloudflare.com> Date: Mon, 21 Sep 2026 14:40:35 -0500 Subject: [PATCH 4/5] fix: guard the headline against title override leakage resolvePageHeadline accepts the <title> override and documents it as deliberately ignored, so the section/product suffix tests pass real overrides and prove it is not parsed (addresses CR-669744c84224). --- src/components/PageHead.astro | 2 +- src/util/page-head.node.test.ts | 14 ++++++++++++-- src/util/page-head.ts | 11 +++++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro index fe4d4cff985..aa083aa1b29 100644 --- a/src/components/PageHead.astro +++ b/src/components/PageHead.astro @@ -111,7 +111,7 @@ const fullTitle = resolvePageTitle({ titleSuffix, siteTitle: config.title, }); -const structuredHeadline = resolvePageHeadline({ title }); +const structuredHeadline = resolvePageHeadline({ title, titleOverride }); // Parity: no site-wide fallback — an absent description omits the tags. const resolvedDescription = description; diff --git a/src/util/page-head.node.test.ts b/src/util/page-head.node.test.ts index 7e9d14bc235..18cd7d7f668 100644 --- a/src/util/page-head.node.test.ts +++ b/src/util/page-head.node.test.ts @@ -149,11 +149,21 @@ describe("resolvePageHeadline", () => { }); test("ignores `<title>` overrides with section suffixes (e.g. 'Performance · Use cases')", () => { - expect(resolvePageHeadline({ title: "Performance" })).toBe("Performance"); + expect( + resolvePageHeadline({ + title: "Performance", + titleOverride: "Performance · Use cases", + }), + ).toBe("Performance"); }); test("ignores `<title>` overrides with product suffixes (e.g. 'Plans — Cloudflare for SaaS')", () => { - expect(resolvePageHeadline({ title: "Plans" })).toBe("Plans"); + expect( + resolvePageHeadline({ + title: "Plans", + titleOverride: "Plans — Cloudflare for SaaS", + }), + ).toBe("Plans"); }); }); diff --git a/src/util/page-head.ts b/src/util/page-head.ts index 84a97340a17..2c8bb3ff6de 100644 --- a/src/util/page-head.ts +++ b/src/util/page-head.ts @@ -35,8 +35,15 @@ export function resolvePageTitle({ /** JSON-LD headline: the semantic page title, matching the page's visible * `<h1>` — never the `<title>` override or its section/product/site suffixes - * ("Performance · Use cases", "Plans — Cloudflare for SaaS"). */ -export function resolvePageHeadline({ title }: { title: string }): string { + * ("Performance · Use cases", "Plans — Cloudflare for SaaS"). `titleOverride` + * is deliberately ignored: SEO overrides use inconsistent separators and can + * carry real page titles on either side (e.g. "Get started - CLI"). */ +export function resolvePageHeadline({ + title, +}: { + title: string; + titleOverride?: string; +}): string { return title; } From d4f7e213e815dd0d277cb6cffe76b19bac20aba7 Mon Sep 17 00:00:00 2001 From: mvm <vance@cloudflare.com> Date: Mon, 21 Sep 2026 14:55:32 -0500 Subject: [PATCH 5/5] fix: drop titleOverride from resolvePageHeadline The override is no longer an input at all, so the headline being free of override suffixes holds by construction and the call site is guarded by TypeScript's excess-property check. Resolves the tension between CR-7564e63faba3 (tests cannot prove ignoring) and CR-6f600c2b08b2 (dead API surface): both findings dissolve with the param removed. --- src/components/PageHead.astro | 2 +- src/util/page-head.node.test.ts | 20 +------------------- src/util/page-head.ts | 15 +++++---------- 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro index aa083aa1b29..fe4d4cff985 100644 --- a/src/components/PageHead.astro +++ b/src/components/PageHead.astro @@ -111,7 +111,7 @@ const fullTitle = resolvePageTitle({ titleSuffix, siteTitle: config.title, }); -const structuredHeadline = resolvePageHeadline({ title, titleOverride }); +const structuredHeadline = resolvePageHeadline({ title }); // Parity: no site-wide fallback — an absent description omits the tags. const resolvedDescription = description; diff --git a/src/util/page-head.node.test.ts b/src/util/page-head.node.test.ts index 18cd7d7f668..c5236904ca0 100644 --- a/src/util/page-head.node.test.ts +++ b/src/util/page-head.node.test.ts @@ -144,27 +144,9 @@ describe("resolvePageTitle", () => { }); describe("resolvePageHeadline", () => { - test("uses the semantic page title", () => { + test("returns the semantic page title", () => { expect(resolvePageHeadline({ title: "Get started" })).toBe("Get started"); }); - - test("ignores `<title>` overrides with section suffixes (e.g. 'Performance · Use cases')", () => { - expect( - resolvePageHeadline({ - title: "Performance", - titleOverride: "Performance · Use cases", - }), - ).toBe("Performance"); - }); - - test("ignores `<title>` overrides with product suffixes (e.g. 'Plans — Cloudflare for SaaS')", () => { - expect( - resolvePageHeadline({ - title: "Plans", - titleOverride: "Plans — Cloudflare for SaaS", - }), - ).toBe("Plans"); - }); }); describe("classifyContentType", () => { diff --git a/src/util/page-head.ts b/src/util/page-head.ts index 2c8bb3ff6de..afc0834ae30 100644 --- a/src/util/page-head.ts +++ b/src/util/page-head.ts @@ -34,16 +34,11 @@ export function resolvePageTitle({ } /** JSON-LD headline: the semantic page title, matching the page's visible - * `<h1>` — never the `<title>` override or its section/product/site suffixes - * ("Performance · Use cases", "Plans — Cloudflare for SaaS"). `titleOverride` - * is deliberately ignored: SEO overrides use inconsistent separators and can - * carry real page titles on either side (e.g. "Get started - CLI"). */ -export function resolvePageHeadline({ - title, -}: { - title: string; - titleOverride?: string; -}): string { + * `<h1>`. The `<title>` override is deliberately not an input: SEO overrides + * use inconsistent separators and can carry real page titles on either side + * (e.g. "Get started - CLI"), so their section/product suffixes cannot be + * stripped reliably. */ +export function resolvePageHeadline({ title }: { title: string }): string { return title; }