@decocms/apps-blog 7.2.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/package.json +46 -0
- package/src/__tests__/handlePosts.test.ts +285 -0
- package/src/__tests__/loaders.test.ts +207 -0
- package/src/__tests__/mod.test.ts +11 -0
- package/src/__tests__/records.test.ts +92 -0
- package/src/core/handlePosts.ts +88 -0
- package/src/core/records.ts +30 -0
- package/src/index.ts +24 -0
- package/src/loaderMap.ts +57 -0
- package/src/loaders/Author.ts +9 -0
- package/src/loaders/BlogPostItem.ts +19 -0
- package/src/loaders/BlogPostPage.ts +39 -0
- package/src/loaders/BlogRelatedPosts.ts +66 -0
- package/src/loaders/Blogpost.ts +9 -0
- package/src/loaders/BlogpostListing.ts +101 -0
- package/src/loaders/Category.ts +9 -0
- package/src/loaders/GetCategories.ts +48 -0
- package/src/mod.ts +55 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +105 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/apps-blog",
|
|
3
|
+
"version": "7.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco app: blog content and CMS integration",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/decocms/blocks.git",
|
|
9
|
+
"directory": "packages/apps-blog"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./mod": "./src/mod.ts",
|
|
15
|
+
"./types": "./src/types.ts",
|
|
16
|
+
"./loaderMap": "./src/loaderMap.ts",
|
|
17
|
+
"./loaders/*": "./src/loaders/*.ts",
|
|
18
|
+
"./core/*": "./src/core/*.ts",
|
|
19
|
+
"./registry": "./src/registry.ts"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"test": "vitest run --root ../.. packages/apps-blog/",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"lint:unused": "knip"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@decocms/blocks": "7.2.0",
|
|
29
|
+
"@decocms/apps-commerce": "7.2.0",
|
|
30
|
+
"@decocms/apps-website": "7.2.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"react-dom": "^19.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "^19.0.0",
|
|
38
|
+
"@types/react-dom": "^19.0.0",
|
|
39
|
+
"knip": "^5.86.0",
|
|
40
|
+
"typescript": "^5.9.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"registry": "https://registry.npmjs.org",
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import handlePosts, {
|
|
3
|
+
filterPostsByCategory,
|
|
4
|
+
filterPostsBySlugs,
|
|
5
|
+
filterPostsByTerm,
|
|
6
|
+
filterRelatedPosts,
|
|
7
|
+
slicePosts,
|
|
8
|
+
sortPosts,
|
|
9
|
+
} from "../core/handlePosts";
|
|
10
|
+
import type { BlogPost, SortBy } from "../types";
|
|
11
|
+
|
|
12
|
+
function makePost(overrides: Partial<BlogPost> = {}): BlogPost {
|
|
13
|
+
return {
|
|
14
|
+
title: "Test Post",
|
|
15
|
+
slug: "test-post",
|
|
16
|
+
date: "2024-06-01",
|
|
17
|
+
excerpt: "A test post",
|
|
18
|
+
content: "Full content here",
|
|
19
|
+
categories: [{ name: "News", slug: "news" }],
|
|
20
|
+
authors: [{ name: "Author", email: "a@b.com" }],
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// sortPosts
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
describe("sortPosts", () => {
|
|
29
|
+
const posts = [
|
|
30
|
+
makePost({ title: "Alpha", slug: "a", date: "2024-01-01" }),
|
|
31
|
+
makePost({ title: "Charlie", slug: "c", date: "2024-03-01" }),
|
|
32
|
+
makePost({ title: "Bravo", slug: "b", date: "2024-02-01" }),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
it("sorts date_desc — most recent first", () => {
|
|
36
|
+
const sorted = sortPosts(posts, "date_desc");
|
|
37
|
+
expect(sorted.map((p) => p.slug)).toEqual(["c", "b", "a"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("sorts date_asc — oldest first", () => {
|
|
41
|
+
const sorted = sortPosts(posts, "date_asc");
|
|
42
|
+
expect(sorted.map((p) => p.slug)).toEqual(["a", "b", "c"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("sorts title_asc", () => {
|
|
46
|
+
const sorted = sortPosts(posts, "title_asc");
|
|
47
|
+
// localeCompare(a,b) is negated for asc → Z-A
|
|
48
|
+
expect(sorted.map((p) => p.title)).toEqual(["Charlie", "Bravo", "Alpha"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sorts title_desc", () => {
|
|
52
|
+
const sorted = sortPosts(posts, "title_desc");
|
|
53
|
+
// localeCompare(a,b) kept as-is for desc → A-Z
|
|
54
|
+
expect(sorted.map((p) => p.title)).toEqual(["Alpha", "Bravo", "Charlie"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to date for invalid sort field", () => {
|
|
58
|
+
const sorted = sortPosts(posts, "invalid_desc" as SortBy);
|
|
59
|
+
expect(sorted.map((p) => p.slug)).toEqual(["c", "b", "a"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to desc for invalid sort order", () => {
|
|
63
|
+
const sorted = sortPosts(posts, "date_wrong" as SortBy);
|
|
64
|
+
expect(sorted.map((p) => p.slug)).toEqual(["c", "b", "a"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sorts posts with missing date to end", () => {
|
|
68
|
+
const withMissing = [
|
|
69
|
+
makePost({ slug: "no-date", date: undefined as unknown as string }),
|
|
70
|
+
makePost({ slug: "has-date", date: "2024-06-01" }),
|
|
71
|
+
];
|
|
72
|
+
const sorted = sortPosts(withMissing, "date_desc");
|
|
73
|
+
expect(sorted[0].slug).toBe("has-date");
|
|
74
|
+
expect(sorted[1].slug).toBe("no-date");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws on empty array (accesses blogPosts[0])", () => {
|
|
78
|
+
expect(() => sortPosts([], "date_desc")).toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not mutate original array", () => {
|
|
82
|
+
const original = [...posts];
|
|
83
|
+
sortPosts(posts, "date_asc");
|
|
84
|
+
expect(posts).toEqual(original);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// filterPostsByCategory
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
describe("filterPostsByCategory", () => {
|
|
92
|
+
const posts = [
|
|
93
|
+
makePost({ slug: "a", categories: [{ name: "News", slug: "news" }] }),
|
|
94
|
+
makePost({
|
|
95
|
+
slug: "b",
|
|
96
|
+
categories: [
|
|
97
|
+
{ name: "News", slug: "news" },
|
|
98
|
+
{ name: "Tech", slug: "tech" },
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
makePost({ slug: "c", categories: [{ name: "Tech", slug: "tech" }] }),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
it("returns all posts when no slug provided", () => {
|
|
105
|
+
expect(filterPostsByCategory(posts)).toHaveLength(3);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("filters by matching slug", () => {
|
|
109
|
+
const result = filterPostsByCategory(posts, "tech");
|
|
110
|
+
expect(result.map((p) => p.slug)).toEqual(["b", "c"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns empty when no match", () => {
|
|
114
|
+
expect(filterPostsByCategory(posts, "sports")).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("excludes posts with no categories", () => {
|
|
118
|
+
const withNone = [...posts, makePost({ slug: "d", categories: undefined })];
|
|
119
|
+
expect(filterPostsByCategory(withNone, "news").map((p) => p.slug)).toEqual(["a", "b"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("includes post when one of multiple categories matches", () => {
|
|
123
|
+
const result = filterPostsByCategory(posts, "tech");
|
|
124
|
+
expect(result.find((p) => p.slug === "b")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// filterPostsBySlugs
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
describe("filterPostsBySlugs", () => {
|
|
132
|
+
const posts = [makePost({ slug: "a" }), makePost({ slug: "b" }), makePost({ slug: "c" })];
|
|
133
|
+
|
|
134
|
+
it("filters to only matching slugs", () => {
|
|
135
|
+
expect(filterPostsBySlugs(posts, ["a", "c"]).map((p) => p.slug)).toEqual(["a", "c"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns empty for empty slugs array", () => {
|
|
139
|
+
expect(filterPostsBySlugs(posts, [])).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// filterPostsByTerm
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
describe("filterPostsByTerm", () => {
|
|
147
|
+
const posts = [
|
|
148
|
+
makePost({ slug: "a", title: "Hello World", excerpt: "intro", content: "body" }),
|
|
149
|
+
makePost({ slug: "b", title: "Goodbye", excerpt: "farewell world", content: "end" }),
|
|
150
|
+
makePost({ slug: "c", title: "Nothing", excerpt: "nope", content: "empty" }),
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
it("matches in title", () => {
|
|
154
|
+
expect(filterPostsByTerm(posts, "Hello").map((p) => p.slug)).toEqual(["a"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("matches in excerpt", () => {
|
|
158
|
+
expect(filterPostsByTerm(posts, "farewell").map((p) => p.slug)).toEqual(["b"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("matches in content", () => {
|
|
162
|
+
expect(filterPostsByTerm(posts, "empty").map((p) => p.slug)).toEqual(["c"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("is case-insensitive", () => {
|
|
166
|
+
expect(filterPostsByTerm(posts, "hELLo")).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("returns empty when no match", () => {
|
|
170
|
+
expect(filterPostsByTerm(posts, "zzz")).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// filterRelatedPosts
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
describe("filterRelatedPosts", () => {
|
|
178
|
+
const posts = [
|
|
179
|
+
makePost({ slug: "a", categories: [{ name: "News", slug: "news" }] }),
|
|
180
|
+
makePost({ slug: "b", categories: [{ name: "Tech", slug: "tech" }] }),
|
|
181
|
+
makePost({ slug: "c", categories: undefined }),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
it("includes post with category overlap", () => {
|
|
185
|
+
expect(filterRelatedPosts(posts, ["news"]).map((p) => p.slug)).toEqual(["a"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("excludes post with no overlap", () => {
|
|
189
|
+
expect(filterRelatedPosts(posts, ["sports"])).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("excludes post with no categories", () => {
|
|
193
|
+
const result = filterRelatedPosts(posts, ["news"]);
|
|
194
|
+
expect(result.find((p) => p.slug === "c")).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// slicePosts
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
describe("slicePosts", () => {
|
|
202
|
+
const posts = Array.from({ length: 5 }, (_, i) => makePost({ slug: `p${i + 1}` }));
|
|
203
|
+
|
|
204
|
+
it("page 1 returns first N", () => {
|
|
205
|
+
expect(slicePosts(posts, 1, 2).map((p) => p.slug)).toEqual(["p1", "p2"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("page 2 returns next N", () => {
|
|
209
|
+
expect(slicePosts(posts, 2, 2).map((p) => p.slug)).toEqual(["p3", "p4"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("last page with fewer items", () => {
|
|
213
|
+
expect(slicePosts(posts, 3, 2).map((p) => p.slug)).toEqual(["p5"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("page beyond total returns empty", () => {
|
|
217
|
+
expect(slicePosts(posts, 10, 2)).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// handlePosts (composition pipeline)
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
describe("handlePosts", () => {
|
|
225
|
+
const posts = [
|
|
226
|
+
makePost({
|
|
227
|
+
slug: "a",
|
|
228
|
+
title: "Alpha",
|
|
229
|
+
date: "2024-01-01",
|
|
230
|
+
categories: [{ name: "News", slug: "news" }],
|
|
231
|
+
}),
|
|
232
|
+
makePost({
|
|
233
|
+
slug: "b",
|
|
234
|
+
title: "Bravo",
|
|
235
|
+
date: "2024-02-01",
|
|
236
|
+
categories: [{ name: "Tech", slug: "tech" }],
|
|
237
|
+
}),
|
|
238
|
+
makePost({
|
|
239
|
+
slug: "c",
|
|
240
|
+
title: "Charlie",
|
|
241
|
+
date: "2024-03-01",
|
|
242
|
+
categories: [
|
|
243
|
+
{ name: "News", slug: "news" },
|
|
244
|
+
{ name: "Tech", slug: "tech" },
|
|
245
|
+
],
|
|
246
|
+
}),
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
it("slug string + postSlugs — uses slug filtering", () => {
|
|
250
|
+
const result = handlePosts(posts, "date_desc", "news", ["a", "c"]);
|
|
251
|
+
// sorted by date_desc: c (March) before a (Jan)
|
|
252
|
+
expect(result?.map((p) => p.slug)).toEqual(["c", "a"]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("slug string + term — chains category + term filters", () => {
|
|
256
|
+
const result = handlePosts(posts, "date_desc", "news", undefined, "Alpha");
|
|
257
|
+
expect(result?.map((p) => p.slug)).toEqual(["a"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("slug as string[] — triggers related posts path", () => {
|
|
261
|
+
const result = handlePosts(posts, "date_desc", ["tech"]);
|
|
262
|
+
expect(result?.map((p) => p.slug)).toEqual(["c", "b"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("no slug + term — term-only filtering", () => {
|
|
266
|
+
const result = handlePosts(posts, "date_asc", undefined, undefined, "Bravo");
|
|
267
|
+
expect(result).toHaveLength(1);
|
|
268
|
+
expect(result![0].slug).toBe("b");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("excludePostSlug removes matching post", () => {
|
|
272
|
+
const result = handlePosts(posts, "date_desc", undefined, undefined, undefined, "b");
|
|
273
|
+
expect(result?.find((p) => p.slug === "b")).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("returns null when result is empty", () => {
|
|
277
|
+
expect(handlePosts(posts, "date_desc", "nonexistent")).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns all posts sorted when no filters given", () => {
|
|
281
|
+
const result = handlePosts(posts, "date_desc");
|
|
282
|
+
expect(result).toHaveLength(3);
|
|
283
|
+
expect(result![0].slug).toBe("c");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../core/records", () => ({
|
|
4
|
+
getRecordsByPath: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { getRecordsByPath } from "../core/records";
|
|
8
|
+
import BlogPostItem from "../loaders/BlogPostItem";
|
|
9
|
+
import BlogPostPageLoader from "../loaders/BlogPostPage";
|
|
10
|
+
import BlogpostListing from "../loaders/BlogpostListing";
|
|
11
|
+
import BlogRelatedPostsLoader from "../loaders/BlogRelatedPosts";
|
|
12
|
+
import GetCategories from "../loaders/GetCategories";
|
|
13
|
+
import type { BlogPost, Category } from "../types";
|
|
14
|
+
|
|
15
|
+
const mockGetRecords = getRecordsByPath as ReturnType<typeof vi.fn>;
|
|
16
|
+
|
|
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
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
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" }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mockGetRecords.mockReturnValue(samplePosts);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// BlogpostListing
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// BlogRelatedPosts
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// BlogPostPage
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// GetCategories
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// BlogPostItem
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { configure } from "../mod";
|
|
3
|
+
|
|
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
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@decocms/blocks/cms", () => ({
|
|
4
|
+
loadBlocks: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { loadBlocks } from "@decocms/blocks/cms";
|
|
8
|
+
import { getRecordsByPath } from "../core/records";
|
|
9
|
+
|
|
10
|
+
const mockLoadBlocks = loadBlocks as ReturnType<typeof vi.fn>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("getRecordsByPath", () => {
|
|
17
|
+
it("extracts records matching path prefix", () => {
|
|
18
|
+
mockLoadBlocks.mockReturnValue({
|
|
19
|
+
"collections/blog/posts/hello": {
|
|
20
|
+
name: "collections/blog/posts/hello",
|
|
21
|
+
post: { title: "Hello", slug: "hello" },
|
|
22
|
+
},
|
|
23
|
+
"collections/blog/posts/world": {
|
|
24
|
+
name: "collections/blog/posts/world",
|
|
25
|
+
post: { title: "World", slug: "world" },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = getRecordsByPath("collections/blog/posts", "post");
|
|
30
|
+
expect(result).toHaveLength(2);
|
|
31
|
+
expect(result).toEqual(
|
|
32
|
+
expect.arrayContaining([
|
|
33
|
+
expect.objectContaining({ title: "Hello", slug: "hello" }),
|
|
34
|
+
expect.objectContaining({ title: "World", slug: "world" }),
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("skips non-object blocks", () => {
|
|
40
|
+
mockLoadBlocks.mockReturnValue({
|
|
41
|
+
"collections/blog/posts/a": "not-an-object",
|
|
42
|
+
"collections/blog/posts/b": null,
|
|
43
|
+
"collections/blog/posts/c": {
|
|
44
|
+
name: "collections/blog/posts/c",
|
|
45
|
+
post: { title: "C" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = getRecordsByPath("collections/blog/posts", "post");
|
|
50
|
+
expect(result).toHaveLength(1);
|
|
51
|
+
expect(result[0]).toEqual(expect.objectContaining({ title: "C" }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("skips blocks without the accessor field", () => {
|
|
55
|
+
mockLoadBlocks.mockReturnValue({
|
|
56
|
+
"collections/blog/posts/x": {
|
|
57
|
+
name: "collections/blog/posts/x",
|
|
58
|
+
other: { title: "X" },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = getRecordsByPath("collections/blog/posts", "post");
|
|
63
|
+
expect(result).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("derives id from block name", () => {
|
|
67
|
+
mockLoadBlocks.mockReturnValue({
|
|
68
|
+
"collections/blog/posts/my-post": {
|
|
69
|
+
name: "collections/blog/posts/my-post",
|
|
70
|
+
post: { title: "My Post" },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = getRecordsByPath<{ title: string; id?: string }>(
|
|
75
|
+
"collections/blog/posts",
|
|
76
|
+
"post",
|
|
77
|
+
);
|
|
78
|
+
expect(result[0].id).toBe("my-post");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns empty array when no blocks match", () => {
|
|
82
|
+
mockLoadBlocks.mockReturnValue({
|
|
83
|
+
"collections/blog/categories/cat": {
|
|
84
|
+
name: "collections/blog/categories/cat",
|
|
85
|
+
category: { name: "Cat" },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = getRecordsByPath("collections/blog/posts", "post");
|
|
90
|
+
expect(result).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { BlogPost, SortBy } from "../types";
|
|
2
|
+
|
|
3
|
+
const VALID_SORT_ORDERS = ["asc", "desc"];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sort posts by the given criteria.
|
|
7
|
+
* Skips view-based sorting (no Drizzle in this port).
|
|
8
|
+
*/
|
|
9
|
+
export const sortPosts = (blogPosts: BlogPost[], sortBy: SortBy): BlogPost[] => {
|
|
10
|
+
const parts = sortBy.split("_");
|
|
11
|
+
const sortMethod = (parts[0] in blogPosts[0] ? parts[0] : "date") as keyof BlogPost;
|
|
12
|
+
const sortOrder = VALID_SORT_ORDERS.includes(parts[1]) ? parts[1] : "desc";
|
|
13
|
+
|
|
14
|
+
return [...blogPosts].sort((a, b) => {
|
|
15
|
+
if (!a[sortMethod] && !b[sortMethod]) return 0;
|
|
16
|
+
if (!a[sortMethod]) return 1;
|
|
17
|
+
if (!b[sortMethod]) return -1;
|
|
18
|
+
|
|
19
|
+
const comparison =
|
|
20
|
+
sortMethod === "date"
|
|
21
|
+
? new Date(`${b.date}T00:00:00`).getTime() - new Date(`${a.date}T00:00:00`).getTime()
|
|
22
|
+
: (a[sortMethod]?.toString().localeCompare(b[sortMethod]?.toString() ?? "") ?? 0);
|
|
23
|
+
|
|
24
|
+
return sortOrder === "desc" ? comparison : -comparison;
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Filter posts by a single category slug. */
|
|
29
|
+
export const filterPostsByCategory = (posts: BlogPost[], slug?: string): BlogPost[] =>
|
|
30
|
+
slug ? posts.filter(({ categories }) => categories?.find((c) => c.slug === slug)) : posts;
|
|
31
|
+
|
|
32
|
+
/** Filter posts whose slug is in the given list. */
|
|
33
|
+
export const filterPostsBySlugs = (posts: BlogPost[], postSlugs: string[]): BlogPost[] =>
|
|
34
|
+
posts.filter(({ slug }) => postSlugs.includes(slug));
|
|
35
|
+
|
|
36
|
+
/** Filter posts matching a search term (title, excerpt, content). */
|
|
37
|
+
export const filterPostsByTerm = (posts: BlogPost[], term: string): BlogPost[] =>
|
|
38
|
+
posts.filter(({ content, excerpt, title }) =>
|
|
39
|
+
[content, excerpt, title].some((field) => field?.toLowerCase().includes(term.toLowerCase())),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Filter posts whose categories overlap with the given slug array. */
|
|
43
|
+
export const filterRelatedPosts = (posts: BlogPost[], slugs: string[]): BlogPost[] =>
|
|
44
|
+
posts.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug)));
|
|
45
|
+
|
|
46
|
+
/** Slice posts for pagination. */
|
|
47
|
+
export const slicePosts = (
|
|
48
|
+
posts: BlogPost[],
|
|
49
|
+
pageNumber: number,
|
|
50
|
+
postsPerPage: number,
|
|
51
|
+
): BlogPost[] => {
|
|
52
|
+
const startIndex = (pageNumber - 1) * postsPerPage;
|
|
53
|
+
return posts.slice(startIndex, startIndex + postsPerPage);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Combined filter & sort pipeline (no slice).
|
|
58
|
+
*/
|
|
59
|
+
export default function handlePosts(
|
|
60
|
+
posts: BlogPost[],
|
|
61
|
+
sortBy: SortBy,
|
|
62
|
+
slug?: string | string[],
|
|
63
|
+
postSlugs?: string[],
|
|
64
|
+
term?: string,
|
|
65
|
+
excludePostSlug?: string,
|
|
66
|
+
): BlogPost[] | null {
|
|
67
|
+
let filtered: BlogPost[];
|
|
68
|
+
|
|
69
|
+
if (typeof slug === "string") {
|
|
70
|
+
filtered =
|
|
71
|
+
postSlugs && postSlugs.length > 0
|
|
72
|
+
? filterPostsBySlugs(posts, postSlugs)
|
|
73
|
+
: filterPostsByCategory(posts, slug);
|
|
74
|
+
if (term) filtered = filterPostsByTerm(filtered, term);
|
|
75
|
+
} else if (Array.isArray(slug)) {
|
|
76
|
+
filtered = filterRelatedPosts(posts, slug);
|
|
77
|
+
} else {
|
|
78
|
+
filtered = term ? filterPostsByTerm(posts, term) : posts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (excludePostSlug) {
|
|
82
|
+
filtered = filtered.filter(({ slug: s }) => s !== excludePostSlug);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!filtered || filtered.length === 0) return null;
|
|
86
|
+
|
|
87
|
+
return sortPosts(filtered, sortBy);
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { loadBlocks } from "@decocms/blocks/cms";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retrieve records from CMS blocks by path prefix.
|
|
5
|
+
*
|
|
6
|
+
* Scans the decofile blocks whose key starts with `path` and extracts the
|
|
7
|
+
* nested value at `accessor` from each matching block.
|
|
8
|
+
*
|
|
9
|
+
* Equivalent to the Deno `getRecordsByPath(ctx, path, accessor)` but uses
|
|
10
|
+
* `loadBlocks()` from `@decocms/blocks/cms` instead of `ctx.get(resolvables)`.
|
|
11
|
+
*/
|
|
12
|
+
export function getRecordsByPath<T>(path: string, accessor: string): T[] {
|
|
13
|
+
const blocks = loadBlocks() as Record<string, Record<string, unknown>>;
|
|
14
|
+
const results: T[] = [];
|
|
15
|
+
|
|
16
|
+
for (const [key, value] of Object.entries(blocks)) {
|
|
17
|
+
if (!key.startsWith(path) || !value || typeof value !== "object") {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const record = value[accessor] as T | undefined;
|
|
22
|
+
if (!record) continue;
|
|
23
|
+
|
|
24
|
+
const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", "");
|
|
25
|
+
|
|
26
|
+
results.push({ ...record, id } as T);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return results;
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for the blog app.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { getRecordsByPath } from "./core/records";
|
|
6
|
+
/** @deprecated Use `createBlogLoaders` instead. */
|
|
7
|
+
export {
|
|
8
|
+
createBlogLoaders,
|
|
9
|
+
createBlogLoaders as createBlogCommerceLoaders,
|
|
10
|
+
} from "./loaderMap";
|
|
11
|
+
export { configure } from "./mod";
|
|
12
|
+
|
|
13
|
+
// Types
|
|
14
|
+
export type {
|
|
15
|
+
Author,
|
|
16
|
+
BlogPost,
|
|
17
|
+
BlogPostListingPage,
|
|
18
|
+
BlogPostPage,
|
|
19
|
+
Category,
|
|
20
|
+
ExtraProps,
|
|
21
|
+
PageInfo,
|
|
22
|
+
Seo,
|
|
23
|
+
SortBy,
|
|
24
|
+
} from "./types";
|
package/src/loaderMap.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog loader map factory for CMS block resolution.
|
|
3
|
+
*
|
|
4
|
+
* Returns a `Record<string, LoaderFn>` that the site spreads into its
|
|
5
|
+
* block loader registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import AuthorLoader from "./loaders/Author";
|
|
9
|
+
import BlogPostItemLoader from "./loaders/BlogPostItem";
|
|
10
|
+
import BlogPostPageLoader from "./loaders/BlogPostPage";
|
|
11
|
+
import BlogpostLoader from "./loaders/Blogpost";
|
|
12
|
+
import BlogpostListingLoader from "./loaders/BlogpostListing";
|
|
13
|
+
import BlogRelatedPostsLoader from "./loaders/BlogRelatedPosts";
|
|
14
|
+
import CategoryLoader from "./loaders/Category";
|
|
15
|
+
import GetCategoriesLoader from "./loaders/GetCategories";
|
|
16
|
+
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: loader props/returns vary per block
|
|
18
|
+
export type LoaderFn = (props: any, request?: Request) => Promise<any> | any;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create the blog loader map.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { createBlogLoaders } from "@decocms/apps/blog";
|
|
26
|
+
*
|
|
27
|
+
* const COMMERCE_LOADERS = {
|
|
28
|
+
* ...createVtexCommerceLoaders(),
|
|
29
|
+
* ...createBlogLoaders(),
|
|
30
|
+
* };
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function createBlogLoaders(): Record<string, LoaderFn> {
|
|
34
|
+
return {
|
|
35
|
+
// Loader keys match the Deno app's __resolveType paths
|
|
36
|
+
"blog/loaders/BlogPostPage.ts": BlogPostPageLoader,
|
|
37
|
+
"blog/loaders/BlogPostPage": BlogPostPageLoader,
|
|
38
|
+
"blog/loaders/BlogpostListing.ts": BlogpostListingLoader,
|
|
39
|
+
"blog/loaders/BlogpostListing": BlogpostListingLoader,
|
|
40
|
+
"blog/loaders/BlogRelatedPosts.ts": BlogRelatedPostsLoader,
|
|
41
|
+
"blog/loaders/BlogRelatedPosts": BlogRelatedPostsLoader,
|
|
42
|
+
"blog/loaders/GetCategories.ts": GetCategoriesLoader,
|
|
43
|
+
"blog/loaders/GetCategories": GetCategoriesLoader,
|
|
44
|
+
"blog/loaders/Blogpost.ts": BlogpostLoader,
|
|
45
|
+
"blog/loaders/Blogpost": BlogpostLoader,
|
|
46
|
+
"blog/loaders/Category.ts": CategoryLoader,
|
|
47
|
+
"blog/loaders/Category": CategoryLoader,
|
|
48
|
+
"blog/loaders/Author.ts": AuthorLoader,
|
|
49
|
+
"blog/loaders/Author": AuthorLoader,
|
|
50
|
+
|
|
51
|
+
// BlogPostItem: looks up a single post by slug, returns BlogPost
|
|
52
|
+
"blog/loaders/BlogPostItem.ts": BlogPostItemLoader,
|
|
53
|
+
"blog/loaders/BlogPostItem": BlogPostItemLoader,
|
|
54
|
+
"blog/loaders/BlogpostList.ts": BlogpostListingLoader,
|
|
55
|
+
"blog/loaders/BlogpostList": BlogpostListingLoader,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getRecordsByPath } from "../core/records";
|
|
2
|
+
import type { BlogPost } from "../types";
|
|
3
|
+
|
|
4
|
+
export interface Props {
|
|
5
|
+
slug: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @title BlogPostItem
|
|
10
|
+
* @description Fetches a single blog post by slug. Returns the BlogPost
|
|
11
|
+
* directly (not wrapped in BlogPostPage).
|
|
12
|
+
*/
|
|
13
|
+
export default function BlogPostItem(props: Props & { __pageUrl?: string }): BlogPost | null {
|
|
14
|
+
const { slug } = props;
|
|
15
|
+
if (!slug) return null;
|
|
16
|
+
|
|
17
|
+
const posts = getRecordsByPath<BlogPost>("collections/blog/posts", "post");
|
|
18
|
+
return posts.find((p) => p?.slug === slug) ?? null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getRecordsByPath } from "../core/records";
|
|
2
|
+
import type { BlogPost, BlogPostPage } from "../types";
|
|
3
|
+
|
|
4
|
+
const COLLECTION_PATH = "collections/blog/posts";
|
|
5
|
+
const ACCESSOR = "post";
|
|
6
|
+
|
|
7
|
+
export interface Props {
|
|
8
|
+
slug: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @title BlogPostPage
|
|
13
|
+
* @description Fetches a specific blog post page by its slug.
|
|
14
|
+
*/
|
|
15
|
+
export default function BlogPostPageLoader(
|
|
16
|
+
props: Props & { __pageUrl?: string },
|
|
17
|
+
req?: Request,
|
|
18
|
+
): BlogPostPage | null {
|
|
19
|
+
const { slug } = props;
|
|
20
|
+
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
21
|
+
|
|
22
|
+
const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
|
|
23
|
+
const url = new URL(rawUrl);
|
|
24
|
+
const post = posts.find((p) => p?.slug === slug);
|
|
25
|
+
|
|
26
|
+
if (!post) return null;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
"@type": "BlogPostPage",
|
|
30
|
+
post,
|
|
31
|
+
seo: {
|
|
32
|
+
title: post?.seo?.title || post?.title,
|
|
33
|
+
description: post?.seo?.description || post?.excerpt,
|
|
34
|
+
canonical: post?.seo?.canonical || url.href,
|
|
35
|
+
image: post?.seo?.image || post?.image,
|
|
36
|
+
noIndexing: post?.seo?.noIndexing || false,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import handlePosts, { slicePosts } from "../core/handlePosts";
|
|
2
|
+
import { getRecordsByPath } from "../core/records";
|
|
3
|
+
import type { BlogPost, SortBy } from "../types";
|
|
4
|
+
|
|
5
|
+
const COLLECTION_PATH = "collections/blog/posts";
|
|
6
|
+
const ACCESSOR = "post";
|
|
7
|
+
|
|
8
|
+
export interface Props {
|
|
9
|
+
/**
|
|
10
|
+
* @title Items per page
|
|
11
|
+
* @description Number of posts per page to display.
|
|
12
|
+
*/
|
|
13
|
+
count?: number;
|
|
14
|
+
/**
|
|
15
|
+
* @title Page query parameter
|
|
16
|
+
* @description The current page number. Defaults to 1.
|
|
17
|
+
*/
|
|
18
|
+
page?: number;
|
|
19
|
+
/**
|
|
20
|
+
* @title Category Slug
|
|
21
|
+
* @description Filter by a specific category slug.
|
|
22
|
+
*/
|
|
23
|
+
slug?: string | string[];
|
|
24
|
+
/**
|
|
25
|
+
* @title Page sorting parameter
|
|
26
|
+
* @description The sorting option. Default is "date_desc"
|
|
27
|
+
*/
|
|
28
|
+
sortBy?: SortBy;
|
|
29
|
+
/**
|
|
30
|
+
* @description Overrides the query term at url
|
|
31
|
+
*/
|
|
32
|
+
query?: string;
|
|
33
|
+
/**
|
|
34
|
+
* @title Exclude Post Slug
|
|
35
|
+
* @description Excludes a post slug from the list
|
|
36
|
+
*/
|
|
37
|
+
excludePostSlug?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type BlogRelatedPosts = BlogPost[] | null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @title BlogRelatedPosts
|
|
44
|
+
* @description Retrieves a list of blog related posts.
|
|
45
|
+
*/
|
|
46
|
+
export default function BlogRelatedPostsLoader(
|
|
47
|
+
props: Props & { __pageUrl?: string },
|
|
48
|
+
req?: Request,
|
|
49
|
+
): BlogRelatedPosts {
|
|
50
|
+
const { page, count, slug, sortBy, query, excludePostSlug } = props;
|
|
51
|
+
const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
|
|
52
|
+
const url = new URL(rawUrl);
|
|
53
|
+
const postsPerPage = Number(count ?? url.searchParams.get("count") ?? 12);
|
|
54
|
+
const pageNumber = Number(page ?? url.searchParams.get("page") ?? 1);
|
|
55
|
+
const pageSort = sortBy ?? (url.searchParams.get("sortBy") as SortBy) ?? "date_desc";
|
|
56
|
+
const term = query ?? url.searchParams.get("q") ?? undefined;
|
|
57
|
+
|
|
58
|
+
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
59
|
+
|
|
60
|
+
const handledPosts = handlePosts(posts, pageSort, slug, undefined, term, excludePostSlug);
|
|
61
|
+
|
|
62
|
+
if (!handledPosts) return null;
|
|
63
|
+
|
|
64
|
+
const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
|
|
65
|
+
return slicedPosts.length > 0 ? slicedPosts : null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import handlePosts, { slicePosts } from "../core/handlePosts";
|
|
2
|
+
import { getRecordsByPath } from "../core/records";
|
|
3
|
+
import type { BlogPost, BlogPostListingPage, PageInfo, SortBy } from "../types";
|
|
4
|
+
|
|
5
|
+
const COLLECTION_PATH = "collections/blog/posts";
|
|
6
|
+
const ACCESSOR = "post";
|
|
7
|
+
|
|
8
|
+
export interface Props {
|
|
9
|
+
/**
|
|
10
|
+
* @title Category Slug
|
|
11
|
+
* @description Filter by a specific category slug.
|
|
12
|
+
*/
|
|
13
|
+
slug?: string;
|
|
14
|
+
/**
|
|
15
|
+
* @title Items per page
|
|
16
|
+
* @description Number of posts per page to display.
|
|
17
|
+
*/
|
|
18
|
+
count?: number;
|
|
19
|
+
/**
|
|
20
|
+
* @title Page query parameter
|
|
21
|
+
* @description The current page number. Defaults to 1.
|
|
22
|
+
*/
|
|
23
|
+
page?: number;
|
|
24
|
+
/**
|
|
25
|
+
* @title Page sorting parameter
|
|
26
|
+
* @description The sorting option. Default is "date_desc"
|
|
27
|
+
*/
|
|
28
|
+
sortBy?: SortBy;
|
|
29
|
+
/**
|
|
30
|
+
* @description Overrides the query term at url
|
|
31
|
+
*/
|
|
32
|
+
query?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @title BlogPostList
|
|
37
|
+
* @description Retrieves a paginated list of blog posts.
|
|
38
|
+
*/
|
|
39
|
+
export default function BlogPostList(
|
|
40
|
+
props: Props & { __pageUrl?: string },
|
|
41
|
+
req?: Request,
|
|
42
|
+
): BlogPostListingPage | null {
|
|
43
|
+
const { page, count, slug, sortBy, query } = props;
|
|
44
|
+
const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
|
|
45
|
+
const url = new URL(rawUrl);
|
|
46
|
+
const params = url.searchParams;
|
|
47
|
+
const postsPerPage = Number(count ?? params.get("count") ?? 12);
|
|
48
|
+
const pageNumber = Number(page ?? params.get("page") ?? 1);
|
|
49
|
+
const pageSort = sortBy ?? (params.get("sortBy") as SortBy) ?? "date_desc";
|
|
50
|
+
const term = query ?? params.get("q") ?? undefined;
|
|
51
|
+
|
|
52
|
+
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const handledPosts = handlePosts(posts, pageSort, slug, undefined, term);
|
|
56
|
+
|
|
57
|
+
if (!handledPosts) return null;
|
|
58
|
+
|
|
59
|
+
const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
|
|
60
|
+
if (slicedPosts.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
const category = slicedPosts[0].categories?.find((c) => c.slug === slug);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
posts: slicedPosts,
|
|
66
|
+
pageInfo: toPageInfo(handledPosts, postsPerPage, pageNumber, params),
|
|
67
|
+
seo: {
|
|
68
|
+
title: category?.name ?? "",
|
|
69
|
+
canonical: new URL(url.pathname, url.origin).href,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
} catch (e) {
|
|
73
|
+
console.error("[BlogpostListing]", e);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toPageInfo(
|
|
79
|
+
posts: BlogPost[],
|
|
80
|
+
postsPerPage: number,
|
|
81
|
+
pageNumber: number,
|
|
82
|
+
params: URLSearchParams,
|
|
83
|
+
): PageInfo {
|
|
84
|
+
const totalPosts = posts.length;
|
|
85
|
+
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
|
86
|
+
const hasNextPage = totalPages > pageNumber;
|
|
87
|
+
const hasPrevPage = pageNumber > 1;
|
|
88
|
+
const nextPage = new URLSearchParams(params);
|
|
89
|
+
const previousPage = new URLSearchParams(params);
|
|
90
|
+
|
|
91
|
+
if (hasNextPage) nextPage.set("page", (pageNumber + 1).toString());
|
|
92
|
+
if (hasPrevPage) previousPage.set("page", (pageNumber - 1).toString());
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
nextPage: hasNextPage ? `?${nextPage}` : undefined,
|
|
96
|
+
previousPage: hasPrevPage ? `?${previousPage}` : undefined,
|
|
97
|
+
currentPage: pageNumber,
|
|
98
|
+
records: totalPosts,
|
|
99
|
+
recordPerPage: postsPerPage,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getRecordsByPath } from "../core/records";
|
|
2
|
+
import type { Category } from "../types";
|
|
3
|
+
|
|
4
|
+
const COLLECTION_PATH = "collections/blog/categories";
|
|
5
|
+
const ACCESSOR = "category";
|
|
6
|
+
|
|
7
|
+
export interface Props {
|
|
8
|
+
/**
|
|
9
|
+
* @title Category Slug
|
|
10
|
+
* @description Get the category data from a specific slug.
|
|
11
|
+
*/
|
|
12
|
+
slug?: string;
|
|
13
|
+
/**
|
|
14
|
+
* @title Items count
|
|
15
|
+
* @description Number of categories to return
|
|
16
|
+
*/
|
|
17
|
+
count?: number;
|
|
18
|
+
/**
|
|
19
|
+
* @title Sort
|
|
20
|
+
* @description The sorting option. Default is "title_desc"
|
|
21
|
+
*/
|
|
22
|
+
sortBy?: "title_asc" | "title_desc";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @title GetCategories
|
|
27
|
+
* @description Retrieves a list of blog categories.
|
|
28
|
+
*/
|
|
29
|
+
export default function GetCategories({
|
|
30
|
+
count,
|
|
31
|
+
slug,
|
|
32
|
+
sortBy = "title_desc",
|
|
33
|
+
}: Props): Category[] | null {
|
|
34
|
+
const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
|
|
35
|
+
|
|
36
|
+
if (!categories?.length) return null;
|
|
37
|
+
|
|
38
|
+
if (slug) {
|
|
39
|
+
return categories.filter((c) => c.slug === slug);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sortedCategories = categories.sort((a, b) => {
|
|
43
|
+
const comparison = a.name.localeCompare(b.name);
|
|
44
|
+
return sortBy.endsWith("_desc") ? comparison : -comparison;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return count ? sortedCategories.slice(0, count) : sortedCategories;
|
|
48
|
+
}
|
package/src/mod.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog app module — standard autoconfig contract.
|
|
3
|
+
*
|
|
4
|
+
* Exports `configure` following the AppModContract pattern.
|
|
5
|
+
* Provides blog post, category, and author loaders for the site.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AppDefinition,
|
|
10
|
+
ResolveSecretFn,
|
|
11
|
+
} from "@decocms/apps-commerce/app-types";
|
|
12
|
+
import manifest from "./manifest.gen";
|
|
13
|
+
|
|
14
|
+
// -------------------------------------------------------------------------
|
|
15
|
+
// CMS Props
|
|
16
|
+
// -------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** @title Deco Blog */
|
|
19
|
+
export interface Props {
|
|
20
|
+
/**
|
|
21
|
+
* @title Page Slug
|
|
22
|
+
* @description The slug of the BlogPostPage to embed. Use :category and :slug.
|
|
23
|
+
*/
|
|
24
|
+
pageSlug?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BlogState = Props;
|
|
28
|
+
|
|
29
|
+
// -------------------------------------------------------------------------
|
|
30
|
+
// Configure
|
|
31
|
+
// -------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Configure the Blog app from CMS block data.
|
|
35
|
+
* Always returns an AppDefinition (no required fields).
|
|
36
|
+
*/
|
|
37
|
+
export async function configure(
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: block data comes from CMS with no fixed schema
|
|
39
|
+
_block: any,
|
|
40
|
+
_resolveSecret: ResolveSecretFn,
|
|
41
|
+
): Promise<AppDefinition<BlogState>> {
|
|
42
|
+
return {
|
|
43
|
+
name: "blog",
|
|
44
|
+
manifest,
|
|
45
|
+
state: { pageSlug: _block?.pageSlug },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Placeholder preview for CMS editor. */
|
|
50
|
+
export const preview = undefined;
|
|
51
|
+
|
|
52
|
+
/** Default export for schema generation and Deno-style app bridges. */
|
|
53
|
+
export default function Blog(state: Props) {
|
|
54
|
+
return { state };
|
|
55
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppRegistryEntry } from "@decocms/apps-commerce/registry";
|
|
2
|
+
|
|
3
|
+
export const BLOG_REGISTRY_ENTRY: AppRegistryEntry = {
|
|
4
|
+
blockKey: "deco-blog",
|
|
5
|
+
module: () => import("./mod"),
|
|
6
|
+
displayName: "Blog",
|
|
7
|
+
category: "content",
|
|
8
|
+
description: "Blog posts, categories, and authors from CMS collections",
|
|
9
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ImageWidget } from "@decocms/apps-website/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @titleBy name
|
|
5
|
+
* @widget author
|
|
6
|
+
*/
|
|
7
|
+
export interface Author {
|
|
8
|
+
name: string;
|
|
9
|
+
email: string;
|
|
10
|
+
avatar?: ImageWidget;
|
|
11
|
+
jobTitle?: string;
|
|
12
|
+
company?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Category {
|
|
16
|
+
name: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BlogPost {
|
|
21
|
+
title: string;
|
|
22
|
+
excerpt: string;
|
|
23
|
+
/**
|
|
24
|
+
* @title Main image
|
|
25
|
+
*/
|
|
26
|
+
image?: ImageWidget;
|
|
27
|
+
/**
|
|
28
|
+
* @title Alt text for the image
|
|
29
|
+
*/
|
|
30
|
+
alt?: string;
|
|
31
|
+
/**
|
|
32
|
+
* @widget blog
|
|
33
|
+
* @collection authors
|
|
34
|
+
*/
|
|
35
|
+
authors?: Author[];
|
|
36
|
+
/**
|
|
37
|
+
* @widget blog
|
|
38
|
+
* @collection categories
|
|
39
|
+
*/
|
|
40
|
+
categories?: Category[];
|
|
41
|
+
/**
|
|
42
|
+
* @format date
|
|
43
|
+
*/
|
|
44
|
+
date: string;
|
|
45
|
+
slug: string;
|
|
46
|
+
/**
|
|
47
|
+
* @title Post Content
|
|
48
|
+
* @format rich-text
|
|
49
|
+
*/
|
|
50
|
+
content?: string;
|
|
51
|
+
/**
|
|
52
|
+
* @title Sections
|
|
53
|
+
* @label hidden
|
|
54
|
+
* @changeable true
|
|
55
|
+
*/
|
|
56
|
+
sections?: unknown[];
|
|
57
|
+
/**
|
|
58
|
+
* @title SEO
|
|
59
|
+
*/
|
|
60
|
+
seo?: Seo;
|
|
61
|
+
/**
|
|
62
|
+
* @title ReadTime in minutes
|
|
63
|
+
*/
|
|
64
|
+
readTime?: number;
|
|
65
|
+
/**
|
|
66
|
+
* @title Extra Props
|
|
67
|
+
*/
|
|
68
|
+
extraProps?: ExtraProps[];
|
|
69
|
+
id?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ExtraProps {
|
|
73
|
+
key: string;
|
|
74
|
+
value: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Seo {
|
|
78
|
+
title?: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
image?: ImageWidget;
|
|
81
|
+
canonical?: string;
|
|
82
|
+
noIndexing?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BlogPostPage {
|
|
86
|
+
"@type": "BlogPostPage";
|
|
87
|
+
post: BlogPost;
|
|
88
|
+
seo?: Seo | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type SortBy = "date_desc" | "date_asc" | "title_asc" | "title_desc";
|
|
92
|
+
|
|
93
|
+
export interface PageInfo {
|
|
94
|
+
nextPage?: string;
|
|
95
|
+
previousPage?: string;
|
|
96
|
+
currentPage: number;
|
|
97
|
+
records?: number;
|
|
98
|
+
recordPerPage?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface BlogPostListingPage {
|
|
102
|
+
posts: BlogPost[];
|
|
103
|
+
pageInfo: PageInfo;
|
|
104
|
+
seo: Seo;
|
|
105
|
+
}
|