From 26fa3bdf89940ebcf677567bfa7ff076fc0ba120 Mon Sep 17 00:00:00 2001 From: pranavsekhar Date: Thu, 17 Sep 2026 11:15:41 -0700 Subject: [PATCH 1/2] 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 9eb3d5ffe828b3393cddfa6a652e0d2259165cfe Mon Sep 17 00:00:00 2001 From: pranavsekhar Date: Thu, 17 Sep 2026 15:35:46 -0700 Subject: [PATCH 2/2] 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. */