Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/apps-blog/src/__tests__/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
28 changes: 26 additions & 2 deletions packages/apps-blog/src/__tests__/seoSections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/apps-blog/src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
8 changes: 6 additions & 2 deletions packages/apps-blog/src/utils/jsonLD.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 } : {}),
Expand Down