diff --git a/packages/apps-blog/src/__tests__/date.test.ts b/packages/apps-blog/src/__tests__/date.test.ts index 5870efa1..c5ae9448 100644 --- a/packages/apps-blog/src/__tests__/date.test.ts +++ b/packages/apps-blog/src/__tests__/date.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from "vitest"; import { sortPosts } from "../core/handlePosts"; import type { BlogPost } from "../types"; -import { dateToTime, scheduledTime } from "../utils/date"; +import { dateToTime, scheduledTime, toISODateTime } from "../utils/date"; const post = (slug: string, date: string): BlogPost => ({ title: slug, @@ -132,3 +132,35 @@ describe("scheduledTime", () => { expect(scheduledTime("1970-01-01T00:00:00Z")).toBe(0); }); }); + +describe("toISODateTime", () => { + it("places a bare date at 08:00 UTC with an explicit designator", () => { + expect(toISODateTime("2025-06-01")).toBe("2025-06-01T08:00:00.000Z"); + }); + + it("pins an offset-less date-time to UTC", () => { + expect(toISODateTime("2025-06-01T00:00:00")).toBe("2025-06-01T00:00:00.000Z"); + }); + + it("keeps the instant of a date-time that carries its own offset", () => { + expect(toISODateTime("2025-06-01T09:00:00+02:00")).toBe("2025-06-01T07:00:00.000Z"); + }); + + it("keeps the Unix epoch instead of mistaking it for a failure", () => { + expect(toISODateTime("1970-01-01T00:00:00Z")).toBe("1970-01-01T00:00:00.000Z"); + }); + + it("returns undefined for an unparseable value", () => { + expect(toISODateTime("")).toBeUndefined(); + expect(toISODateTime("not a date")).toBeUndefined(); + }); + + it("rejects an impossible calendar date instead of rolling it over", () => { + expect(toISODateTime("2024-02-31")).toBeUndefined(); + expect(toISODateTime("2024-02-31T10:00:00Z")).toBeUndefined(); + }); + + it("rejects non-ISO strings that `Date` would read in server-local time", () => { + expect(toISODateTime("June 1, 2025")).toBeUndefined(); + }); +}); diff --git a/packages/apps-blog/src/__tests__/seoSections.test.ts b/packages/apps-blog/src/__tests__/seoSections.test.ts index e895610c..1b53a105 100644 --- a/packages/apps-blog/src/__tests__/seoSections.test.ts +++ b/packages/apps-blog/src/__tests__/seoSections.test.ts @@ -57,11 +57,35 @@ describe("toBlogPosting", () => { const node = toBlogPosting(post({ dateModified: "2024-07-01" }), "https://a.com/blog/x"); expect(node["@type"]).toBe("BlogPosting"); expect(node.headline).toBe("How to brew"); - expect(node.datePublished).toBe("2024-06-01"); - expect(node.dateModified).toBe("2024-07-01"); + expect(node.datePublished).toBe("2024-06-01T08:00:00.000Z"); + expect(node.dateModified).toBe("2024-07-01T08:00:00.000Z"); expect(node.mainEntityOfPage).toEqual({ "@type": "WebPage", "@id": "https://a.com/blog/x" }); }); + it("emits dates as ISO 8601 date-times with a timezone, as the Rich Results Test requires", () => { + expect(toBlogPosting(post({ date: "2024-06-01T10:30:00" })).datePublished).toBe( + "2024-06-01T10:30:00.000Z", + ); + expect(toBlogPosting(post({ date: "2024-06-01T10:30:00-03:00" })).datePublished).toBe( + "2024-06-01T13:30:00.000Z", + ); + }); + + it("normalizes full timestamps and bare dates alike", () => { + expect(toBlogPosting(post({ date: "2025-10-31T15:10:01Z" })).datePublished).toBe( + "2025-10-31T15:10:01.000Z", + ); + expect(toBlogPosting(post({ date: "2026-08-14" })).datePublished).toBe( + "2026-08-14T08:00:00.000Z", + ); + }); + + it("drops dates that can't be parsed instead of emitting invalid structured data", () => { + const node = toBlogPosting(post({ date: "not a date", dateModified: "soon" })); + expect("datePublished" in node).toBe(false); + expect("dateModified" in node).toBe(false); + }); + it("omits `@context` — the Seo component adds it when serializing", () => { expect("@context" in toBlogPosting(post())).toBe(false); }); diff --git a/packages/apps-blog/src/utils/date.ts b/packages/apps-blog/src/utils/date.ts index 708ecfd1..cff02135 100644 --- a/packages/apps-blog/src/utils/date.ts +++ b/packages/apps-blog/src/utils/date.ts @@ -93,3 +93,28 @@ export const scheduledTime = (value: string): number | null => { // `+99:00` matches the pattern but is not a real instant. return Number.isNaN(parsed) ? null : parsed; }; + +const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Normalizes a CMS-authored date into a full ISO 8601 date-time with a + * timezone, for JSON-LD's `datePublished`/`dateModified`, or returns + * `undefined` if the value isn't a real ISO date. + * + * `BlogPost.date` is usually a bare `YYYY-MM-DD` (the admin's date picker), + * which Google's Rich Results Test rejects as an invalid datetime missing its + * timezone. A bare date carries no time of day, so it is placed at 08:00 UTC + * rather than midnight: that instant falls on the same calendar day from + * UTC-08:00 to UTC+14:00, so search results don't show a post a day early in + * the Americas. + * + * Parsing goes through `scheduledTime` rather than `dateToTime` for the same + * reasons it exists: an impossible date such as `2024-02-31` is dropped instead + * of rolling over into March, non-ISO strings aren't read in server-local time, + * and the Unix epoch isn't mistaken for a failure. Anything rejected is omitted + * rather than emitted as invalid structured data. + */ +export const toISODateTime = (date: string) => { + const time = scheduledTime(BARE_DATE.test(date) ? `${date}T08:00:00` : date); + return time === null ? undefined : new Date(time).toISOString(); +}; diff --git a/packages/apps-blog/src/utils/jsonLD.ts b/packages/apps-blog/src/utils/jsonLD.ts index 8f57a587..0739bbc2 100644 --- a/packages/apps-blog/src/utils/jsonLD.ts +++ b/packages/apps-blog/src/utils/jsonLD.ts @@ -1,4 +1,5 @@ import type { Author, BlogPost, Category, Publisher } from "../types"; +import { toISODateTime } from "./date"; const toAuthor = (author: Author) => { const type = author.type ?? "Person"; @@ -64,13 +65,16 @@ export const toBlogPosting = (post: BlogPost, url?: string, publisher?: Publishe const interactionStatistic = post.interactionStatistic?.userInteractionCount != null ? post.interactionStatistic : undefined; + const datePublished = post.date ? toISODateTime(post.date) : undefined; + const dateModified = post.dateModified ? toISODateTime(post.dateModified) : undefined; + return { "@type": "BlogPosting" as const, headline: post.title, ...(post.excerpt ? { description: post.excerpt } : {}), ...(image ? { image: [image] } : {}), - ...(post.date ? { datePublished: post.date } : {}), - ...(post.dateModified ? { dateModified: post.dateModified } : {}), + ...(datePublished ? { datePublished } : {}), + ...(dateModified ? { dateModified } : {}), ...(post.authors?.length ? { author: post.authors.map(toAuthor) } : {}), ...(publisher?.name ? { publisher: toOrganization(publisher) } : {}), ...(categories?.length ? { articleSection: categories } : {}),