@decocms/apps-blog 7.77.2 → 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 +4 -4
- package/src/__tests__/date.test.ts +33 -1
- package/src/__tests__/seoSections.test.ts +26 -2
- package/src/utils/date.ts +25 -0
- package/src/utils/jsonLD.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-blog",
|
|
3
|
-
"version": "7.77.
|
|
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.
|
|
42
|
-
"@decocms/apps-commerce": "7.77.
|
|
43
|
-
"@decocms/apps-website": "7.77.
|
|
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-
|
|
61
|
-
expect(node.dateModified).toBe("2024-07-
|
|
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
|
+
};
|
package/src/utils/jsonLD.ts
CHANGED
|
@@ -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
|
-
...(
|
|
73
|
-
...(
|
|
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 } : {}),
|