@decocms/apps-blog 7.77.1 → 7.77.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-blog",
3
- "version": "7.77.1",
3
+ "version": "7.77.3",
4
4
  "type": "module",
5
5
  "description": "Deco app: blog content and CMS integration",
6
6
  "repository": {
@@ -38,9 +38,9 @@
38
38
  "lint:unused": "knip"
39
39
  },
40
40
  "dependencies": {
41
- "@decocms/blocks": "7.77.1",
42
- "@decocms/apps-commerce": "7.77.1",
43
- "@decocms/apps-website": "7.77.1"
41
+ "@decocms/blocks": "7.77.3",
42
+ "@decocms/apps-commerce": "7.77.3",
43
+ "@decocms/apps-website": "7.77.3"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "react": "^19.0.0",
@@ -13,7 +13,7 @@
13
13
  import { describe, expect, it } from "vitest";
14
14
  import { sortPosts } from "../core/handlePosts";
15
15
  import type { BlogPost } from "../types";
16
- import { dateToTime, scheduledTime } from "../utils/date";
16
+ import { dateToTime, scheduledTime, toISODateTime } from "../utils/date";
17
17
 
18
18
  const post = (slug: string, date: string): BlogPost => ({
19
19
  title: slug,
@@ -132,3 +132,35 @@ describe("scheduledTime", () => {
132
132
  expect(scheduledTime("1970-01-01T00:00:00Z")).toBe(0);
133
133
  });
134
134
  });
135
+
136
+ describe("toISODateTime", () => {
137
+ it("places a bare date at 08:00 UTC with an explicit designator", () => {
138
+ expect(toISODateTime("2025-06-01")).toBe("2025-06-01T08:00:00.000Z");
139
+ });
140
+
141
+ it("pins an offset-less date-time to UTC", () => {
142
+ expect(toISODateTime("2025-06-01T00:00:00")).toBe("2025-06-01T00:00:00.000Z");
143
+ });
144
+
145
+ it("keeps the instant of a date-time that carries its own offset", () => {
146
+ expect(toISODateTime("2025-06-01T09:00:00+02:00")).toBe("2025-06-01T07:00:00.000Z");
147
+ });
148
+
149
+ it("keeps the Unix epoch instead of mistaking it for a failure", () => {
150
+ expect(toISODateTime("1970-01-01T00:00:00Z")).toBe("1970-01-01T00:00:00.000Z");
151
+ });
152
+
153
+ it("returns undefined for an unparseable value", () => {
154
+ expect(toISODateTime("")).toBeUndefined();
155
+ expect(toISODateTime("not a date")).toBeUndefined();
156
+ });
157
+
158
+ it("rejects an impossible calendar date instead of rolling it over", () => {
159
+ expect(toISODateTime("2024-02-31")).toBeUndefined();
160
+ expect(toISODateTime("2024-02-31T10:00:00Z")).toBeUndefined();
161
+ });
162
+
163
+ it("rejects non-ISO strings that `Date` would read in server-local time", () => {
164
+ expect(toISODateTime("June 1, 2025")).toBeUndefined();
165
+ });
166
+ });
@@ -57,11 +57,35 @@ describe("toBlogPosting", () => {
57
57
  const node = toBlogPosting(post({ dateModified: "2024-07-01" }), "https://a.com/blog/x");
58
58
  expect(node["@type"]).toBe("BlogPosting");
59
59
  expect(node.headline).toBe("How to brew");
60
- expect(node.datePublished).toBe("2024-06-01");
61
- expect(node.dateModified).toBe("2024-07-01");
60
+ expect(node.datePublished).toBe("2024-06-01T08:00:00.000Z");
61
+ expect(node.dateModified).toBe("2024-07-01T08:00:00.000Z");
62
62
  expect(node.mainEntityOfPage).toEqual({ "@type": "WebPage", "@id": "https://a.com/blog/x" });
63
63
  });
64
64
 
65
+ it("emits dates as ISO 8601 date-times with a timezone, as the Rich Results Test requires", () => {
66
+ expect(toBlogPosting(post({ date: "2024-06-01T10:30:00" })).datePublished).toBe(
67
+ "2024-06-01T10:30:00.000Z",
68
+ );
69
+ expect(toBlogPosting(post({ date: "2024-06-01T10:30:00-03:00" })).datePublished).toBe(
70
+ "2024-06-01T13:30:00.000Z",
71
+ );
72
+ });
73
+
74
+ it("normalizes full timestamps and bare dates alike", () => {
75
+ expect(toBlogPosting(post({ date: "2025-10-31T15:10:01Z" })).datePublished).toBe(
76
+ "2025-10-31T15:10:01.000Z",
77
+ );
78
+ expect(toBlogPosting(post({ date: "2026-08-14" })).datePublished).toBe(
79
+ "2026-08-14T08:00:00.000Z",
80
+ );
81
+ });
82
+
83
+ it("drops dates that can't be parsed instead of emitting invalid structured data", () => {
84
+ const node = toBlogPosting(post({ date: "not a date", dateModified: "soon" }));
85
+ expect("datePublished" in node).toBe(false);
86
+ expect("dateModified" in node).toBe(false);
87
+ });
88
+
65
89
  it("omits `@context` — the Seo component adds it when serializing", () => {
66
90
  expect("@context" in toBlogPosting(post())).toBe(false);
67
91
  });
package/src/utils/date.ts CHANGED
@@ -93,3 +93,28 @@ export const scheduledTime = (value: string): number | null => {
93
93
  // `+99:00` matches the pattern but is not a real instant.
94
94
  return Number.isNaN(parsed) ? null : parsed;
95
95
  };
96
+
97
+ const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
98
+
99
+ /**
100
+ * Normalizes a CMS-authored date into a full ISO 8601 date-time with a
101
+ * timezone, for JSON-LD's `datePublished`/`dateModified`, or returns
102
+ * `undefined` if the value isn't a real ISO date.
103
+ *
104
+ * `BlogPost.date` is usually a bare `YYYY-MM-DD` (the admin's date picker),
105
+ * which Google's Rich Results Test rejects as an invalid datetime missing its
106
+ * timezone. A bare date carries no time of day, so it is placed at 08:00 UTC
107
+ * rather than midnight: that instant falls on the same calendar day from
108
+ * UTC-08:00 to UTC+14:00, so search results don't show a post a day early in
109
+ * the Americas.
110
+ *
111
+ * Parsing goes through `scheduledTime` rather than `dateToTime` for the same
112
+ * reasons it exists: an impossible date such as `2024-02-31` is dropped instead
113
+ * of rolling over into March, non-ISO strings aren't read in server-local time,
114
+ * and the Unix epoch isn't mistaken for a failure. Anything rejected is omitted
115
+ * rather than emitted as invalid structured data.
116
+ */
117
+ export const toISODateTime = (date: string) => {
118
+ const time = scheduledTime(BARE_DATE.test(date) ? `${date}T08:00:00` : date);
119
+ return time === null ? undefined : new Date(time).toISOString();
120
+ };
@@ -1,4 +1,5 @@
1
1
  import type { Author, BlogPost, Category, Publisher } from "../types";
2
+ import { toISODateTime } from "./date";
2
3
 
3
4
  const toAuthor = (author: Author) => {
4
5
  const type = author.type ?? "Person";
@@ -64,13 +65,16 @@ export const toBlogPosting = (post: BlogPost, url?: string, publisher?: Publishe
64
65
  const interactionStatistic =
65
66
  post.interactionStatistic?.userInteractionCount != null ? post.interactionStatistic : undefined;
66
67
 
68
+ const datePublished = post.date ? toISODateTime(post.date) : undefined;
69
+ const dateModified = post.dateModified ? toISODateTime(post.dateModified) : undefined;
70
+
67
71
  return {
68
72
  "@type": "BlogPosting" as const,
69
73
  headline: post.title,
70
74
  ...(post.excerpt ? { description: post.excerpt } : {}),
71
75
  ...(image ? { image: [image] } : {}),
72
- ...(post.date ? { datePublished: post.date } : {}),
73
- ...(post.dateModified ? { dateModified: post.dateModified } : {}),
76
+ ...(datePublished ? { datePublished } : {}),
77
+ ...(dateModified ? { dateModified } : {}),
74
78
  ...(post.authors?.length ? { author: post.authors.map(toAuthor) } : {}),
75
79
  ...(publisher?.name ? { publisher: toOrganization(publisher) } : {}),
76
80
  ...(categories?.length ? { articleSection: categories } : {}),