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..fc1479a6b70 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,25 @@ 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("uses the page title without a product/site suffix", () => { + expect(resolvePageHeadline({ title: "Get started" })).toBe("Get started"); + }); + + test("uses the clean base even when `` preserves the raw override", () => { + expect( + resolvePageHeadline({ + title: "raw", + titleOverride: "Model X (Author) | Workers AI", + }), + ).toBe("Model X (Author)"); }); }); @@ -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 </script> b", + headline: "a </script> b", })!; expect(out).not.toContain("</script>"); 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..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,12 +27,24 @@ 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: 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 }); +} + /** Favicon link: first of svg > ico > png that exists, else svg. */ export function resolveFavicon(exists: (file: string) => boolean): { file: string; @@ -104,7 +126,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 +142,7 @@ export interface StructuredDataInput { export function buildStructuredData({ schemaType, canonical, - fullTitle, + headline, description, lang, ogImage, @@ -134,7 +156,7 @@ export function buildStructuredData({ "@context": "https://schema.org", "@type": schemaType, "@id": `${canonical}#page`, - headline: fullTitle, + headline, ...(description ? { description } : {}), url: canonical, inLanguage: lang,