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
78 changes: 78 additions & 0 deletions blog/tests/jsonLD.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
25 changes: 25 additions & 0 deletions blog/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
aka-sacci-ccr marked this conversation as resolved.
*
* 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();
};
10 changes: 8 additions & 2 deletions blog/utils/jsonLD.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
: 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
Loading