@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 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";
@@ -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,9 @@
1
+ import type { Author } from "../types";
2
+
3
+ /**
4
+ * @title Author
5
+ * @description Defines a blog post author.
6
+ */
7
+ const loader = ({ author }: { author: Author }): Author => author;
8
+
9
+ export default loader;
@@ -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,9 @@
1
+ import type { BlogPost } from "../types";
2
+
3
+ /**
4
+ * @title Blogpost
5
+ * @description Defines a blog post.
6
+ */
7
+ const loader = ({ post }: { post: BlogPost }): BlogPost => post;
8
+
9
+ export default loader;
@@ -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,9 @@
1
+ import type { Category } from "../types";
2
+
3
+ /**
4
+ * @title Category
5
+ * @description Defines a blog post category.
6
+ */
7
+ const loader = ({ category }: { category: Category }): Category => category;
8
+
9
+ export default loader;
@@ -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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist"
5
+ },
6
+ "include": ["src/**/*"]
7
+ }