@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,101 +1,167 @@
1
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
2
+ import {
3
+ ancestorsOf,
4
+ descendantSlugs,
5
+ indexCategories,
6
+ withCategoryPath,
7
+ } from "../core/categoryTree";
1
8
  import handlePosts, { slicePosts } from "../core/handlePosts";
2
9
  import { getRecordsByPath } from "../core/records";
3
- import type { BlogPost, BlogPostListingPage, PageInfo, SortBy } from "../types";
10
+ import type { BlogPost, BlogPostListingPage, Category, PageInfo, SortBy } from "../types";
4
11
 
5
12
  const COLLECTION_PATH = "collections/blog/posts";
6
13
  const ACCESSOR = "post";
14
+ const CATEGORIES_PATH = "collections/blog/categories";
15
+ const CATEGORY_ACCESSOR = "category";
7
16
 
8
17
  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;
18
+ /**
19
+ * @title Category Slug
20
+ * @description Filter by a category slug. May be a full path
21
+ * ("parent/child"); posts of every subcategory below the last segment are
22
+ * included.
23
+ */
24
+ slug?: string;
25
+ /**
26
+ * @title Items per page
27
+ * @description Number of posts per page to display.
28
+ */
29
+ count?: number;
30
+ /**
31
+ * @title Page query parameter
32
+ * @description The current page number. Defaults to 1.
33
+ */
34
+ page?: number;
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;
33
44
  }
34
45
 
46
+ const report = (scope: string, e: unknown) => {
47
+ const error = serializeError(e);
48
+ logger.error(error.message, { error, scope });
49
+ };
50
+
51
+ /** Categories with a usable name and slug, alphabetically. */
52
+ const loadCategories = (): Category[] =>
53
+ getRecordsByPath<Category>(CATEGORIES_PATH, CATEGORY_ACCESSOR)
54
+ .filter(
55
+ (c) =>
56
+ typeof c?.name === "string" &&
57
+ c.name.length > 0 &&
58
+ typeof c?.slug === "string" &&
59
+ c.slug.length > 0,
60
+ )
61
+ .sort((a, b) => a.name.localeCompare(b.name));
62
+
35
63
  /**
36
- * @title BlogPostList
37
- * @description Retrieves a paginated list of blog posts.
64
+ * @title BlogPostListing
65
+ * @description Retrieves a paginated listing page of blog posts.
38
66
  */
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
- }
67
+ export default async function BlogPostListing(
68
+ props: Props & { __pageUrl?: string },
69
+ req?: Request,
70
+ ): Promise<BlogPostListingPage | null> {
71
+ const { page, count, slug, sortBy, query } = props;
72
+ const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
73
+ const url = new URL(rawUrl);
74
+ const params = url.searchParams;
75
+ const postsPerPage = Number(count ?? params.get("count") ?? 12);
76
+ const pageNumber = Number(page ?? params.get("page") ?? 1);
77
+ const pageSort = sortBy ?? (params.get("sortBy") as SortBy) ?? "date_desc";
78
+ const term = query ?? params.get("q") ?? undefined;
79
+
80
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
81
+
82
+ try {
83
+ let categories: Category[] | null = null;
84
+ try {
85
+ categories = loadCategories();
86
+ } catch (e) {
87
+ report("blog/BlogpostListing/categories", e);
88
+ }
89
+
90
+ // The slug prop carries the whole category path ("pai/filho") when the
91
+ // site routes the listing as a catch-all; only the leaf identifies the
92
+ // category, the segments before it are its ancestors.
93
+ const requestedSegments = (slug ?? "").split("/").filter(Boolean);
94
+ const leafSlug = requestedSegments[requestedSegments.length - 1];
95
+
96
+ const index = indexCategories(categories);
97
+ const chain = leafSlug ? ancestorsOf(leafSlug, index) : null;
98
+
99
+ // A parent lists its own posts plus every descendant's.
100
+ const categorySlugs = leafSlug ? descendantSlugs(leafSlug, categories) : undefined;
101
+
102
+ const handledPosts = await handlePosts(posts, pageSort, categorySlugs, undefined, term);
103
+
104
+ if (!handledPosts) return null;
105
+
106
+ const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
107
+ if (slicedPosts.length === 0) return null;
108
+
109
+ let category: Category | null = null;
110
+ if (leafSlug) {
111
+ // Prefer the standalone category record (it carries description and
112
+ // sections); fall back to the denormalized copy embedded in a post,
113
+ // which is all a site that never created category records will have.
114
+ category =
115
+ index.get(leafSlug) ??
116
+ slicedPosts[0]?.categories?.find((c) => c?.slug === leafSlug) ??
117
+ null;
118
+ }
119
+
120
+ // Only a chain that came out of the records is trustworthy enough to name
121
+ // a canonical URL; a missing or broken one keeps the flat behaviour.
122
+ const canonical = chain ? withCategoryPath(url, chain, { requested: requestedSegments }) : null;
123
+
124
+ return {
125
+ posts: slicedPosts,
126
+ category,
127
+ categories,
128
+ categoryPath: chain ?? (category ? [category] : null),
129
+ pageInfo: toPageInfo(handledPosts, postsPerPage, pageNumber, params),
130
+ seo: {
131
+ title: category?.name ?? "",
132
+ description: category?.description,
133
+ // Reached through a stale or wrong path, the page still renders and
134
+ // points at the one canonical URL instead of 404ing.
135
+ canonical: canonical ?? new URL(url.pathname, url.origin).href,
136
+ },
137
+ };
138
+ } catch (e) {
139
+ report("blog/BlogpostListing", e);
140
+ return null;
141
+ }
76
142
  }
77
143
 
78
144
  function toPageInfo(
79
- posts: BlogPost[],
80
- postsPerPage: number,
81
- pageNumber: number,
82
- params: URLSearchParams,
145
+ posts: BlogPost[],
146
+ postsPerPage: number,
147
+ pageNumber: number,
148
+ params: URLSearchParams,
83
149
  ): 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
- };
150
+ const totalPosts = posts.length;
151
+ const totalPages = Math.ceil(totalPosts / postsPerPage);
152
+ const hasNextPage = totalPages > pageNumber;
153
+ const hasPrevPage = pageNumber > 1;
154
+ const nextPage = new URLSearchParams(params);
155
+ const previousPage = new URLSearchParams(params);
156
+
157
+ if (hasNextPage) nextPage.set("page", (pageNumber + 1).toString());
158
+ if (hasPrevPage) previousPage.set("page", (pageNumber - 1).toString());
159
+
160
+ return {
161
+ nextPage: hasNextPage ? `?${nextPage}` : undefined,
162
+ previousPage: hasPrevPage ? `?${previousPage}` : undefined,
163
+ currentPage: pageNumber,
164
+ records: totalPosts,
165
+ recordPerPage: postsPerPage,
166
+ };
101
167
  }
@@ -1,3 +1,4 @@
1
+ import { childrenOf } from "../core/categoryTree";
1
2
  import { getRecordsByPath } from "../core/records";
2
3
  import type { Category } from "../types";
3
4
 
@@ -5,21 +6,26 @@ const COLLECTION_PATH = "collections/blog/categories";
5
6
  const ACCESSOR = "category";
6
7
 
7
8
  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";
9
+ /**
10
+ * @title Category Slug
11
+ * @description Get the category data from a specific slug.
12
+ */
13
+ slug?: string;
14
+ /**
15
+ * @title Parent category slug
16
+ * @description Return only the direct children of this category.
17
+ */
18
+ parentSlug?: string;
19
+ /**
20
+ * @title Items count
21
+ * @description Number of categories to return
22
+ */
23
+ count?: number;
24
+ /**
25
+ * @title Sort
26
+ * @description The sorting option. Default is "title_desc"
27
+ */
28
+ sortBy?: "title_asc" | "title_desc";
23
29
  }
24
30
 
25
31
  /**
@@ -27,22 +33,39 @@ export interface Props {
27
33
  * @description Retrieves a list of blog categories.
28
34
  */
29
35
  export default function GetCategories({
30
- count,
31
- slug,
32
- sortBy = "title_desc",
36
+ count,
37
+ slug,
38
+ parentSlug,
39
+ sortBy = "title_desc",
33
40
  }: Props): Category[] | null {
34
- const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
41
+ const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
35
42
 
36
- if (!categories?.length) return null;
43
+ if (!categories?.length) return null;
37
44
 
38
- if (slug) {
39
- return categories.filter((c) => c.slug === slug);
40
- }
45
+ // Records come straight from the CMS, so name/slug are strings only by
46
+ // convention. A record missing either has no route and no label, so it would
47
+ // render as a blank chip linking nowhere — and would throw in the
48
+ // `localeCompare` sort below.
49
+ const validCategories = categories.filter(
50
+ (c) =>
51
+ typeof c?.name === "string" &&
52
+ c.name.length > 0 &&
53
+ typeof c?.slug === "string" &&
54
+ c.slug.length > 0,
55
+ );
41
56
 
42
- const sortedCategories = categories.sort((a, b) => {
43
- const comparison = a.name.localeCompare(b.name);
44
- return sortBy.endsWith("_desc") ? comparison : -comparison;
45
- });
57
+ if (slug) {
58
+ return validCategories.filter((c) => c.slug === slug);
59
+ }
46
60
 
47
- return count ? sortedCategories.slice(0, count) : sortedCategories;
61
+ if (!validCategories.length) return null;
62
+
63
+ const scoped = parentSlug ? childrenOf(parentSlug, validCategories) : validCategories;
64
+
65
+ const sortedCategories = scoped.sort((a, b) => {
66
+ const comparison = a.name.localeCompare(b.name);
67
+ return sortBy.endsWith("_desc") ? comparison : -comparison;
68
+ });
69
+
70
+ return count ? sortedCategories.slice(0, count) : sortedCategories;
48
71
  }
@@ -0,0 +1,36 @@
1
+ import { getRatings } from "../../../core/records";
2
+ import type { BlogPost, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore ratings in the aggregateRating calc
8
+ */
9
+ ignoreRatings?: Ignore;
10
+ /**
11
+ * @description Return only aggregate rating object
12
+ */
13
+ onlyAggregate?: boolean;
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPost list: Ratings
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function ratingsExt({
21
+ ignoreRatings,
22
+ onlyAggregate,
23
+ }: Props): ExtensionOf<BlogPost[] | null> {
24
+ return async (posts: BlogPost[] | null) => {
25
+ if (!posts || posts.length === 0) {
26
+ return null;
27
+ }
28
+
29
+ return Promise.all(
30
+ posts.map(async (post) => {
31
+ const ratings = await getRatings({ post, ignoreRatings, onlyAggregate });
32
+ return { ...post, ...ratings };
33
+ }),
34
+ );
35
+ };
36
+ }
@@ -0,0 +1,36 @@
1
+ import { getReviews } from "../../../core/records";
2
+ import type { BlogPost, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore specific reviews
8
+ */
9
+ ignoreReviews?: Ignore;
10
+ /**
11
+ * @description Order By
12
+ */
13
+ orderBy?: "date_asc" | "date_desc";
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPost list: Reviews
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function reviewsExt({
21
+ ignoreReviews,
22
+ orderBy,
23
+ }: Props): ExtensionOf<BlogPost[] | null> {
24
+ return async (posts: BlogPost[] | null) => {
25
+ if (!posts || posts.length === 0) {
26
+ return null;
27
+ }
28
+
29
+ return Promise.all(
30
+ posts.map(async (post) => {
31
+ const reviews = await getReviews({ post, ignoreReviews, orderBy });
32
+ return { ...post, ...reviews };
33
+ }),
34
+ );
35
+ };
36
+ }
@@ -0,0 +1,38 @@
1
+ import { getRatings } from "../../../core/records";
2
+ import type { BlogPostListingPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore ratings in the aggregateRating calc
8
+ */
9
+ ignoreRatings?: Ignore;
10
+ /**
11
+ * @description Return only aggregate rating object
12
+ */
13
+ onlyAggregate?: boolean;
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPostListing: Ratings
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function ratingsExt({
21
+ ignoreRatings,
22
+ onlyAggregate,
23
+ }: Props): ExtensionOf<BlogPostListingPage | null> {
24
+ return async (blogpostListingPage: BlogPostListingPage | null) => {
25
+ if (!blogpostListingPage) {
26
+ return null;
27
+ }
28
+
29
+ const posts = await Promise.all(
30
+ blogpostListingPage.posts.map(async (post) => {
31
+ const ratings = await getRatings({ post, onlyAggregate, ignoreRatings });
32
+ return { ...post, ...ratings };
33
+ }),
34
+ );
35
+
36
+ return { ...blogpostListingPage, posts };
37
+ };
38
+ }
@@ -0,0 +1,38 @@
1
+ import { getReviews } from "../../../core/records";
2
+ import type { BlogPostListingPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore specific reviews
8
+ */
9
+ ignoreReviews?: Ignore;
10
+ /**
11
+ * @description Order By
12
+ */
13
+ orderBy?: "date_asc" | "date_desc";
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPostListing: Reviews
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function reviewsExt({
21
+ ignoreReviews,
22
+ orderBy,
23
+ }: Props): ExtensionOf<BlogPostListingPage | null> {
24
+ return async (blogpostListingPage: BlogPostListingPage | null) => {
25
+ if (!blogpostListingPage) {
26
+ return null;
27
+ }
28
+
29
+ const posts = await Promise.all(
30
+ blogpostListingPage.posts.map(async (post) => {
31
+ const reviews = await getReviews({ post, ignoreReviews, orderBy });
32
+ return { ...post, ...reviews };
33
+ }),
34
+ );
35
+
36
+ return { ...blogpostListingPage, posts };
37
+ };
38
+ }
@@ -0,0 +1,24 @@
1
+ import { getRatings } from "../../../core/records";
2
+ import type { BlogPostPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore ratings in the aggregateRating calc
8
+ */
9
+ ignoreRatings?: Ignore;
10
+ }
11
+
12
+ /**
13
+ * @title ExtensionOf BlogPostPage: Ratings
14
+ * @description It can harm performance. Use wisely
15
+ */
16
+ export default function ratingsExt({ ignoreRatings }: Props): ExtensionOf<BlogPostPage | null> {
17
+ return async (blogpostPage: BlogPostPage | null) => {
18
+ if (!blogpostPage) {
19
+ return null;
20
+ }
21
+ const post = await getRatings({ post: blogpostPage.post, ignoreRatings });
22
+ return { ...blogpostPage, post };
23
+ };
24
+ }
@@ -0,0 +1,31 @@
1
+ import { getReviews } from "../../../core/records";
2
+ import type { BlogPostPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore specific reviews
8
+ */
9
+ ignoreReviews?: Ignore;
10
+ /**
11
+ * @description Order By
12
+ */
13
+ orderBy?: "date_asc" | "date_desc";
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPostPage: Reviews
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function reviewsExt({
21
+ ignoreReviews,
22
+ orderBy,
23
+ }: Props): ExtensionOf<BlogPostPage | null> {
24
+ return async (blogpostPage: BlogPostPage | null) => {
25
+ if (!blogpostPage) {
26
+ return null;
27
+ }
28
+ const post = await getReviews({ post: blogpostPage.post, ignoreReviews, orderBy });
29
+ return { ...blogpostPage, post };
30
+ };
31
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * An enrichment step applied to a loader's already-resolved output.
3
+ *
4
+ * In `deco-cx/apps` this type comes from `website/loaders/extension.ts`, which
5
+ * also ships the generic `extension` loader that composes a list of these over
6
+ * one data source. That loader has not been ported into
7
+ * `@decocms/apps-website`, so the type is declared here rather than imported —
8
+ * the *composition* plumbing is a website-app concern and porting it into
9
+ * apps-blog would put it in the wrong package (see this package's README for
10
+ * the tracked gap).
11
+ *
12
+ * Each extension below is therefore usable directly: call it to obtain the
13
+ * enrichment function, then apply that to the loader result.
14
+ */
15
+ export type ExtensionOf<T> = (data: T) => Promise<T>;