Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/PageHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
classifyContentType,
inferContentType,
resolveFavicon,
resolvePageHeadline,
resolvePageTitle,
resolveSocialImagePath,
} from "~/util/page-head";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -175,7 +177,7 @@ const datePublished = meta?.datePublished
const jsonLd = buildStructuredData({
schemaType: resolvedSchemaType,
canonical,
fullTitle,
headline: structuredHeadline,
description: structuredDescription,
lang,
ogImage,
Expand Down
2 changes: 1 addition & 1 deletion src/util/__snapshots__/page-head.node.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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"]}"`;
26 changes: 21 additions & 5 deletions src/util/page-head.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
classifyContentType,
inferContentType,
resolveFavicon,
resolvePageHeadline,
resolvePageTitle,
resolveSocialImagePath,
} from "./page-head";
Expand Down Expand Up @@ -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 `<title>` preserves the raw override", () => {
expect(
resolvePageHeadline({
title: "raw",
titleOverride: "Model X (Author) | Workers AI",
}),
).toBe("Model X (Author)");
});
});

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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>");
Expand All @@ -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",
Expand Down
30 changes: 26 additions & 4 deletions src/util/page-head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -120,7 +142,7 @@ export interface StructuredDataInput {
export function buildStructuredData({
schemaType,
canonical,
fullTitle,
headline,
description,
lang,
ogImage,
Expand All @@ -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,
Expand Down