From ebf6fdb597a3e606e4ce8ed927c4f1a324f285f9 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:14:27 -0300 Subject: [PATCH 1/4] fix(apps-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) --- packages/apps-blog/src/__tests__/date.test.ts | 17 ++++++++++++++++- .../src/__tests__/seoSections.test.ts | 19 +++++++++++++++++-- packages/apps-blog/src/utils/date.ts | 16 ++++++++++++++++ packages/apps-blog/src/utils/jsonLD.ts | 8 ++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/apps-blog/src/__tests__/date.test.ts b/packages/apps-blog/src/__tests__/date.test.ts index 5870efa1..bdd254c1 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,18 @@ describe("scheduledTime", () => { expect(scheduledTime("1970-01-01T00:00:00Z")).toBe(0); }); }); + +describe("toISODateTime", () => { + it("expands a bare date to midnight UTC with an explicit designator", () => { + expect(toISODateTime("2025-06-01")).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("returns undefined for an unparseable value", () => { + expect(toISODateTime("")).toBeUndefined(); + expect(toISODateTime("not a date")).toBeUndefined(); + }); +}); diff --git a/packages/apps-blog/src/__tests__/seoSections.test.ts b/packages/apps-blog/src/__tests__/seoSections.test.ts index e895610c..540f959a 100644 --- a/packages/apps-blog/src/__tests__/seoSections.test.ts +++ b/packages/apps-blog/src/__tests__/seoSections.test.ts @@ -57,11 +57,26 @@ 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-01T00:00:00.000Z"); + expect(node.dateModified).toBe("2024-07-01T00: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("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..c78db90f 100644 --- a/packages/apps-blog/src/utils/date.ts +++ b/packages/apps-blog/src/utils/date.ts @@ -23,6 +23,22 @@ export const dateToTime = (date: string) => ISO_WITHOUT_TIMEZONE.test(date) ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` : 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/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 } : {}), From ac4df2e11f6debedf442cb5644d004ad857f4707 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:20:51 -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) --- packages/apps-blog/src/__tests__/date.test.ts | 8 ++++++-- .../apps-blog/src/__tests__/seoSections.test.ts | 4 ++-- packages/apps-blog/src/utils/date.ts | 13 +++++++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/apps-blog/src/__tests__/date.test.ts b/packages/apps-blog/src/__tests__/date.test.ts index bdd254c1..fcff26c7 100644 --- a/packages/apps-blog/src/__tests__/date.test.ts +++ b/packages/apps-blog/src/__tests__/date.test.ts @@ -134,8 +134,12 @@ describe("scheduledTime", () => { }); describe("toISODateTime", () => { - it("expands a bare date to midnight UTC with an explicit designator", () => { - expect(toISODateTime("2025-06-01")).toBe("2025-06-01T00:00:00.000Z"); + 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", () => { diff --git a/packages/apps-blog/src/__tests__/seoSections.test.ts b/packages/apps-blog/src/__tests__/seoSections.test.ts index 540f959a..f8f98172 100644 --- a/packages/apps-blog/src/__tests__/seoSections.test.ts +++ b/packages/apps-blog/src/__tests__/seoSections.test.ts @@ -57,8 +57,8 @@ 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-01T00:00:00.000Z"); - expect(node.dateModified).toBe("2024-07-01T00:00:00.000Z"); + 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" }); }); diff --git a/packages/apps-blog/src/utils/date.ts b/packages/apps-blog/src/utils/date.ts index c78db90f..6d63bf09 100644 --- a/packages/apps-blog/src/utils/date.ts +++ b/packages/apps-blog/src/utils/date.ts @@ -23,6 +23,8 @@ export const dateToTime = (date: string) => ISO_WITHOUT_TIMEZONE.test(date) ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` : 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 @@ -30,12 +32,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 0aa6d504bc97bd3ba38c7f34bda6a567bd0f77a5 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:23:16 -0300 Subject: [PATCH 3/4] fix(apps-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) --- packages/apps-blog/src/__tests__/date.test.ts | 13 ++++++ packages/apps-blog/src/utils/date.ts | 46 ++++++++++--------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/apps-blog/src/__tests__/date.test.ts b/packages/apps-blog/src/__tests__/date.test.ts index fcff26c7..c5ae9448 100644 --- a/packages/apps-blog/src/__tests__/date.test.ts +++ b/packages/apps-blog/src/__tests__/date.test.ts @@ -146,8 +146,21 @@ describe("toISODateTime", () => { 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/utils/date.ts b/packages/apps-blog/src/utils/date.ts index 6d63bf09..cff02135 100644 --- a/packages/apps-blog/src/utils/date.ts +++ b/packages/apps-blog/src/utils/date.ts @@ -23,27 +23,6 @@ export const dateToTime = (date: string) => ISO_WITHOUT_TIMEZONE.test(date) ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` : 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 @@ -114,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(); +}; From 320f118fcde108ea009ed6a0d2daf5510e48d0c8 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 8 Oct 2026 16:28:16 -0300 Subject: [PATCH 4/4] test(apps-blog): test(blog): pin full-timestamp and bare-date normalization side by side Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/apps-blog/src/__tests__/seoSections.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/apps-blog/src/__tests__/seoSections.test.ts b/packages/apps-blog/src/__tests__/seoSections.test.ts index f8f98172..1b53a105 100644 --- a/packages/apps-blog/src/__tests__/seoSections.test.ts +++ b/packages/apps-blog/src/__tests__/seoSections.test.ts @@ -71,6 +71,15 @@ describe("toBlogPosting", () => { ); }); + 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);