@decocms/apps-blog 7.52.1 → 7.53.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 (63) hide show
  1. package/package.json +13 -4
  2. package/src/README.md +69 -0
  3. package/src/__tests__/blogRecords.test.ts +270 -0
  4. package/src/__tests__/date.test.ts +134 -0
  5. package/src/__tests__/handlePosts.test.ts +230 -230
  6. package/src/__tests__/loaders.test.ts +154 -154
  7. package/src/__tests__/mod.test.ts +6 -6
  8. package/src/__tests__/publicationStatus.test.ts +176 -0
  9. package/src/__tests__/records.test.ts +68 -68
  10. package/src/__tests__/seoSections.test.ts +245 -0
  11. package/src/actions/submitRating.ts +36 -0
  12. package/src/actions/submitReview.ts +95 -0
  13. package/src/actions/submitView.ts +28 -0
  14. package/src/client.ts +69 -0
  15. package/src/core/blogRecords.ts +94 -0
  16. package/src/core/handlePosts.ts +154 -63
  17. package/src/core/records.ts +147 -12
  18. package/src/index.ts +48 -14
  19. package/src/loaderMap.ts +28 -24
  20. package/src/loaders/BlogPostItem.ts +15 -5
  21. package/src/loaders/BlogPostPage.ts +25 -20
  22. package/src/loaders/BlogRelatedPosts.ts +45 -45
  23. package/src/loaders/BlogpostList.ts +73 -0
  24. package/src/loaders/BlogpostListing.ts +114 -77
  25. package/src/loaders/GetCategories.ts +42 -28
  26. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  27. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  28. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  29. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  30. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  31. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  32. package/src/loaders/extensions/types.ts +15 -0
  33. package/src/manifest.gen.ts +82 -14
  34. package/src/mod.ts +71 -20
  35. package/src/registry.ts +5 -5
  36. package/src/sections/Seo/SeoBlogPost.tsx +75 -0
  37. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  38. package/src/sections/Template.tsx +96 -0
  39. package/src/sections/blocks/BlockImage.tsx +21 -0
  40. package/src/sections/blocks/Callout.tsx +64 -0
  41. package/src/sections/blocks/CardGroup.tsx +60 -0
  42. package/src/sections/blocks/Checklist.tsx +58 -0
  43. package/src/sections/blocks/Code.tsx +29 -0
  44. package/src/sections/blocks/Comparison.tsx +77 -0
  45. package/src/sections/blocks/Cta.tsx +22 -0
  46. package/src/sections/blocks/Divider.tsx +3 -0
  47. package/src/sections/blocks/Heading.tsx +28 -0
  48. package/src/sections/blocks/List.tsx +39 -0
  49. package/src/sections/blocks/Paragraph.tsx +15 -0
  50. package/src/sections/blocks/Quote.tsx +32 -0
  51. package/src/sections/blocks/Stat.tsx +23 -0
  52. package/src/sections/blocks/StatGroup.tsx +38 -0
  53. package/src/sections/blocks/Steps.tsx +64 -0
  54. package/src/sections/blocks/Table.tsx +80 -0
  55. package/src/sections/blocks/Video.tsx +39 -0
  56. package/src/static/css.ts +178 -0
  57. package/src/types.ts +312 -77
  58. package/src/utils/blocksToSections.ts +170 -0
  59. package/src/utils/constants.ts +1 -0
  60. package/src/utils/date.ts +95 -0
  61. package/src/utils/hardSanitize.ts +106 -0
  62. package/src/utils/jsonLD.ts +115 -0
  63. 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,193 @@ 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
103
  });
104
104
 
105
105
  // ---------------------------------------------------------------------------
106
106
  // BlogPostPage
107
107
  // ---------------------------------------------------------------------------
108
108
  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
- });
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
+ });
151
151
  });
152
152
 
153
153
  // ---------------------------------------------------------------------------
154
154
  // GetCategories
155
155
  // ---------------------------------------------------------------------------
156
156
  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
- });
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
+ });
188
188
  });
189
189
 
190
190
  // ---------------------------------------------------------------------------
191
191
  // BlogPostItem
192
192
  // ---------------------------------------------------------------------------
193
193
  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
- });
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
+ });
207
207
  });
@@ -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
+ });