@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.
Files changed (66) hide show
  1. package/README.md +21 -0
  2. package/package.json +14 -5
  3. package/src/README.md +69 -0
  4. package/src/__tests__/blogRecords.test.ts +270 -0
  5. package/src/__tests__/categoryTree.test.ts +209 -0
  6. package/src/__tests__/date.test.ts +134 -0
  7. package/src/__tests__/handlePosts.test.ts +256 -230
  8. package/src/__tests__/loaders.test.ts +198 -154
  9. package/src/__tests__/mod.test.ts +6 -6
  10. package/src/__tests__/publicationStatus.test.ts +176 -0
  11. package/src/__tests__/records.test.ts +68 -68
  12. package/src/__tests__/seoSections.test.ts +245 -0
  13. package/src/actions/submitRating.ts +36 -0
  14. package/src/actions/submitReview.ts +95 -0
  15. package/src/actions/submitView.ts +28 -0
  16. package/src/client.ts +70 -0
  17. package/src/core/blogRecords.ts +94 -0
  18. package/src/core/categoryTree.ts +210 -0
  19. package/src/core/handlePosts.ts +160 -64
  20. package/src/core/records.ts +147 -12
  21. package/src/index.ts +48 -14
  22. package/src/loaderMap.ts +28 -24
  23. package/src/loaders/BlogPostItem.ts +15 -5
  24. package/src/loaders/BlogPostPage.ts +61 -24
  25. package/src/loaders/BlogRelatedPosts.ts +45 -45
  26. package/src/loaders/BlogpostList.ts +93 -0
  27. package/src/loaders/BlogpostListing.ts +151 -85
  28. package/src/loaders/GetCategories.ts +51 -28
  29. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  30. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  31. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  32. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  33. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  34. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  35. package/src/loaders/extensions/types.ts +15 -0
  36. package/src/manifest.gen.ts +82 -14
  37. package/src/mod.ts +73 -20
  38. package/src/registry.ts +5 -5
  39. package/src/sections/Seo/SeoBlogPost.tsx +77 -0
  40. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  41. package/src/sections/Template.tsx +106 -0
  42. package/src/sections/blocks/BlockImage.tsx +42 -0
  43. package/src/sections/blocks/Callout.tsx +64 -0
  44. package/src/sections/blocks/CardGroup.tsx +60 -0
  45. package/src/sections/blocks/Checklist.tsx +58 -0
  46. package/src/sections/blocks/Code.tsx +29 -0
  47. package/src/sections/blocks/Comparison.tsx +77 -0
  48. package/src/sections/blocks/Cta.tsx +22 -0
  49. package/src/sections/blocks/Divider.tsx +3 -0
  50. package/src/sections/blocks/Heading.tsx +28 -0
  51. package/src/sections/blocks/List.tsx +39 -0
  52. package/src/sections/blocks/Paragraph.tsx +15 -0
  53. package/src/sections/blocks/Quote.tsx +32 -0
  54. package/src/sections/blocks/Stat.tsx +23 -0
  55. package/src/sections/blocks/StatGroup.tsx +38 -0
  56. package/src/sections/blocks/Steps.tsx +64 -0
  57. package/src/sections/blocks/Table.tsx +80 -0
  58. package/src/sections/blocks/Video.tsx +39 -0
  59. package/src/static/css.ts +178 -0
  60. package/src/types.ts +333 -77
  61. package/src/utils/blocksToSections.ts +175 -0
  62. package/src/utils/constants.ts +1 -0
  63. package/src/utils/date.ts +95 -0
  64. package/src/utils/hardSanitize.ts +106 -0
  65. package/src/utils/jsonLD.ts +118 -0
  66. 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
- getRecordsByPath: vi.fn(),
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
- 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
- };
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
- 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" }),
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
- vi.clearAllMocks();
38
- mockGetRecords.mockReturnValue(samplePosts);
37
+ vi.clearAllMocks();
38
+ mockGetRecords.mockReturnValue(samplePosts);
39
39
  });
40
40
 
41
41
  // ---------------------------------------------------------------------------
42
42
  // BlogpostListing
43
43
  // ---------------------------------------------------------------------------
44
44
  describe("BlogpostListing", () => {
45
- it("returns paginated listing with defaults (page=1, count=12, sortBy=date_desc)", () => {
46
- const result = 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", () => {
56
- const req = new Request("http://localhost/blog?page=2&count=1");
57
- const result = 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", () => {
65
- const result = 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", () => {
72
- mockGetRecords.mockReturnValue([]);
73
- expect(BlogpostListing({})).toBeNull();
74
- });
75
-
76
- it("SEO canonical strips query params", () => {
77
- const req = new Request("http://localhost/blog?page=1&count=1");
78
- const result = BlogpostListing({}, req);
79
- expect(result).not.toBeNull();
80
- expect(result!.seo.canonical).toBe("http://localhost/blog");
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
- it("excludes current post via excludePostSlug", () => {
89
- const result = 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)", () => {
95
- const result = BlogRelatedPostsLoader({});
96
- expect(Array.isArray(result)).toBe(true);
97
- });
98
-
99
- it("returns null when empty", () => {
100
- mockGetRecords.mockReturnValue([]);
101
- expect(BlogRelatedPostsLoader({})).toBeNull();
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
- it("returns BlogPostPage with correct @type", () => {
110
- const result = BlogPostPageLoader({ slug: "post-a" });
111
- expect(result).not.toBeNull();
112
- expect(result!["@type"]).toBe("BlogPostPage");
113
- expect(result!.post.slug).toBe("post-a");
114
- });
115
-
116
- it("returns null when post not found", () => {
117
- expect(BlogPostPageLoader({ slug: "nonexistent" })).toBeNull();
118
- });
119
-
120
- it("SEO fields fall back to post fields", () => {
121
- mockGetRecords.mockReturnValue([
122
- makePost({
123
- slug: "no-seo",
124
- title: "Fallback Title",
125
- excerpt: "Fallback Desc",
126
- image: "img.png",
127
- seo: undefined,
128
- }),
129
- ]);
130
-
131
- const result = BlogPostPageLoader({ slug: "no-seo" });
132
- expect(result).not.toBeNull();
133
- expect(result!.seo?.title).toBe("Fallback Title");
134
- expect(result!.seo?.description).toBe("Fallback Desc");
135
- expect(result!.seo?.image).toBe("img.png");
136
- });
137
-
138
- it("uses SEO fields when present", () => {
139
- mockGetRecords.mockReturnValue([
140
- makePost({
141
- slug: "has-seo",
142
- title: "Post Title",
143
- seo: { title: "SEO Title", description: "SEO Desc" },
144
- }),
145
- ]);
146
-
147
- const result = BlogPostPageLoader({ slug: "has-seo" });
148
- expect(result!.seo?.title).toBe("SEO Title");
149
- expect(result!.seo?.description).toBe("SEO Desc");
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
- const categories: Category[] = [
158
- { name: "Beta", slug: "beta" },
159
- { name: "Alpha", slug: "alpha" },
160
- { name: "Charlie", slug: "charlie" },
161
- ];
162
-
163
- beforeEach(() => {
164
- mockGetRecords.mockReturnValue([...categories]);
165
- });
166
-
167
- it("sorts by name (title_desc default)", () => {
168
- const result = GetCategories({});
169
- expect(result).not.toBeNull();
170
- expect(result!.map((c) => c.name)).toEqual(["Alpha", "Beta", "Charlie"]);
171
- });
172
-
173
- it("filters by slug when provided", () => {
174
- const result = GetCategories({ slug: "alpha" });
175
- expect(result).toHaveLength(1);
176
- expect(result![0].slug).toBe("alpha");
177
- });
178
-
179
- it("slices by count", () => {
180
- const result = GetCategories({ count: 2 });
181
- expect(result).toHaveLength(2);
182
- });
183
-
184
- it("returns null when no categories", () => {
185
- mockGetRecords.mockReturnValue([]);
186
- expect(GetCategories({})).toBeNull();
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
- it("returns post by slug", () => {
195
- const result = BlogPostItem({ slug: "post-b" });
196
- expect(result).not.toBeNull();
197
- expect(result!.slug).toBe("post-b");
198
- });
199
-
200
- it("returns null when no slug provided", () => {
201
- expect(BlogPostItem({ slug: "" })).toBeNull();
202
- });
203
-
204
- it("returns null when post not found", () => {
205
- expect(BlogPostItem({ slug: "nonexistent" })).toBeNull();
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
- 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
- });
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
+ });