diff --git a/blog/tests/jsonLD.test.ts b/blog/tests/jsonLD.test.ts new file mode 100644 index 000000000..c6e83b1dc --- /dev/null +++ b/blog/tests/jsonLD.test.ts @@ -0,0 +1,78 @@ +import { assertEquals } from "@std/assert"; +import { BlogPost } from "../types.ts"; +import { toISODateTime } from "../utils/date.ts"; +import { toBlogPosting } from "../utils/jsonLD.ts"; + +const post = (overrides: Partial = {}): BlogPost => ({ + title: "How to brew", + slug: "how-to-brew", + date: "2024-06-01", + excerpt: "A guide", + ...overrides, +}); + +Deno.test("toISODateTime places a bare date at 08:00 UTC", () => { + assertEquals(toISODateTime("2025-06-01"), "2025-06-01T08:00:00.000Z"); +}); + +Deno.test("toISODateTime pins an offset-less date-time to UTC", () => { + assertEquals( + toISODateTime("2025-06-01T00:00:00"), + "2025-06-01T00:00:00.000Z", + ); +}); + +Deno.test("toISODateTime keeps the instant of an offset date-time", () => { + assertEquals( + toISODateTime("2025-06-01T09:00:00+02:00"), + "2025-06-01T07:00:00.000Z", + ); +}); + +Deno.test("toISODateTime keeps the Unix epoch", () => { + assertEquals( + toISODateTime("1970-01-01T00:00:00Z"), + "1970-01-01T00:00:00.000Z", + ); +}); + +Deno.test("toISODateTime returns undefined for an unparseable value", () => { + assertEquals(toISODateTime(""), undefined); + assertEquals(toISODateTime("not a date"), undefined); +}); + +Deno.test("toISODateTime rejects an impossible calendar date", () => { + assertEquals(toISODateTime("2024-02-31"), undefined); + assertEquals(toISODateTime("2024-02-31T10:00:00Z"), undefined); +}); + +Deno.test("toISODateTime rejects non-ISO strings", () => { + assertEquals(toISODateTime("June 1, 2025"), undefined); +}); + +Deno.test("toBlogPosting normalizes full timestamps and bare dates alike", () => { + assertEquals( + toBlogPosting(post({ date: "2025-10-31T15:10:01Z" })).datePublished, + "2025-10-31T15:10:01.000Z", + ); + assertEquals( + toBlogPosting(post({ date: "2026-08-14" })).datePublished, + "2026-08-14T08:00:00.000Z", + ); +}); + +Deno.test("toBlogPosting emits dates with a timezone", () => { + const node = toBlogPosting(post({ dateModified: "2024-07-01T10:30:00" })); + + assertEquals(node.datePublished, "2024-06-01T08:00:00.000Z"); + assertEquals(node.dateModified, "2024-07-01T10:30:00.000Z"); +}); + +Deno.test("toBlogPosting drops dates that can't be parsed", () => { + const node = toBlogPosting( + post({ date: "not a date", dateModified: "soon" }), + ); + + assertEquals("datePublished" in node, false); + assertEquals("dateModified" in node, false); +}); diff --git a/blog/utils/date.ts b/blog/utils/date.ts index 1299df9db..8d1fe55e7 100644 --- a/blog/utils/date.ts +++ b/blog/utils/date.ts @@ -94,3 +94,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/blog/utils/jsonLD.ts b/blog/utils/jsonLD.ts index 0c2c8cac8..43a4ede54 100644 --- a/blog/utils/jsonLD.ts +++ b/blog/utils/jsonLD.ts @@ -1,4 +1,5 @@ import { Author, BlogPost, Category, Publisher } from "../types.ts"; +import { toISODateTime } from "./date.ts"; const toAuthor = (author: Author) => { const type = author.type ?? "Person"; @@ -76,13 +77,18 @@ export const toBlogPosting = ( ? 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 } : {}),