From 83255907dc98ad24ebe1bb3bc9363387a7b18afd Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:14:32 -0300 Subject: [PATCH 1/4] fix(blog): emit JSON-LD dates as ISO date-times with a timezone BlogPosting's datePublished/dateModified were emitted verbatim, so a bare YYYY-MM-DD from the admin's date picker failed Google's Rich Results Test (invalid datetime, missing timezone). Normalize them via a new toISODateTime helper, pinned to UTC like dateToTime, and drop values that can't be parsed. Co-Authored-By: Claude Opus 5.5 (1M context) --- blog/tests/jsonLD.test.ts | 44 +++++++++++++++++++++++++++++++++++++++ blog/utils/date.ts | 16 ++++++++++++++ blog/utils/jsonLD.ts | 10 +++++++-- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 blog/tests/jsonLD.test.ts diff --git a/blog/tests/jsonLD.test.ts b/blog/tests/jsonLD.test.ts new file mode 100644 index 000000000..4426b19ea --- /dev/null +++ b/blog/tests/jsonLD.test.ts @@ -0,0 +1,44 @@ +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 expands a bare date to midnight UTC", () => { + assertEquals(toISODateTime("2025-06-01"), "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 returns undefined for an unparseable value", () => { + assertEquals(toISODateTime(""), undefined); + assertEquals(toISODateTime("not a date"), undefined); +}); + +Deno.test("toBlogPosting emits dates with a timezone", () => { + const node = toBlogPosting(post({ dateModified: "2024-07-01T10:30:00" })); + + assertEquals(node.datePublished, "2024-06-01T00: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..2983d751a 100644 --- a/blog/utils/date.ts +++ b/blog/utils/date.ts @@ -26,6 +26,22 @@ export const dateToTime = (date: string) => : date, ).getTime() || 0; +/** + * 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 can't be read as a 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. It is pinned to UTC through `dateToTime`, so the emitted instant + * matches the one posts are sorted by; unparseable values are dropped rather + * than emitted as invalid structured data. + */ +export const toISODateTime = (date: string) => { + const time = dateToTime(date); + return time ? new Date(time).toISOString() : undefined; +}; + /** * An ISO 8601 date, optionally with a time and an offset. Anchored, grouped and * deliberately narrow: `Date` accepts far more than this, and the extras are the 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 } : {}), From 1c800a3661fcd5b8e0605386b1d03689e8e4ab58 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:20:54 -0300 Subject: [PATCH 2/4] fix(blog): place bare JSON-LD dates at 08:00 UTC Midnight UTC is the previous evening across the Americas, so search results could show a post a day early. 08:00 UTC lands on the same calendar day from UTC-08:00 to UTC+14:00. Co-Authored-By: Claude Opus 5.5 (1M context) --- blog/tests/jsonLD.test.ts | 13 ++++++++++--- blog/utils/date.ts | 13 +++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/blog/tests/jsonLD.test.ts b/blog/tests/jsonLD.test.ts index 4426b19ea..f9ee474e8 100644 --- a/blog/tests/jsonLD.test.ts +++ b/blog/tests/jsonLD.test.ts @@ -11,8 +11,15 @@ const post = (overrides: Partial = {}): BlogPost => ({ ...overrides, }); -Deno.test("toISODateTime expands a bare date to midnight UTC", () => { - assertEquals(toISODateTime("2025-06-01"), "2025-06-01T00:00:00.000Z"); +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", () => { @@ -30,7 +37,7 @@ Deno.test("toISODateTime returns undefined for an unparseable value", () => { Deno.test("toBlogPosting emits dates with a timezone", () => { const node = toBlogPosting(post({ dateModified: "2024-07-01T10:30:00" })); - assertEquals(node.datePublished, "2024-06-01T00:00:00.000Z"); + assertEquals(node.datePublished, "2024-06-01T08:00:00.000Z"); assertEquals(node.dateModified, "2024-07-01T10:30:00.000Z"); }); diff --git a/blog/utils/date.ts b/blog/utils/date.ts index 2983d751a..3f8ab4495 100644 --- a/blog/utils/date.ts +++ b/blog/utils/date.ts @@ -26,6 +26,8 @@ export const dateToTime = (date: string) => : date, ).getTime() || 0; +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 @@ -33,12 +35,15 @@ export const dateToTime = (date: string) => * * `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. It is pinned to UTC through `dateToTime`, so the emitted instant - * matches the one posts are sorted by; unparseable values are dropped rather - * than emitted as invalid structured data. + * 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. Everything else goes through `dateToTime` (offset-less + * date-times pinned to UTC); unparseable values are dropped rather than + * emitted as invalid structured data. */ export const toISODateTime = (date: string) => { - const time = dateToTime(date); + const time = dateToTime(BARE_DATE.test(date) ? `${date}T08:00:00` : date); return time ? new Date(time).toISOString() : undefined; }; From 4341d8b906ab037818a8871b98ae877a0b742cce Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:23:19 -0300 Subject: [PATCH 3/4] fix(blog): parse JSON-LD dates with scheduledTime's strict ISO check dateToTime is lenient: Date rolls an impossible 2024-02-31 over into March, reads non-ISO strings in server-local time, and its 0-on-failure sentinel dropped a valid Unix-epoch date. scheduledTime range-checks the calendar fields and signals failure with null, so invalid dates are omitted and the epoch survives. Co-Authored-By: Claude Opus 5.5 (1M context) --- blog/tests/jsonLD.test.ts | 16 ++++++++++++++ blog/utils/date.ts | 46 +++++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/blog/tests/jsonLD.test.ts b/blog/tests/jsonLD.test.ts index f9ee474e8..a4bfaa468 100644 --- a/blog/tests/jsonLD.test.ts +++ b/blog/tests/jsonLD.test.ts @@ -29,11 +29,27 @@ Deno.test("toISODateTime keeps the instant of an offset date-time", () => { ); }); +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 emits dates with a timezone", () => { const node = toBlogPosting(post({ dateModified: "2024-07-01T10:30:00" })); diff --git a/blog/utils/date.ts b/blog/utils/date.ts index 3f8ab4495..8d1fe55e7 100644 --- a/blog/utils/date.ts +++ b/blog/utils/date.ts @@ -26,27 +26,6 @@ export const dateToTime = (date: string) => : date, ).getTime() || 0; -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 can't be read as a 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. Everything else goes through `dateToTime` (offset-less - * date-times pinned to UTC); unparseable values are dropped rather than - * emitted as invalid structured data. - */ -export const toISODateTime = (date: string) => { - const time = dateToTime(BARE_DATE.test(date) ? `${date}T08:00:00` : date); - return time ? new Date(time).toISOString() : undefined; -}; - /** * An ISO 8601 date, optionally with a time and an offset. Anchored, grouped and * deliberately narrow: `Date` accepts far more than this, and the extras are the @@ -115,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(); +}; From b88bf92fb73ebd334687b31497824163481d9abe Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:28:19 -0300 Subject: [PATCH 4/4] test(blog): pin full-timestamp and bare-date normalization side by side Co-Authored-By: Claude Opus 5.5 (1M context) --- blog/tests/jsonLD.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/blog/tests/jsonLD.test.ts b/blog/tests/jsonLD.test.ts index a4bfaa468..c6e83b1dc 100644 --- a/blog/tests/jsonLD.test.ts +++ b/blog/tests/jsonLD.test.ts @@ -50,6 +50,17 @@ 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" }));