@decocms/apps-blog 8.0.0 → 8.1.0-next.0
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/README.md +21 -0
- package/package.json +14 -5
- package/src/README.md +69 -0
- package/src/__tests__/blogRecords.test.ts +270 -0
- package/src/__tests__/categoryTree.test.ts +209 -0
- package/src/__tests__/date.test.ts +134 -0
- package/src/__tests__/handlePosts.test.ts +256 -230
- package/src/__tests__/loaders.test.ts +198 -154
- package/src/__tests__/mod.test.ts +6 -6
- package/src/__tests__/publicationStatus.test.ts +176 -0
- package/src/__tests__/records.test.ts +68 -68
- package/src/__tests__/seoSections.test.ts +245 -0
- package/src/actions/submitRating.ts +36 -0
- package/src/actions/submitReview.ts +95 -0
- package/src/actions/submitView.ts +28 -0
- package/src/client.ts +70 -0
- package/src/core/blogRecords.ts +94 -0
- package/src/core/categoryTree.ts +210 -0
- package/src/core/handlePosts.ts +160 -64
- package/src/core/records.ts +147 -12
- package/src/index.ts +48 -14
- package/src/loaderMap.ts +28 -24
- package/src/loaders/BlogPostItem.ts +15 -5
- package/src/loaders/BlogPostPage.ts +61 -24
- package/src/loaders/BlogRelatedPosts.ts +45 -45
- package/src/loaders/BlogpostList.ts +93 -0
- package/src/loaders/BlogpostListing.ts +151 -85
- package/src/loaders/GetCategories.ts +51 -28
- package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
- package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
- package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
- package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
- package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
- package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
- package/src/loaders/extensions/types.ts +15 -0
- package/src/manifest.gen.ts +82 -14
- package/src/mod.ts +73 -20
- package/src/registry.ts +5 -5
- package/src/sections/Seo/SeoBlogPost.tsx +77 -0
- package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
- package/src/sections/Template.tsx +106 -0
- package/src/sections/blocks/BlockImage.tsx +42 -0
- package/src/sections/blocks/Callout.tsx +64 -0
- package/src/sections/blocks/CardGroup.tsx +60 -0
- package/src/sections/blocks/Checklist.tsx +58 -0
- package/src/sections/blocks/Code.tsx +29 -0
- package/src/sections/blocks/Comparison.tsx +77 -0
- package/src/sections/blocks/Cta.tsx +22 -0
- package/src/sections/blocks/Divider.tsx +3 -0
- package/src/sections/blocks/Heading.tsx +28 -0
- package/src/sections/blocks/List.tsx +39 -0
- package/src/sections/blocks/Paragraph.tsx +15 -0
- package/src/sections/blocks/Quote.tsx +32 -0
- package/src/sections/blocks/Stat.tsx +23 -0
- package/src/sections/blocks/StatGroup.tsx +38 -0
- package/src/sections/blocks/Steps.tsx +64 -0
- package/src/sections/blocks/Table.tsx +80 -0
- package/src/sections/blocks/Video.tsx +39 -0
- package/src/static/css.ts +178 -0
- package/src/types.ts +333 -77
- package/src/utils/blocksToSections.ts +175 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/date.ts +95 -0
- package/src/utils/hardSanitize.ts +106 -0
- package/src/utils/jsonLD.ts +118 -0
- package/src/utils/sanitizeHtml.ts +37 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("../core/records", () => ({
|
|
4
|
-
|
|
4
|
+
getRecordsByPath: vi.fn(),
|
|
5
5
|
}));
|
|
6
6
|
|
|
7
7
|
import { getRecordsByPath } from "../core/records";
|
|
@@ -15,193 +15,237 @@ import type { BlogPost, Category } from "../types";
|
|
|
15
15
|
const mockGetRecords = getRecordsByPath as ReturnType<typeof vi.fn>;
|
|
16
16
|
|
|
17
17
|
function makePost(overrides: Partial<BlogPost> = {}): BlogPost {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
return {
|
|
19
|
+
title: "Test Post",
|
|
20
|
+
slug: "test-post",
|
|
21
|
+
date: "2024-06-01",
|
|
22
|
+
excerpt: "A test post",
|
|
23
|
+
content: "Full content here",
|
|
24
|
+
categories: [{ name: "News", slug: "news" }],
|
|
25
|
+
authors: [{ name: "Author", email: "a@b.com" }],
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const samplePosts: BlogPost[] = [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
makePost({ slug: "post-a", title: "Alpha", date: "2024-01-01" }),
|
|
32
|
+
makePost({ slug: "post-b", title: "Bravo", date: "2024-02-01" }),
|
|
33
|
+
makePost({ slug: "post-c", title: "Charlie", date: "2024-03-01" }),
|
|
34
34
|
];
|
|
35
35
|
|
|
36
36
|
beforeEach(() => {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mockGetRecords.mockReturnValue(samplePosts);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
// BlogpostListing
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
describe("BlogpostListing", () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
45
|
+
it("returns paginated listing with defaults (page=1, count=12, sortBy=date_desc)", async () => {
|
|
46
|
+
const result = await BlogpostListing({});
|
|
47
|
+
expect(result).not.toBeNull();
|
|
48
|
+
expect(result!.posts).toHaveLength(3);
|
|
49
|
+
expect(result!.pageInfo.currentPage).toBe(1);
|
|
50
|
+
expect(result!.pageInfo.recordPerPage).toBe(12);
|
|
51
|
+
// date_desc: most recent first
|
|
52
|
+
expect(result!.posts[0].slug).toBe("post-c");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("URL search params override props", async () => {
|
|
56
|
+
const req = new Request("http://localhost/blog?page=2&count=1");
|
|
57
|
+
const result = await BlogpostListing({}, req);
|
|
58
|
+
expect(result).not.toBeNull();
|
|
59
|
+
expect(result!.pageInfo.currentPage).toBe(2);
|
|
60
|
+
expect(result!.posts).toHaveLength(1);
|
|
61
|
+
expect(result!.posts[0].slug).toBe("post-b"); // second page of date_desc
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("computes nextPage / previousPage correctly", async () => {
|
|
65
|
+
const result = await BlogpostListing({ count: 1, page: 2 });
|
|
66
|
+
expect(result).not.toBeNull();
|
|
67
|
+
expect(result!.pageInfo.nextPage).toContain("page=3");
|
|
68
|
+
expect(result!.pageInfo.previousPage).toContain("page=1");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns null when no posts match", async () => {
|
|
72
|
+
mockGetRecords.mockReturnValue([]);
|
|
73
|
+
expect(await BlogpostListing({})).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("SEO canonical strips query params", async () => {
|
|
77
|
+
const req = new Request("http://localhost/blog?page=1&count=1");
|
|
78
|
+
const result = await BlogpostListing({}, req);
|
|
79
|
+
expect(result).not.toBeNull();
|
|
80
|
+
expect(result!.seo.canonical).toBe("http://localhost/blog");
|
|
81
|
+
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
// ---------------------------------------------------------------------------
|
|
85
85
|
// BlogRelatedPosts
|
|
86
86
|
// ---------------------------------------------------------------------------
|
|
87
87
|
describe("BlogRelatedPostsLoader", () => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
88
|
+
it("excludes current post via excludePostSlug", async () => {
|
|
89
|
+
const result = await BlogRelatedPostsLoader({ excludePostSlug: "post-a" });
|
|
90
|
+
expect(result).not.toBeNull();
|
|
91
|
+
expect(result!.find((p) => p.slug === "post-a")).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns BlogPost[] (not BlogPostListingPage)", async () => {
|
|
95
|
+
const result = await BlogRelatedPostsLoader({});
|
|
96
|
+
expect(Array.isArray(result)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null when empty", async () => {
|
|
100
|
+
mockGetRecords.mockReturnValue([]);
|
|
101
|
+
expect(await BlogRelatedPostsLoader({})).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// A post with no categories arrives here as `slug: []`. Answering the whole
|
|
105
|
+
// blog would turn "related posts" into a second listing of everything.
|
|
106
|
+
it("returns null for an empty category list, not every post", async () => {
|
|
107
|
+
expect(await BlogRelatedPostsLoader({ slug: [] })).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("still relates on a non-empty category list", async () => {
|
|
111
|
+
mockGetRecords.mockReturnValue([
|
|
112
|
+
makePost({ slug: "in", categories: [{ name: "News", slug: "news" }] }),
|
|
113
|
+
makePost({ slug: "out", categories: [{ name: "Tech", slug: "tech" }] }),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const result = await BlogRelatedPostsLoader({ slug: ["news"] });
|
|
117
|
+
expect(result?.map((p) => p.slug)).toEqual(["in"]);
|
|
118
|
+
});
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
// ---------------------------------------------------------------------------
|
|
106
122
|
// BlogPostPage
|
|
107
123
|
// ---------------------------------------------------------------------------
|
|
108
124
|
describe("BlogPostPageLoader", () => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
125
|
+
it("returns BlogPostPage with correct @type", () => {
|
|
126
|
+
const result = BlogPostPageLoader({ slug: "post-a" });
|
|
127
|
+
expect(result).not.toBeNull();
|
|
128
|
+
expect(result!["@type"]).toBe("BlogPostPage");
|
|
129
|
+
expect(result!.post.slug).toBe("post-a");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns null when post not found", () => {
|
|
133
|
+
expect(BlogPostPageLoader({ slug: "nonexistent" })).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("SEO fields fall back to post fields", () => {
|
|
137
|
+
mockGetRecords.mockReturnValue([
|
|
138
|
+
makePost({
|
|
139
|
+
slug: "no-seo",
|
|
140
|
+
title: "Fallback Title",
|
|
141
|
+
excerpt: "Fallback Desc",
|
|
142
|
+
image: "img.png",
|
|
143
|
+
seo: undefined,
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const result = BlogPostPageLoader({ slug: "no-seo" });
|
|
148
|
+
expect(result).not.toBeNull();
|
|
149
|
+
expect(result!.seo?.title).toBe("Fallback Title");
|
|
150
|
+
expect(result!.seo?.description).toBe("Fallback Desc");
|
|
151
|
+
expect(result!.seo?.image).toBe("img.png");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("uses SEO fields when present", () => {
|
|
155
|
+
mockGetRecords.mockReturnValue([
|
|
156
|
+
makePost({
|
|
157
|
+
slug: "has-seo",
|
|
158
|
+
title: "Post Title",
|
|
159
|
+
seo: { title: "SEO Title", description: "SEO Desc" },
|
|
160
|
+
}),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const result = BlogPostPageLoader({ slug: "has-seo" });
|
|
164
|
+
expect(result!.seo?.title).toBe("SEO Title");
|
|
165
|
+
expect(result!.seo?.description).toBe("SEO Desc");
|
|
166
|
+
});
|
|
151
167
|
});
|
|
152
168
|
|
|
153
169
|
// ---------------------------------------------------------------------------
|
|
154
170
|
// GetCategories
|
|
155
171
|
// ---------------------------------------------------------------------------
|
|
156
172
|
describe("GetCategories", () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
173
|
+
const categories: Category[] = [
|
|
174
|
+
{ name: "Beta", slug: "beta" },
|
|
175
|
+
{ name: "Alpha", slug: "alpha" },
|
|
176
|
+
{ name: "Charlie", slug: "charlie" },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
mockGetRecords.mockReturnValue([...categories]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("sorts by name (title_desc default)", () => {
|
|
184
|
+
const result = GetCategories({});
|
|
185
|
+
expect(result).not.toBeNull();
|
|
186
|
+
expect(result!.map((c) => c.name)).toEqual(["Alpha", "Beta", "Charlie"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("filters by slug when provided", () => {
|
|
190
|
+
const result = GetCategories({ slug: "alpha" });
|
|
191
|
+
expect(result).toHaveLength(1);
|
|
192
|
+
expect(result![0].slug).toBe("alpha");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("slices by count", () => {
|
|
196
|
+
const result = GetCategories({ count: 2 });
|
|
197
|
+
expect(result).toHaveLength(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns null when no categories", () => {
|
|
201
|
+
mockGetRecords.mockReturnValue([]);
|
|
202
|
+
expect(GetCategories({})).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("parentSlug", () => {
|
|
206
|
+
const nested: Category[] = [
|
|
207
|
+
{ name: "Root", slug: "root" },
|
|
208
|
+
{ name: "Zeta", slug: "zeta", parentSlug: "root" },
|
|
209
|
+
{ name: "Alpha", slug: "alpha", parentSlug: "root" },
|
|
210
|
+
{ name: "Deep", slug: "deep", parentSlug: "alpha" },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
mockGetRecords.mockReturnValue([...nested]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns only direct children, not the whole subtree", () => {
|
|
218
|
+
const result = GetCategories({ parentSlug: "root" });
|
|
219
|
+
expect(result!.map((c) => c.slug)).toEqual(["Alpha", "Zeta"].map((n) => n.toLowerCase()));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("slices the scoped children by count", () => {
|
|
223
|
+
expect(GetCategories({ parentSlug: "root", count: 1 })!.map((c) => c.slug)).toEqual([
|
|
224
|
+
"alpha",
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns an empty list for a category with no children", () => {
|
|
229
|
+
expect(GetCategories({ parentSlug: "deep" })).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
188
232
|
});
|
|
189
233
|
|
|
190
234
|
// ---------------------------------------------------------------------------
|
|
191
235
|
// BlogPostItem
|
|
192
236
|
// ---------------------------------------------------------------------------
|
|
193
237
|
describe("BlogPostItem", () => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
238
|
+
it("returns post by slug", () => {
|
|
239
|
+
const result = BlogPostItem({ slug: "post-b" });
|
|
240
|
+
expect(result).not.toBeNull();
|
|
241
|
+
expect(result!.slug).toBe("post-b");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("returns null when no slug provided", () => {
|
|
245
|
+
expect(BlogPostItem({ slug: "" })).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns null when post not found", () => {
|
|
249
|
+
expect(BlogPostItem({ slug: "nonexistent" })).toBeNull();
|
|
250
|
+
});
|
|
207
251
|
});
|
|
@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { configure } from "../mod";
|
|
3
3
|
|
|
4
4
|
describe("blog module", () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
it("returns AppDefinition with name 'blog' and manifest", async () => {
|
|
6
|
+
const app = await configure({}, async () => null);
|
|
7
|
+
expect(app.name).toBe("blog");
|
|
8
|
+
expect(app.manifest).toBeDefined();
|
|
9
|
+
expect(app.state).toEqual({});
|
|
10
|
+
});
|
|
11
11
|
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reason this package was re-synced from `deco-cx/apps`.
|
|
3
|
+
*
|
|
4
|
+
* Scope: the read-time publication gate — `isPublishedStatus` / `isLivePost`,
|
|
5
|
+
* `filterRoutablePosts`, and the fact that the gate runs *before* pagination so
|
|
6
|
+
* `count` yields `count` renderable posts. Also covers the counterpart on the
|
|
7
|
+
* detail loaders: a non-live post still renders (it is the CMS preview) but is
|
|
8
|
+
* marked `noIndexing`.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not covered here: the strict-parsing edge cases of
|
|
11
|
+
* `scheduledTime` (see `date.test.ts`) and anything requiring a records
|
|
12
|
+
* adapter (ratings/reviews/views).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
vi.mock("../core/records", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("../core/records")>();
|
|
19
|
+
return { ...actual, getRecordsByPath: vi.fn() };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import handlePosts, { filterRoutablePosts } from "../core/handlePosts";
|
|
23
|
+
import { getRecordsByPath } from "../core/records";
|
|
24
|
+
import BlogPostItem from "../loaders/BlogPostItem";
|
|
25
|
+
import BlogPostPageLoader from "../loaders/BlogPostPage";
|
|
26
|
+
import BlogpostListing from "../loaders/BlogpostListing";
|
|
27
|
+
import type { BlogPost, PostStatus } from "../types";
|
|
28
|
+
import { isLivePost, isPublishedStatus } from "../types";
|
|
29
|
+
|
|
30
|
+
const mockGetRecords = getRecordsByPath as ReturnType<typeof vi.fn>;
|
|
31
|
+
|
|
32
|
+
const post = (overrides: Partial<BlogPost> = {}): BlogPost => ({
|
|
33
|
+
title: "Post",
|
|
34
|
+
slug: "post",
|
|
35
|
+
date: "2024-06-01",
|
|
36
|
+
excerpt: "excerpt",
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const HOUR = 60 * 60 * 1000;
|
|
41
|
+
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
|
|
42
|
+
|
|
43
|
+
describe("isPublishedStatus", () => {
|
|
44
|
+
it("treats an absent status as published — legacy records predate the field", () => {
|
|
45
|
+
expect(isPublishedStatus(undefined)).toBe(true);
|
|
46
|
+
expect(isPublishedStatus("")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts only an explicit `published`", () => {
|
|
50
|
+
expect(isPublishedStatus("published")).toBe(true);
|
|
51
|
+
for (const status of ["draft", "scheduled", "archived", "generating", "awaiting_review"]) {
|
|
52
|
+
expect(isPublishedStatus(status)).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fails closed on a status this version does not know", () => {
|
|
57
|
+
expect(isPublishedStatus("some_future_state")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isLivePost", () => {
|
|
62
|
+
it("hides every non-published, non-scheduled status", () => {
|
|
63
|
+
const hidden: PostStatus[] = ["draft", "archived", "generating", "awaiting_review"];
|
|
64
|
+
for (const status of hidden) {
|
|
65
|
+
expect(isLivePost({ status })).toBe(false);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("hides a scheduled post whose instant has not arrived", () => {
|
|
70
|
+
expect(isLivePost({ status: "scheduled", scheduledDatetime: iso(HOUR) })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("shows a scheduled post whose instant has passed", () => {
|
|
74
|
+
expect(isLivePost({ status: "scheduled", scheduledDatetime: iso(-HOUR) })).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("hides a scheduled post with a missing or unreadable instant", () => {
|
|
78
|
+
expect(isLivePost({ status: "scheduled" })).toBe(false);
|
|
79
|
+
// Lenient `Date` would read this as the year 2000, i.e. already live.
|
|
80
|
+
expect(isLivePost({ status: "scheduled", scheduledDatetime: "0" })).toBe(false);
|
|
81
|
+
// Calendar overflow must be rejected, not rolled forward to March 3rd.
|
|
82
|
+
expect(isLivePost({ status: "scheduled", scheduledDatetime: "2020-02-31" })).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("honours the epoch as a real instant", () => {
|
|
86
|
+
expect(isLivePost({ status: "scheduled", scheduledDatetime: "1970-01-01T00:00:00Z" })).toBe(
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("filterRoutablePosts", () => {
|
|
93
|
+
it("drops posts with no usable slug — they have no route to link to", () => {
|
|
94
|
+
const posts = [
|
|
95
|
+
post({ slug: "ok" }),
|
|
96
|
+
post({ slug: "" }),
|
|
97
|
+
post({ slug: " " }),
|
|
98
|
+
post({ slug: undefined as unknown as string }),
|
|
99
|
+
post({ slug: 42 as unknown as string }),
|
|
100
|
+
];
|
|
101
|
+
expect(filterRoutablePosts(posts).map((p) => p.slug)).toEqual(["ok"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("listing pagination applies the gate before slicing", () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
vi.clearAllMocks();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const mixed = [
|
|
111
|
+
post({ slug: "live-1", date: "2024-05-01" }),
|
|
112
|
+
post({ slug: "draft-1", date: "2024-04-01", status: "draft" }),
|
|
113
|
+
post({ slug: "generating-1", date: "2024-03-01", status: "generating" }),
|
|
114
|
+
post({
|
|
115
|
+
slug: "future-1",
|
|
116
|
+
date: "2024-02-01",
|
|
117
|
+
status: "scheduled",
|
|
118
|
+
scheduledDatetime: iso(HOUR),
|
|
119
|
+
}),
|
|
120
|
+
post({
|
|
121
|
+
slug: "past-1",
|
|
122
|
+
date: "2024-01-01",
|
|
123
|
+
status: "scheduled",
|
|
124
|
+
scheduledDatetime: iso(-HOUR),
|
|
125
|
+
}),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
it("handlePosts keeps only live posts", async () => {
|
|
129
|
+
const result = await handlePosts(mixed, "date_desc");
|
|
130
|
+
expect(result?.map((p) => p.slug)).toEqual(["live-1", "past-1"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("count: 2 yields 2 renderable posts, not 2 records minus the hidden ones", async () => {
|
|
134
|
+
mockGetRecords.mockReturnValue(mixed);
|
|
135
|
+
const result = await BlogpostListing({ count: 2 });
|
|
136
|
+
expect(result).not.toBeNull();
|
|
137
|
+
// Without the gate running first, the page-1 slice would have been
|
|
138
|
+
// [live-1, draft-1] and rendered a single visible card.
|
|
139
|
+
expect(result?.posts.map((p) => p.slug)).toEqual(["live-1", "past-1"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns null when every record is gated out", async () => {
|
|
143
|
+
mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft" })]);
|
|
144
|
+
expect(await BlogpostListing({})).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("detail loaders serve non-live posts as previews, unindexed", () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
vi.clearAllMocks();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("BlogPostPage marks a draft noIndexing but still renders it", () => {
|
|
154
|
+
mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft" })]);
|
|
155
|
+
const result = BlogPostPageLoader({ slug: "d" });
|
|
156
|
+
expect(result?.post.slug).toBe("d");
|
|
157
|
+
expect(result?.seo?.noIndexing).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("BlogPostPage leaves a live post indexable", () => {
|
|
161
|
+
mockGetRecords.mockReturnValue([post({ slug: "p", status: "published" })]);
|
|
162
|
+
expect(BlogPostPageLoader({ slug: "p" })?.seo?.noIndexing).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("BlogPostItem stamps noIndexing without discarding the post's own seo", () => {
|
|
166
|
+
mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft", seo: { title: "Kept" } })]);
|
|
167
|
+
const result = BlogPostItem({ slug: "d" });
|
|
168
|
+
expect(result?.seo).toEqual({ title: "Kept", noIndexing: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("BlogPostItem returns a live post untouched", () => {
|
|
172
|
+
const live = post({ slug: "p" });
|
|
173
|
+
mockGetRecords.mockReturnValue([live]);
|
|
174
|
+
expect(BlogPostItem({ slug: "p" })).toBe(live);
|
|
175
|
+
});
|
|
176
|
+
});
|