Skip to content
Merged
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 });
// 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"]}"`;
17 changes: 12 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,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");
});
});

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -245,7 +252,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 +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",
Expand Down
27 changes: 23 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,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;
Expand Down Expand Up @@ -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;
Expand All @@ -120,7 +139,7 @@ export interface StructuredDataInput {
export function buildStructuredData({
schemaType,
canonical,
fullTitle,
headline,
description,
lang,
ogImage,
Expand All @@ -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,
Expand Down
Loading