@decocms/apps-blog 8.0.0 → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +21 -0
  2. package/package.json +14 -5
  3. package/src/README.md +69 -0
  4. package/src/__tests__/blogRecords.test.ts +270 -0
  5. package/src/__tests__/categoryTree.test.ts +209 -0
  6. package/src/__tests__/date.test.ts +134 -0
  7. package/src/__tests__/handlePosts.test.ts +256 -230
  8. package/src/__tests__/loaders.test.ts +198 -154
  9. package/src/__tests__/mod.test.ts +6 -6
  10. package/src/__tests__/publicationStatus.test.ts +176 -0
  11. package/src/__tests__/records.test.ts +68 -68
  12. package/src/__tests__/seoSections.test.ts +245 -0
  13. package/src/actions/submitRating.ts +36 -0
  14. package/src/actions/submitReview.ts +95 -0
  15. package/src/actions/submitView.ts +28 -0
  16. package/src/client.ts +70 -0
  17. package/src/core/blogRecords.ts +94 -0
  18. package/src/core/categoryTree.ts +210 -0
  19. package/src/core/handlePosts.ts +160 -64
  20. package/src/core/records.ts +147 -12
  21. package/src/index.ts +48 -14
  22. package/src/loaderMap.ts +28 -24
  23. package/src/loaders/BlogPostItem.ts +15 -5
  24. package/src/loaders/BlogPostPage.ts +61 -24
  25. package/src/loaders/BlogRelatedPosts.ts +45 -45
  26. package/src/loaders/BlogpostList.ts +93 -0
  27. package/src/loaders/BlogpostListing.ts +151 -85
  28. package/src/loaders/GetCategories.ts +51 -28
  29. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  30. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  31. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  32. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  33. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  34. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  35. package/src/loaders/extensions/types.ts +15 -0
  36. package/src/manifest.gen.ts +82 -14
  37. package/src/mod.ts +73 -20
  38. package/src/registry.ts +5 -5
  39. package/src/sections/Seo/SeoBlogPost.tsx +77 -0
  40. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  41. package/src/sections/Template.tsx +106 -0
  42. package/src/sections/blocks/BlockImage.tsx +42 -0
  43. package/src/sections/blocks/Callout.tsx +64 -0
  44. package/src/sections/blocks/CardGroup.tsx +60 -0
  45. package/src/sections/blocks/Checklist.tsx +58 -0
  46. package/src/sections/blocks/Code.tsx +29 -0
  47. package/src/sections/blocks/Comparison.tsx +77 -0
  48. package/src/sections/blocks/Cta.tsx +22 -0
  49. package/src/sections/blocks/Divider.tsx +3 -0
  50. package/src/sections/blocks/Heading.tsx +28 -0
  51. package/src/sections/blocks/List.tsx +39 -0
  52. package/src/sections/blocks/Paragraph.tsx +15 -0
  53. package/src/sections/blocks/Quote.tsx +32 -0
  54. package/src/sections/blocks/Stat.tsx +23 -0
  55. package/src/sections/blocks/StatGroup.tsx +38 -0
  56. package/src/sections/blocks/Steps.tsx +64 -0
  57. package/src/sections/blocks/Table.tsx +80 -0
  58. package/src/sections/blocks/Video.tsx +39 -0
  59. package/src/static/css.ts +178 -0
  60. package/src/types.ts +333 -77
  61. package/src/utils/blocksToSections.ts +175 -0
  62. package/src/utils/constants.ts +1 -0
  63. package/src/utils/date.ts +95 -0
  64. package/src/utils/hardSanitize.ts +106 -0
  65. package/src/utils/jsonLD.ts +118 -0
  66. package/src/utils/sanitizeHtml.ts +37 -0
@@ -1,4 +1,7 @@
1
1
  import { loadBlocks } from "@decocms/blocks/cms";
2
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
3
+ import type { BlogPost, Ignore, Rating, Review } from "../types";
4
+ import { getBlogRecordsAdapter } from "./blogRecords";
2
5
 
3
6
  /**
4
7
  * Retrieve records from CMS blocks by path prefix.
@@ -10,21 +13,153 @@ import { loadBlocks } from "@decocms/blocks/cms";
10
13
  * `loadBlocks()` from `@decocms/blocks/cms` instead of `ctx.get(resolvables)`.
11
14
  */
12
15
  export function getRecordsByPath<T>(path: string, accessor: string): T[] {
13
- const blocks = loadBlocks() as Record<string, Record<string, unknown>>;
14
- const results: T[] = [];
16
+ const blocks = loadBlocks() as Record<string, Record<string, unknown>>;
17
+ const results: T[] = [];
15
18
 
16
- for (const [key, value] of Object.entries(blocks)) {
17
- if (!key.startsWith(path) || !value || typeof value !== "object") {
18
- continue;
19
- }
19
+ for (const [key, value] of Object.entries(blocks)) {
20
+ if (!key.startsWith(path) || !value || typeof value !== "object") {
21
+ continue;
22
+ }
20
23
 
21
- const record = value[accessor] as T | undefined;
22
- if (!record) continue;
24
+ const record = value[accessor] as T | undefined;
25
+ if (!record) continue;
23
26
 
24
- const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", "");
27
+ const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", "");
25
28
 
26
- results.push({ ...record, id } as T);
27
- }
29
+ results.push({ ...record, id } as T);
30
+ }
28
31
 
29
- return results;
32
+ return results;
33
+ }
34
+
35
+ const report = (scope: string, e: unknown) => {
36
+ const error = serializeError(e);
37
+ logger.error(error.message, { error, scope });
38
+ };
39
+
40
+ /**
41
+ * Ratings for one post, normalised with the rating scale.
42
+ *
43
+ * `bestRating`/`worstRating` are stamped here rather than stored, matching
44
+ * upstream: the scale is a property of this app's UI, not of the record.
45
+ */
46
+ export async function getRatingsBySlug({ slug }: { slug: string }): Promise<Rating[]> {
47
+ try {
48
+ const currentRatings = await getBlogRecordsAdapter()?.listRatings?.(slug);
49
+
50
+ return (currentRatings ?? []).map((rating) => ({
51
+ ...rating,
52
+ bestRating: 5,
53
+ worstRating: 1,
54
+ }));
55
+ } catch (e) {
56
+ report("blog/getRatingsBySlug", e);
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /** Returns the post enriched with its ratings and their aggregate. */
62
+ export async function getRatings({
63
+ post,
64
+ ignoreRatings,
65
+ onlyAggregate,
66
+ }: {
67
+ post: BlogPost;
68
+ ignoreRatings?: Ignore;
69
+ onlyAggregate?: boolean;
70
+ }): Promise<BlogPost> {
71
+ const contentRating = await getRatingsBySlug({ slug: post.slug });
72
+
73
+ const { ratingCount, ratingTotal } =
74
+ contentRating.length === 0
75
+ ? { ratingCount: 0, ratingTotal: 0 }
76
+ : contentRating.reduce(
77
+ (acc, { ratingValue, additionalType }) =>
78
+ ignoreRatings?.active &&
79
+ additionalType &&
80
+ ignoreRatings.markedAs?.includes(additionalType)
81
+ ? acc
82
+ : {
83
+ ratingCount: acc.ratingCount + 1,
84
+ ratingTotal: acc.ratingTotal + (ratingValue ?? 0),
85
+ },
86
+ { ratingCount: 0, ratingTotal: 0 },
87
+ );
88
+
89
+ const ratingValue = ratingTotal / ratingCount;
90
+
91
+ return {
92
+ ...post,
93
+ contentRating: onlyAggregate ? undefined : contentRating,
94
+ aggregateRating: {
95
+ ...post.aggregateRating,
96
+ "@type": "AggregateRating",
97
+ ratingCount,
98
+ ratingValue: Number.isNaN(ratingValue) ? 0 : ratingValue,
99
+ bestRating: 5,
100
+ worstRating: 1,
101
+ },
102
+ };
103
+ }
104
+
105
+ export const getReviewById = async ({ id }: { id?: string }): Promise<Review | null> => {
106
+ if (!id) {
107
+ return null;
108
+ }
109
+ try {
110
+ return (await getBlogRecordsAdapter()?.getReview?.(id)) ?? null;
111
+ } catch (e) {
112
+ report("blog/getReviewById", e);
113
+ return null;
114
+ }
115
+ };
116
+
117
+ export async function getReviewsBySlug({
118
+ slug,
119
+ ignoreReviews,
120
+ orderBy = "date_desc",
121
+ }: {
122
+ slug: string;
123
+ ignoreReviews?: Ignore;
124
+ orderBy?: "date_asc" | "date_desc";
125
+ }): Promise<Review[]> {
126
+ const excludeAdditionalTypes =
127
+ ignoreReviews?.active && ignoreReviews.markedAs && ignoreReviews.markedAs.length > 0
128
+ ? ignoreReviews.markedAs
129
+ : undefined;
130
+
131
+ try {
132
+ const currentComments = await getBlogRecordsAdapter()?.listReviews?.({
133
+ slug,
134
+ excludeAdditionalTypes,
135
+ orderBy,
136
+ });
137
+
138
+ return currentComments ?? [];
139
+ } catch (e) {
140
+ report("blog/getReviewsBySlug", e);
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /** Returns the post enriched with its reviews and their count. */
146
+ export async function getReviews({
147
+ post,
148
+ ...rest
149
+ }: {
150
+ post: BlogPost;
151
+ ignoreReviews?: Ignore;
152
+ orderBy?: "date_asc" | "date_desc";
153
+ }): Promise<BlogPost> {
154
+ const review = await getReviewsBySlug({ slug: post.slug, ...rest });
155
+
156
+ return {
157
+ ...post,
158
+ review,
159
+ aggregateRating: {
160
+ ...post.aggregateRating,
161
+ "@type": "AggregateRating",
162
+ reviewCount: review.length,
163
+ },
164
+ };
30
165
  }
package/src/index.ts CHANGED
@@ -1,24 +1,58 @@
1
1
  /**
2
2
  * Public API for the blog app.
3
+ *
4
+ * Subpath imports cover the rest of the surface:
5
+ * `@decocms/apps-blog/loaders/*`, `/actions/*`, `/sections/*`,
6
+ * `/sections/Seo/*`, `/sections/blocks/*`, `/utils/*`, `/core/*`,
7
+ * `/mod`, `/registry`, `/types`.
3
8
  */
4
9
 
10
+ export { type BlogConfig, type BlogSeoDefaults, configureBlog, getBlogConfig } from "./client";
11
+ export {
12
+ type BlogRecordsAdapter,
13
+ type CreateReviewInput,
14
+ getBlogRecordsAdapter,
15
+ type ListReviewsOptions,
16
+ setBlogRecordsAdapter,
17
+ type UpdateReviewInput,
18
+ type UpsertRatingInput,
19
+ } from "./core/blogRecords";
5
20
  export { getRecordsByPath } from "./core/records";
6
- /** @deprecated Use `createBlogLoaders` instead. */
7
21
  export {
8
- createBlogLoaders,
9
- createBlogLoaders as createBlogCommerceLoaders,
22
+ createBlogLoaders,
23
+ /** @deprecated Use `createBlogLoaders` instead. */
24
+ createBlogLoaders as createBlogCommerceLoaders,
10
25
  } from "./loaderMap";
11
- export { configure } from "./mod";
12
-
26
+ export { configure, handlers } from "./mod";
13
27
  // Types
14
28
  export type {
15
- Author,
16
- BlogPost,
17
- BlogPostListingPage,
18
- BlogPostPage,
19
- Category,
20
- ExtraProps,
21
- PageInfo,
22
- Seo,
23
- SortBy,
29
+ AggregateRating,
30
+ Author,
31
+ Banner,
32
+ BannerItem,
33
+ BlogPost,
34
+ BlogPostListingPage,
35
+ BlogPostPage,
36
+ Category,
37
+ ExtraProps,
38
+ Ignore,
39
+ ImageCarousel,
40
+ InteractionCounter,
41
+ PageInfo,
42
+ PostStatus,
43
+ Publisher,
44
+ Rating,
45
+ Review,
46
+ Section,
47
+ Seo,
48
+ SortBy,
49
+ ViewFromDatabase,
24
50
  } from "./types";
51
+ // Publication-status predicates — runtime exports, not just types: a site that
52
+ // renders its own listing must apply the same liveness rule the loaders do.
53
+ export { isLivePost, isPublishedStatus } from "./types";
54
+ export { blocksToSections } from "./utils/blocksToSections";
55
+ export { dateToTime, scheduledTime } from "./utils/date";
56
+ export { hardSanitize } from "./utils/hardSanitize";
57
+ export { toBlogPosting, toBreadcrumbList, toOrganization, withCanonicalBase } from "./utils/jsonLD";
58
+ export { sanitizeHref, sanitizeHtml } from "./utils/sanitizeHtml";
package/src/loaderMap.ts CHANGED
@@ -9,20 +9,24 @@ import AuthorLoader from "./loaders/Author";
9
9
  import BlogPostItemLoader from "./loaders/BlogPostItem";
10
10
  import BlogPostPageLoader from "./loaders/BlogPostPage";
11
11
  import BlogpostLoader from "./loaders/Blogpost";
12
+ import BlogpostListLoader from "./loaders/BlogpostList";
12
13
  import BlogpostListingLoader from "./loaders/BlogpostListing";
13
14
  import BlogRelatedPostsLoader from "./loaders/BlogRelatedPosts";
14
15
  import CategoryLoader from "./loaders/Category";
15
16
  import GetCategoriesLoader from "./loaders/GetCategories";
16
17
 
17
- // biome-ignore lint/suspicious/noExplicitAny: loader props/returns vary per block
18
18
  export type LoaderFn = (props: any, request?: Request) => Promise<any> | any;
19
19
 
20
20
  /**
21
21
  * Create the blog loader map.
22
22
  *
23
+ * Each loader is registered under both key forms the resolver may see: the
24
+ * Deno app's `__resolveType` paths carry a `.ts` suffix, while manifest-derived
25
+ * keys do not.
26
+ *
23
27
  * @example
24
28
  * ```ts
25
- * import { createBlogLoaders } from "@decocms/apps/blog";
29
+ * import { createBlogLoaders } from "@decocms/apps-blog";
26
30
  *
27
31
  * const COMMERCE_LOADERS = {
28
32
  * ...createVtexCommerceLoaders(),
@@ -31,27 +35,27 @@ export type LoaderFn = (props: any, request?: Request) => Promise<any> | any;
31
35
  * ```
32
36
  */
33
37
  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,
38
+ const byName: Record<string, LoaderFn> = {
39
+ "blog/loaders/BlogPostPage": BlogPostPageLoader,
40
+ "blog/loaders/BlogpostListing": BlogpostListingLoader,
41
+ // A distinct loader from BlogpostListing: it takes `postSlugs` and returns
42
+ // a flat BlogPost[] rather than a BlogPostListingPage. It used to be
43
+ // aliased to the listing loader here, which silently dropped `postSlugs`
44
+ // and handed callers the wrong shape.
45
+ "blog/loaders/BlogpostList": BlogpostListLoader,
46
+ "blog/loaders/BlogRelatedPosts": BlogRelatedPostsLoader,
47
+ "blog/loaders/GetCategories": GetCategoriesLoader,
48
+ "blog/loaders/Blogpost": BlogpostLoader,
49
+ "blog/loaders/Category": CategoryLoader,
50
+ "blog/loaders/Author": AuthorLoader,
51
+ // BlogPostItem: looks up a single post by slug, returns BlogPost
52
+ "blog/loaders/BlogPostItem": BlogPostItemLoader,
53
+ };
50
54
 
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
- };
55
+ return Object.fromEntries(
56
+ Object.entries(byName).flatMap(([key, fn]) => [
57
+ [key, fn],
58
+ [`${key}.ts`, fn],
59
+ ]),
60
+ );
57
61
  }
@@ -1,8 +1,9 @@
1
1
  import { getRecordsByPath } from "../core/records";
2
2
  import type { BlogPost } from "../types";
3
+ import { isLivePost } from "../types";
3
4
 
4
5
  export interface Props {
5
- slug: string;
6
+ slug: string;
6
7
  }
7
8
 
8
9
  /**
@@ -11,9 +12,18 @@ export interface Props {
11
12
  * directly (not wrapped in BlogPostPage).
12
13
  */
13
14
  export default function BlogPostItem(props: Props & { __pageUrl?: string }): BlogPost | null {
14
- const { slug } = props;
15
- if (!slug) return null;
15
+ const { slug } = props;
16
+ if (!slug) return null;
16
17
 
17
- const posts = getRecordsByPath<BlogPost>("collections/blog/posts", "post");
18
- return posts.find((p) => p?.slug === slug) ?? null;
18
+ const posts = getRecordsByPath<BlogPost>("collections/blog/posts", "post");
19
+ const post = posts.find((p) => p?.slug === slug);
20
+
21
+ if (!post) return null;
22
+
23
+ // A post that isn't live yet — unpublished, or scheduled for an instant still
24
+ // ahead — is still served, because that page *is* the CMS preview. It just
25
+ // must never be indexed. Everything else the post declared under `seo` is
26
+ // kept as-is, and a scheduled post becomes indexable on its own once its
27
+ // instant passes.
28
+ return isLivePost(post) ? post : { ...post, seo: { ...post.seo, noIndexing: true } };
19
29
  }
@@ -1,11 +1,16 @@
1
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
2
+ import { ancestorsOf, indexCategories, withCategoryPath } from "../core/categoryTree";
1
3
  import { getRecordsByPath } from "../core/records";
2
- import type { BlogPost, BlogPostPage } from "../types";
4
+ import type { BlogPost, BlogPostPage, Category } from "../types";
5
+ import { isLivePost } from "../types";
3
6
 
4
7
  const COLLECTION_PATH = "collections/blog/posts";
5
8
  const ACCESSOR = "post";
9
+ const CATEGORIES_PATH = "collections/blog/categories";
10
+ const CATEGORY_ACCESSOR = "category";
6
11
 
7
12
  export interface Props {
8
- slug: string;
13
+ slug: string;
9
14
  }
10
15
 
11
16
  /**
@@ -13,27 +18,59 @@ export interface Props {
13
18
  * @description Fetches a specific blog post page by its slug.
14
19
  */
15
20
  export default function BlogPostPageLoader(
16
- props: Props & { __pageUrl?: string },
17
- req?: Request,
21
+ props: Props & { __pageUrl?: string },
22
+ req?: Request,
18
23
  ): 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
- };
24
+ const { slug } = props;
25
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
26
+
27
+ const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
28
+ const url = new URL(rawUrl);
29
+ const post = posts.find((p) => p?.slug === slug);
30
+
31
+ if (!post) return null;
32
+
33
+ // The post lives under the full path of its primary category, so the
34
+ // breadcrumb and the canonical need that category's ancestor chain. A broken
35
+ // categories collection must not take the post page down with it.
36
+ let categories: Category[] = [];
37
+ try {
38
+ categories = getRecordsByPath<Category>(CATEGORIES_PATH, CATEGORY_ACCESSOR) ?? [];
39
+ } catch (e) {
40
+ const error = serializeError(e);
41
+ logger.error(error.message, { error, scope: "blog/BlogPostPage/categories" });
42
+ }
43
+
44
+ const primarySlug = post.categories?.[0]?.slug;
45
+ const chain =
46
+ typeof primarySlug === "string" && primarySlug
47
+ ? ancestorsOf(primarySlug, indexCategories(categories))
48
+ : null;
49
+
50
+ // Only a chain that came out of the records is trustworthy enough to name a
51
+ // canonical URL; withCategoryPath itself returns null when the route has no
52
+ // category segment to replace, so a /blog/:slug site keeps its own URL.
53
+ const canonical = chain
54
+ ? withCategoryPath(url, chain, {
55
+ knownSlugs: new Set(categories.map((c) => c?.slug)),
56
+ trailing: post.slug,
57
+ })
58
+ : null;
59
+
60
+ return {
61
+ "@type": "BlogPostPage",
62
+ post,
63
+ categories: chain ?? (post.categories?.[0] ? [post.categories[0]] : null),
64
+ seo: {
65
+ title: post?.seo?.title || post?.title,
66
+ description: post?.seo?.description || post?.excerpt,
67
+ canonical: post?.seo?.canonical || canonical || url.href,
68
+ image: post?.seo?.image || post?.image,
69
+ // A post that isn't live yet — unpublished, or scheduled for an instant
70
+ // still ahead — renders anyway, because that page *is* the CMS preview.
71
+ // It just must never be indexed, even if the URL leaks. A scheduled post
72
+ // becomes indexable on its own once its instant passes.
73
+ noIndexing: post?.seo?.noIndexing || !isLivePost(post),
74
+ },
75
+ };
39
76
  }
@@ -6,35 +6,35 @@ const COLLECTION_PATH = "collections/blog/posts";
6
6
  const ACCESSOR = "post";
7
7
 
8
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;
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
38
  }
39
39
 
40
40
  export type BlogRelatedPosts = BlogPost[] | null;
@@ -43,24 +43,24 @@ export type BlogRelatedPosts = BlogPost[] | null;
43
43
  * @title BlogRelatedPosts
44
44
  * @description Retrieves a list of blog related posts.
45
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;
46
+ export default async function BlogRelatedPostsLoader(
47
+ props: Props & { __pageUrl?: string },
48
+ req?: Request,
49
+ ): Promise<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
57
 
58
- const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
58
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
59
59
 
60
- const handledPosts = handlePosts(posts, pageSort, slug, undefined, term, excludePostSlug);
60
+ const handledPosts = await handlePosts(posts, pageSort, slug, undefined, term, excludePostSlug);
61
61
 
62
- if (!handledPosts) return null;
62
+ if (!handledPosts) return null;
63
63
 
64
- const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
65
- return slicedPosts.length > 0 ? slicedPosts : null;
64
+ const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
65
+ return slicedPosts.length > 0 ? slicedPosts : null;
66
66
  }
@@ -0,0 +1,93 @@
1
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
2
+ import { descendantSlugs } from "../core/categoryTree";
3
+ import handlePosts, { slicePosts } from "../core/handlePosts";
4
+ import { getRecordsByPath } from "../core/records";
5
+ import type { BlogPost, Category, SortBy } from "../types";
6
+
7
+ const COLLECTION_PATH = "collections/blog/posts";
8
+ const ACCESSOR = "post";
9
+ const CATEGORIES_PATH = "collections/blog/categories";
10
+ const CATEGORY_ACCESSOR = "category";
11
+
12
+ export interface Props {
13
+ /**
14
+ * @title Items per page
15
+ * @description Number of posts per page to display.
16
+ */
17
+ count?: number;
18
+ /**
19
+ * @title Page query parameter
20
+ * @description The current page number. Defaults to 1.
21
+ */
22
+ page?: number;
23
+ /**
24
+ * @title Category Slug
25
+ * @description Filter by a category slug. May be a full path
26
+ * ("parent/child"); posts of every subcategory below the last segment are
27
+ * included.
28
+ */
29
+ slug?: string;
30
+ /**
31
+ * @title Specific post slugs
32
+ * @description Filter by specific post slugs.
33
+ */
34
+ postSlugs?: string[];
35
+ /**
36
+ * @title Page sorting parameter
37
+ * @description The sorting option. Default is "date_desc"
38
+ */
39
+ sortBy?: SortBy;
40
+ /**
41
+ * @description Overrides the query term at url
42
+ */
43
+ query?: string;
44
+ }
45
+
46
+ /**
47
+ * @title BlogPostList
48
+ * @description Retrieves a flat list of blog posts.
49
+ */
50
+ export default async function BlogPostList(
51
+ props: Props & { __pageUrl?: string },
52
+ req?: Request,
53
+ ): Promise<BlogPost[] | null> {
54
+ const { page, count, slug, sortBy, postSlugs, query } = props;
55
+ const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
56
+ const url = new URL(rawUrl);
57
+ const params = url.searchParams;
58
+ const postsPerPage = Number(count ?? params.get("count") ?? 12);
59
+ const pageNumber = Number(page ?? params.get("page") ?? 1);
60
+ const pageSort = sortBy ?? (params.get("sortBy") as SortBy) ?? "date_desc";
61
+ const term = query ?? params.get("q") ?? undefined;
62
+
63
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
64
+
65
+ try {
66
+ // A parent category also lists its descendants' posts, so the slug expands
67
+ // into the whole subtree below it.
68
+ const leafSlug = (slug ?? "").split("/").filter(Boolean).pop();
69
+ let categorySlugs: string | string[] | undefined = leafSlug;
70
+
71
+ if (leafSlug && !postSlugs?.length) {
72
+ try {
73
+ const categories = getRecordsByPath<Category>(CATEGORIES_PATH, CATEGORY_ACCESSOR);
74
+ categorySlugs = descendantSlugs(leafSlug, categories);
75
+ } catch (e) {
76
+ const error = serializeError(e);
77
+ logger.error(error.message, { error, scope: "blog/BlogpostList/categories" });
78
+ }
79
+ }
80
+
81
+ const handledPosts = await handlePosts(posts, pageSort, categorySlugs, postSlugs, term);
82
+
83
+ if (!handledPosts) return null;
84
+
85
+ const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
86
+
87
+ return slicedPosts.length > 0 ? slicedPosts : null;
88
+ } catch (e) {
89
+ const error = serializeError(e);
90
+ logger.error(error.message, { error, scope: "blog/BlogpostList" });
91
+ return null;
92
+ }
93
+ }