Skip to content

Commit 8b14f3f

Browse files
aka-sacci-ccrdecobotclaude
authored
fix(blog): emit valid schema.org JSON-LD in blog SEO sections (#1643)
* fix(blog): emit valid schema.org JSON-LD in SeoBlogPost and SeoBlogPostListing Both sections were serializing the raw page object into the ld+json script: SeoBlogPost emitted a non-schema.org "BlogPostPage" node and SeoBlogPostListing spread the posts array into an object, producing invalid markup that Google ignores. Add blog/utils/jsonLD.ts with a toBlogPosting helper following https://developers.google.com/search/docs/appearance/structured-data/article and emit a BlogPosting node on post pages and a Blog node with blogPost items on listing pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(blog): publisher, dateModified, BreadcrumbList and canonicalBaseUrl in SEO JSON-LD - Drop empty AggregateRating/InteractionCounter stubs: only emit aggregateRating when ratingValue + ratingCount/reviewCount are set and interactionStatistic when userInteractionCount is set, since bare {"@type": ...} objects fail the Rich Results Test. - Emit publisher (Organization with optional logo/url) on BlogPosting and Blog nodes, configured app-wide via the new publisher state in mod.ts. - Add optional dateModified to BlogPost and emit it when present. - Add canonicalBaseUrl app state that overrides the origin of url/mainEntityOfPage, otherwise built from the request host. - Emit a BreadcrumbList on post and listing pages, derived from the canonical pathname: intermediate items resolve real category names (falling back to humanized slugs) with absolute item URLs; the last item carries the real page name and omits item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(blog): selectable Person/Organization type for Author in JSON-LD Google's Article guidelines accept Person or Organization as author. Add an optional type field to Author (defaults to Person) and emit it as the author @type; jobTitle/worksFor are Person-only properties and are skipped for organizations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(blog/website): align canonical link with JSON-LD host and escape JSON-LD serialization - Apply canonicalBaseUrl to the canonical link as well, so <link rel=canonical> and the JSON-LD url/mainEntityOfPage always point at the same host. - Escape "<" (plus U+2028/U+2029) when serializing jsonLDs in the Seo component: values containing "</script>" could break out of the ld+json script tag via dangerouslySetInnerHTML. The unicode escapes parse back to the same JSON. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(blog): guard timeRequired against invalid readTime and support relative canonical URLs - Only emit timeRequired for finite positive readTime values; negative or Infinity produced invalid ISO 8601 durations (PT-5M, PTInfinityM). - Resolve configured canonicals against the request URL in both SEO sections: a relative canonical previously reached new URL() in toBreadcrumbList and threw, breaking the page render. It also makes the emitted canonical link absolute, as Google requires. - withCanonicalBase now resolves relative URLs against the canonical base and preserves query/hash instead of dropping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: decobot <capy@deco.cx> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d7c4f13 commit 8b14f3f

6 files changed

Lines changed: 226 additions & 18 deletions

File tree

blog/mod.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import manifest, { Manifest } from "./manifest.gen.ts";
22
import { PreviewContainer } from "../utils/preview.tsx";
33
import { type App, type FnContext } from "@deco/deco";
4+
import type { Publisher } from "./types.ts";
45
export type State = {
56
/**
67
* @title Category Slug
@@ -14,6 +15,17 @@ export type State = {
1415
* @example /blog/:category/:slug
1516
*/
1617
pageSlug?: string;
18+
/**
19+
* @title Canonical Base URL
20+
* @description Overrides the origin of the url/mainEntityOfPage emitted in the JSON-LD by the SEO sections, which otherwise use the request host.
21+
* @example https://www.mysite.com
22+
*/
23+
canonicalBaseUrl?: string;
24+
/**
25+
* @title Publisher
26+
* @description Emitted as the publisher of the blog posts in the JSON-LD.
27+
*/
28+
publisher?: Publisher;
1729
};
1830
export type AppContext = FnContext<State, Manifest>;
1931
/**

blog/sections/Seo/SeoBlogPost.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import {
55
} from "../../../website/components/Seo.tsx";
66
import { BlogPostPage } from "../../types.ts";
77
import { AppContext } from "../../mod.ts";
8+
import {
9+
toBlogPosting,
10+
toBreadcrumbList,
11+
withCanonicalBase,
12+
} from "../../utils/jsonLD.ts";
813

914
export interface Props {
1015
/** @title Data Source */
@@ -16,7 +21,7 @@ export interface Props {
1621
}
1722

1823
/** @title Blog Post details */
19-
export function loader(props: Props, _req: Request, ctx: AppContext) {
24+
export function loader(props: Props, req: Request, ctx: AppContext) {
2025
const rawSeo = (ctx as unknown as { seo: Record<string, unknown> }).seo ?? {};
2126
const titleTemplate = typeof rawSeo.titleTemplate === "string"
2227
? rawSeo.titleTemplate
@@ -40,14 +45,26 @@ export function loader(props: Props, _req: Request, ctx: AppContext) {
4045

4146
const image = jsonLD?.post?.seo?.image || jsonLD?.seo?.image ||
4247
jsonLD?.post?.image;
43-
const canonical = jsonLD?.seo?.canonical ? jsonLD?.seo?.canonical : undefined;
48+
const { canonicalBaseUrl, publisher } = ctx;
49+
// Configured canonicals may be relative; resolve against the request URL
50+
const canonical = jsonLD?.seo?.canonical
51+
? withCanonicalBase(
52+
new URL(jsonLD.seo.canonical, req.url).href,
53+
canonicalBaseUrl,
54+
)
55+
: undefined;
4456
const noIndexing = !jsonLD || jsonLD.seo?.noIndexing;
4557

46-
// Some HTML can break the meta tag
47-
const jsonLDWithoutContent = {
48-
...jsonLD,
49-
post: { ...jsonLD?.post, content: undefined },
50-
};
58+
const pageUrl = canonical ?? withCanonicalBase(req.url, canonicalBaseUrl);
59+
const jsonLDs = jsonLD?.post
60+
? [
61+
toBlogPosting(jsonLD.post, pageUrl, publisher),
62+
toBreadcrumbList(pageUrl, {
63+
currentName: jsonLD.post.title,
64+
categories: jsonLD.post.categories,
65+
}),
66+
]
67+
: [];
5168

5269
return {
5370
...seoSiteProps,
@@ -56,7 +73,7 @@ export function loader(props: Props, _req: Request, ctx: AppContext) {
5673
image,
5774
canonical,
5875
noIndexing,
59-
jsonLDs: [jsonLDWithoutContent],
76+
jsonLDs,
6077
};
6178
}
6279

blog/sections/Seo/SeoBlogPostListing.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import {
55
} from "../../../website/components/Seo.tsx";
66
import { BlogPostListingPage } from "../../types.ts";
77
import { AppContext } from "../../mod.ts";
8+
import {
9+
toBlogPosting,
10+
toBreadcrumbList,
11+
toOrganization,
12+
withCanonicalBase,
13+
} from "../../utils/jsonLD.ts";
814

915
export interface Props {
1016
/** @title Data Source */
@@ -15,8 +21,8 @@ export interface Props {
1521
description?: string;
1622
}
1723

18-
/** @title Blog Post details */
19-
export function loader(props: Props, _req: Request, ctx: AppContext) {
24+
/** @title Blog Post listing */
25+
export function loader(props: Props, req: Request, ctx: AppContext) {
2026
const rawSeo = (ctx as unknown as { seo: Record<string, unknown> }).seo ?? {};
2127
const titleTemplate = typeof rawSeo.titleTemplate === "string"
2228
? rawSeo.titleTemplate
@@ -38,22 +44,42 @@ export function loader(props: Props, _req: Request, ctx: AppContext) {
3844
descriptionProp || jsonLD?.seo?.description || "",
3945
);
4046

41-
const canonical = jsonLD?.seo?.canonical ? jsonLD?.seo?.canonical : undefined;
47+
const { canonicalBaseUrl, publisher } = ctx;
48+
// Configured canonicals may be relative; resolve against the request URL
49+
const canonical = jsonLD?.seo?.canonical
50+
? withCanonicalBase(
51+
new URL(jsonLD.seo.canonical, req.url).href,
52+
canonicalBaseUrl,
53+
)
54+
: undefined;
4255
const noIndexing = !jsonLD || jsonLD.seo?.noIndexing;
4356

44-
// Some HTML can break the meta tag
45-
const jsonLDWithoutContent = {
46-
...jsonLD,
47-
post: { ...jsonLD?.posts, content: undefined },
48-
};
57+
const url = canonical ?? withCanonicalBase(req.url, canonicalBaseUrl);
58+
const jsonLDs = jsonLD
59+
? [
60+
{
61+
"@type": "Blog" as const,
62+
...(title ? { name: title } : {}),
63+
...(description ? { description } : {}),
64+
url,
65+
mainEntityOfPage: { "@type": "WebPage" as const, "@id": url },
66+
...(publisher?.name ? { publisher: toOrganization(publisher) } : {}),
67+
blogPost: jsonLD.posts?.map((post) => toBlogPosting(post)) ?? [],
68+
},
69+
toBreadcrumbList(url, {
70+
currentName: jsonLD.category?.name || title || undefined,
71+
categories: jsonLD.categories ?? undefined,
72+
}),
73+
]
74+
: [];
4975

5076
return {
5177
...seoSiteProps,
5278
title,
5379
description,
5480
canonical,
5581
noIndexing,
56-
jsonLDs: [jsonLDWithoutContent],
82+
jsonLDs,
5783
};
5884
}
5985

blog/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { type Section } from "@deco/deco/blocks";
99
export interface Author {
1010
name: string;
1111
email: string;
12+
/**
13+
* @title Type
14+
* @description Whether the author is a person or an organization. Emitted as the author @type in the JSON-LD. Defaults to Person.
15+
* @default Person
16+
*/
17+
type?: "Person" | "Organization";
1218
avatar?: ImageWidget;
1319
jobTitle?: string;
1420
company?: string;
@@ -51,6 +57,12 @@ export interface BlogPost {
5157
* @format date
5258
*/
5359
date: string;
60+
/**
61+
* @title Modified date
62+
* @format date
63+
* @description Date of the last relevant content update. Emitted as dateModified in the JSON-LD.
64+
*/
65+
dateModified?: string;
5466
slug: string;
5567
/**
5668
* @title Post Content
@@ -104,6 +116,14 @@ export interface Seo {
104116
noIndexing?: boolean;
105117
}
106118

119+
/** @titleBy name */
120+
export interface Publisher {
121+
name: string;
122+
/** @title Logo */
123+
logo?: ImageWidget;
124+
url?: string;
125+
}
126+
107127
export interface BlogPostPage {
108128
"@type": "BlogPostPage";
109129
post: BlogPost;

blog/utils/jsonLD.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Author, BlogPost, Category, Publisher } from "../types.ts";
2+
3+
const toAuthor = (author: Author) => {
4+
const type = author.type ?? "Person";
5+
return {
6+
"@type": type,
7+
name: author.name,
8+
// jobTitle and worksFor are Person-only properties
9+
...(type === "Person" && author.jobTitle
10+
? { jobTitle: author.jobTitle }
11+
: {}),
12+
...(type === "Person" && author.company
13+
? { worksFor: { "@type": "Organization" as const, name: author.company } }
14+
: {}),
15+
};
16+
};
17+
18+
export const toOrganization = (publisher: Publisher) => ({
19+
"@type": "Organization" as const,
20+
name: publisher.name,
21+
...(publisher.url ? { url: publisher.url } : {}),
22+
...(publisher.logo
23+
? { logo: { "@type": "ImageObject" as const, url: publisher.logo } }
24+
: {}),
25+
});
26+
27+
/**
28+
* Replaces the origin of a URL, keeping path, query and hash. Relative URLs
29+
* are resolved against the canonical base.
30+
*/
31+
export const withCanonicalBase = (url: string, canonicalBaseUrl?: string) => {
32+
if (!canonicalBaseUrl) {
33+
return url;
34+
}
35+
const { pathname, search, hash } = new URL(url, canonicalBaseUrl);
36+
return new URL(pathname + search + hash, canonicalBaseUrl).href;
37+
};
38+
39+
/**
40+
* Maps a BlogPost to a schema.org BlogPosting node, following
41+
* https://developers.google.com/search/docs/appearance/structured-data/article
42+
*
43+
* The "@context" is intentionally omitted: the Seo component adds it when
44+
* serializing the top-level JSON-LD object.
45+
*/
46+
export const toBlogPosting = (
47+
post: BlogPost,
48+
url?: string,
49+
publisher?: Publisher,
50+
) => {
51+
const image = post.seo?.image || post.image;
52+
const categories = post.categories
53+
?.map((category) => category.name)
54+
.filter(Boolean);
55+
56+
// AggregateRating requires ratingValue and ratingCount/reviewCount;
57+
// InteractionCounter requires userInteractionCount. Posts may carry empty
58+
// {"@type": ...} stubs, which are invalid in the Rich Results Test.
59+
const aggregateRating = post.aggregateRating?.ratingValue != null &&
60+
(post.aggregateRating.ratingCount != null ||
61+
post.aggregateRating.reviewCount != null)
62+
? post.aggregateRating
63+
: undefined;
64+
const interactionStatistic =
65+
post.interactionStatistic?.userInteractionCount != null
66+
? post.interactionStatistic
67+
: undefined;
68+
69+
return {
70+
"@type": "BlogPosting" as const,
71+
headline: post.title,
72+
...(post.excerpt ? { description: post.excerpt } : {}),
73+
...(image ? { image: [image] } : {}),
74+
...(post.date ? { datePublished: post.date } : {}),
75+
...(post.dateModified ? { dateModified: post.dateModified } : {}),
76+
...(post.authors?.length ? { author: post.authors.map(toAuthor) } : {}),
77+
...(publisher?.name ? { publisher: toOrganization(publisher) } : {}),
78+
...(categories?.length ? { articleSection: categories } : {}),
79+
...(post.readTime && post.readTime > 0 && Number.isFinite(post.readTime)
80+
? { timeRequired: `PT${post.readTime}M` }
81+
: {}),
82+
...(url
83+
? { url, mainEntityOfPage: { "@type": "WebPage" as const, "@id": url } }
84+
: {}),
85+
...(aggregateRating ? { aggregateRating } : {}),
86+
...(interactionStatistic ? { interactionStatistic } : {}),
87+
};
88+
};
89+
90+
const humanize = (slug: string) =>
91+
decodeURIComponent(slug)
92+
.replace(/[-_]+/g, " ")
93+
.replace(/^./, (char) => char.toUpperCase());
94+
95+
/**
96+
* Builds a schema.org BreadcrumbList from the pathname of the given absolute
97+
* URL. Intermediate segments resolve their names against the known categories
98+
* (falling back to a humanized slug) and link to absolute URLs; the last item
99+
* uses the real page name and omits "item", as allowed by Google.
100+
*/
101+
export const toBreadcrumbList = (
102+
url: string,
103+
{ currentName, categories }: {
104+
currentName?: string;
105+
categories?: Category[];
106+
} = {},
107+
) => {
108+
const { origin, pathname } = new URL(url);
109+
const segments = pathname.split("/").filter(Boolean);
110+
111+
const nameOf = (segment: string) =>
112+
categories?.find((category) => category.slug === segment)?.name ??
113+
humanize(segment);
114+
115+
const itemListElement = segments.map((segment, index) => {
116+
const isLast = index === segments.length - 1;
117+
return {
118+
"@type": "ListItem" as const,
119+
position: index + 1,
120+
name: isLast ? currentName || nameOf(segment) : nameOf(segment),
121+
...(isLast
122+
? {}
123+
: { item: `${origin}/${segments.slice(0, index + 1).join("/")}` }),
124+
};
125+
});
126+
127+
return { "@type": "BreadcrumbList" as const, itemListElement };
128+
};

website/components/Seo.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,12 @@ function Component({
140140
"@context": "https://schema.org",
141141
// @ts-expect-error Trust me, I'm an engineer
142142
...json,
143-
}),
143+
})
144+
// "<" would allow a "</script>" in the data to break out of the
145+
// tag; the unicode escapes stay equivalent when parsed as JSON
146+
.replace(/</g, "\\u003c")
147+
.replace(/\u2028/g, "\\u2028")
148+
.replace(/\u2029/g, "\\u2029"),
144149
}}
145150
/>
146151
))}

0 commit comments

Comments
 (0)