diff --git a/src/components/PageHead.astro b/src/components/PageHead.astro index 0cc5330eea2..fe4d4cff985 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 }); // 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..c5236904ca0 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"; @@ -135,10 +136,16 @@ 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"); + }); +}); + +describe("resolvePageHeadline", () => { + test("returns the semantic page title", () => { + expect(resolvePageHeadline({ title: "Get started" })).toBe("Get started"); }); }); @@ -181,7 +188,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 +252,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 +262,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..afc0834ae30 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; +} + /** ``: `${base} · ${suffix}` when a suffix applies, else a raw * head.title override or `${title} | ${siteTitle}`. */ export function resolvePageTitle({ @@ -17,12 +27,21 @@ 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 semantic page title, matching the page's visible + * `<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; +} + /** Favicon link: first of svg > ico > png that exists, else svg. */ export function resolveFavicon(exists: (file: string) => boolean): { file: string; @@ -104,7 +123,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 +139,7 @@ export interface StructuredDataInput { export function buildStructuredData({ schemaType, canonical, - fullTitle, + headline, description, lang, ogImage, @@ -134,7 +153,7 @@ export function buildStructuredData({ "@context": "https://schema.org", "@type": schemaType, "@id": `${canonical}#page`, - headline: fullTitle, + headline, ...(description ? { description } : {}), url: canonical, inLanguage: lang,