@decocms/apps-blog 7.52.1 → 7.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +13 -4
  2. package/src/README.md +69 -0
  3. package/src/__tests__/blogRecords.test.ts +270 -0
  4. package/src/__tests__/date.test.ts +134 -0
  5. package/src/__tests__/handlePosts.test.ts +230 -230
  6. package/src/__tests__/loaders.test.ts +154 -154
  7. package/src/__tests__/mod.test.ts +6 -6
  8. package/src/__tests__/publicationStatus.test.ts +176 -0
  9. package/src/__tests__/records.test.ts +68 -68
  10. package/src/__tests__/seoSections.test.ts +245 -0
  11. package/src/actions/submitRating.ts +36 -0
  12. package/src/actions/submitReview.ts +95 -0
  13. package/src/actions/submitView.ts +28 -0
  14. package/src/client.ts +69 -0
  15. package/src/core/blogRecords.ts +94 -0
  16. package/src/core/handlePosts.ts +154 -63
  17. package/src/core/records.ts +147 -12
  18. package/src/index.ts +48 -14
  19. package/src/loaderMap.ts +28 -24
  20. package/src/loaders/BlogPostItem.ts +15 -5
  21. package/src/loaders/BlogPostPage.ts +25 -20
  22. package/src/loaders/BlogRelatedPosts.ts +45 -45
  23. package/src/loaders/BlogpostList.ts +73 -0
  24. package/src/loaders/BlogpostListing.ts +114 -77
  25. package/src/loaders/GetCategories.ts +42 -28
  26. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  27. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  28. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  29. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  30. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  31. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  32. package/src/loaders/extensions/types.ts +15 -0
  33. package/src/manifest.gen.ts +82 -14
  34. package/src/mod.ts +71 -20
  35. package/src/registry.ts +5 -5
  36. package/src/sections/Seo/SeoBlogPost.tsx +75 -0
  37. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  38. package/src/sections/Template.tsx +96 -0
  39. package/src/sections/blocks/BlockImage.tsx +21 -0
  40. package/src/sections/blocks/Callout.tsx +64 -0
  41. package/src/sections/blocks/CardGroup.tsx +60 -0
  42. package/src/sections/blocks/Checklist.tsx +58 -0
  43. package/src/sections/blocks/Code.tsx +29 -0
  44. package/src/sections/blocks/Comparison.tsx +77 -0
  45. package/src/sections/blocks/Cta.tsx +22 -0
  46. package/src/sections/blocks/Divider.tsx +3 -0
  47. package/src/sections/blocks/Heading.tsx +28 -0
  48. package/src/sections/blocks/List.tsx +39 -0
  49. package/src/sections/blocks/Paragraph.tsx +15 -0
  50. package/src/sections/blocks/Quote.tsx +32 -0
  51. package/src/sections/blocks/Stat.tsx +23 -0
  52. package/src/sections/blocks/StatGroup.tsx +38 -0
  53. package/src/sections/blocks/Steps.tsx +64 -0
  54. package/src/sections/blocks/Table.tsx +80 -0
  55. package/src/sections/blocks/Video.tsx +39 -0
  56. package/src/static/css.ts +178 -0
  57. package/src/types.ts +312 -77
  58. package/src/utils/blocksToSections.ts +170 -0
  59. package/src/utils/constants.ts +1 -0
  60. package/src/utils/date.ts +95 -0
  61. package/src/utils/hardSanitize.ts +106 -0
  62. package/src/utils/jsonLD.ts +115 -0
  63. package/src/utils/sanitizeHtml.ts +37 -0
@@ -1,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,12 @@
1
1
  import { getRecordsByPath } from "../core/records";
2
2
  import type { BlogPost, BlogPostPage } from "../types";
3
+ import { isLivePost } from "../types";
3
4
 
4
5
  const COLLECTION_PATH = "collections/blog/posts";
5
6
  const ACCESSOR = "post";
6
7
 
7
8
  export interface Props {
8
- slug: string;
9
+ slug: string;
9
10
  }
10
11
 
11
12
  /**
@@ -13,27 +14,31 @@ export interface Props {
13
14
  * @description Fetches a specific blog post page by its slug.
14
15
  */
15
16
  export default function BlogPostPageLoader(
16
- props: Props & { __pageUrl?: string },
17
- req?: Request,
17
+ props: Props & { __pageUrl?: string },
18
+ req?: Request,
18
19
  ): BlogPostPage | null {
19
- const { slug } = props;
20
- const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
20
+ const { slug } = props;
21
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
21
22
 
22
- const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
23
- const url = new URL(rawUrl);
24
- const post = posts.find((p) => p?.slug === slug);
23
+ const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
24
+ const url = new URL(rawUrl);
25
+ const post = posts.find((p) => p?.slug === slug);
25
26
 
26
- if (!post) return null;
27
+ if (!post) return null;
27
28
 
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
- };
29
+ return {
30
+ "@type": "BlogPostPage",
31
+ post,
32
+ seo: {
33
+ title: post?.seo?.title || post?.title,
34
+ description: post?.seo?.description || post?.excerpt,
35
+ canonical: post?.seo?.canonical || url.href,
36
+ image: post?.seo?.image || post?.image,
37
+ // A post that isn't live yet — unpublished, or scheduled for an instant
38
+ // still ahead — renders anyway, because that page *is* the CMS preview.
39
+ // It just must never be indexed, even if the URL leaks. A scheduled post
40
+ // becomes indexable on its own once its instant passes.
41
+ noIndexing: post?.seo?.noIndexing || !isLivePost(post),
42
+ },
43
+ };
39
44
  }
@@ -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,73 @@
1
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
2
+ import handlePosts, { slicePosts } from "../core/handlePosts";
3
+ import { getRecordsByPath } from "../core/records";
4
+ import type { BlogPost, SortBy } from "../types";
5
+
6
+ const COLLECTION_PATH = "collections/blog/posts";
7
+ const ACCESSOR = "post";
8
+
9
+ export interface Props {
10
+ /**
11
+ * @title Items per page
12
+ * @description Number of posts per page to display.
13
+ */
14
+ count?: number;
15
+ /**
16
+ * @title Page query parameter
17
+ * @description The current page number. Defaults to 1.
18
+ */
19
+ page?: number;
20
+ /**
21
+ * @title Category Slug
22
+ * @description Filter by a specific category slug.
23
+ */
24
+ slug?: string;
25
+ /**
26
+ * @title Specific post slugs
27
+ * @description Filter by specific post slugs.
28
+ */
29
+ postSlugs?: string[];
30
+ /**
31
+ * @title Page sorting parameter
32
+ * @description The sorting option. Default is "date_desc"
33
+ */
34
+ sortBy?: SortBy;
35
+ /**
36
+ * @description Overrides the query term at url
37
+ */
38
+ query?: string;
39
+ }
40
+
41
+ /**
42
+ * @title BlogPostList
43
+ * @description Retrieves a flat list of blog posts.
44
+ */
45
+ export default async function BlogPostList(
46
+ props: Props & { __pageUrl?: string },
47
+ req?: Request,
48
+ ): Promise<BlogPost[] | null> {
49
+ const { page, count, slug, sortBy, postSlugs, query } = props;
50
+ const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
51
+ const url = new URL(rawUrl);
52
+ const params = url.searchParams;
53
+ const postsPerPage = Number(count ?? params.get("count") ?? 12);
54
+ const pageNumber = Number(page ?? params.get("page") ?? 1);
55
+ const pageSort = sortBy ?? (params.get("sortBy") as SortBy) ?? "date_desc";
56
+ const term = query ?? params.get("q") ?? undefined;
57
+
58
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
59
+
60
+ try {
61
+ const handledPosts = await handlePosts(posts, pageSort, slug, postSlugs, term);
62
+
63
+ if (!handledPosts) return null;
64
+
65
+ const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
66
+
67
+ return slicedPosts.length > 0 ? slicedPosts : null;
68
+ } catch (e) {
69
+ const error = serializeError(e);
70
+ logger.error(error.message, { error, scope: "blog/BlogpostList" });
71
+ return null;
72
+ }
73
+ }
@@ -1,101 +1,138 @@
1
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
1
2
  import handlePosts, { slicePosts } from "../core/handlePosts";
2
3
  import { getRecordsByPath } from "../core/records";
3
- import type { BlogPost, BlogPostListingPage, PageInfo, SortBy } from "../types";
4
+ import type { BlogPost, BlogPostListingPage, Category, PageInfo, SortBy } from "../types";
4
5
 
5
6
  const COLLECTION_PATH = "collections/blog/posts";
6
7
  const ACCESSOR = "post";
8
+ const CATEGORIES_PATH = "collections/blog/categories";
9
+ const CATEGORY_ACCESSOR = "category";
7
10
 
8
11
  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;
12
+ /**
13
+ * @title Category Slug
14
+ * @description Filter by a specific category slug.
15
+ */
16
+ slug?: string;
17
+ /**
18
+ * @title Items per page
19
+ * @description Number of posts per page to display.
20
+ */
21
+ count?: number;
22
+ /**
23
+ * @title Page query parameter
24
+ * @description The current page number. Defaults to 1.
25
+ */
26
+ page?: number;
27
+ /**
28
+ * @title Page sorting parameter
29
+ * @description The sorting option. Default is "date_desc"
30
+ */
31
+ sortBy?: SortBy;
32
+ /**
33
+ * @description Overrides the query term at url
34
+ */
35
+ query?: string;
33
36
  }
34
37
 
38
+ const report = (scope: string, e: unknown) => {
39
+ const error = serializeError(e);
40
+ logger.error(error.message, { error, scope });
41
+ };
42
+
43
+ /** Categories with a usable name and slug, alphabetically. */
44
+ const loadCategories = (): Category[] =>
45
+ getRecordsByPath<Category>(CATEGORIES_PATH, CATEGORY_ACCESSOR)
46
+ .filter(
47
+ (c) =>
48
+ typeof c?.name === "string" &&
49
+ c.name.length > 0 &&
50
+ typeof c?.slug === "string" &&
51
+ c.slug.length > 0,
52
+ )
53
+ .sort((a, b) => a.name.localeCompare(b.name));
54
+
35
55
  /**
36
- * @title BlogPostList
37
- * @description Retrieves a paginated list of blog posts.
56
+ * @title BlogPostListing
57
+ * @description Retrieves a paginated listing page of blog posts.
38
58
  */
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;
59
+ export default async function BlogPostListing(
60
+ props: Props & { __pageUrl?: string },
61
+ req?: Request,
62
+ ): Promise<BlogPostListingPage | null> {
63
+ const { page, count, slug, sortBy, query } = props;
64
+ const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/";
65
+ const url = new URL(rawUrl);
66
+ const params = url.searchParams;
67
+ const postsPerPage = Number(count ?? params.get("count") ?? 12);
68
+ const pageNumber = Number(page ?? params.get("page") ?? 1);
69
+ const pageSort = sortBy ?? (params.get("sortBy") as SortBy) ?? "date_desc";
70
+ const term = query ?? params.get("q") ?? undefined;
71
+
72
+ const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
51
73
 
52
- const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
74
+ try {
75
+ const handledPosts = await handlePosts(posts, pageSort, slug, undefined, term);
53
76
 
54
- try {
55
- const handledPosts = handlePosts(posts, pageSort, slug, undefined, term);
77
+ if (!handledPosts) return null;
56
78
 
57
- if (!handledPosts) return null;
79
+ const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
80
+ if (slicedPosts.length === 0) return null;
58
81
 
59
- const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
60
- if (slicedPosts.length === 0) return null;
82
+ let categories: Category[] | null = null;
83
+ try {
84
+ categories = loadCategories();
85
+ } catch (e) {
86
+ report("blog/BlogpostListing/categories", e);
87
+ }
61
88
 
62
- const category = slicedPosts[0].categories?.find((c) => c.slug === slug);
89
+ let category: Category | null = null;
90
+ if (slug) {
91
+ // Prefer the standalone category record (it carries description and
92
+ // sections); fall back to the denormalized copy embedded in a post,
93
+ // which is all a site that never created category records will have.
94
+ category = categories?.find((c) => c.slug === slug) ?? null;
95
+ category ??= slicedPosts[0]?.categories?.find((c) => c.slug === slug) ?? null;
96
+ }
63
97
 
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
- }
98
+ return {
99
+ posts: slicedPosts,
100
+ category,
101
+ categories,
102
+ pageInfo: toPageInfo(handledPosts, postsPerPage, pageNumber, params),
103
+ seo: {
104
+ title: category?.name ?? "",
105
+ description: category?.description,
106
+ canonical: new URL(url.pathname, url.origin).href,
107
+ },
108
+ };
109
+ } catch (e) {
110
+ report("blog/BlogpostListing", e);
111
+ return null;
112
+ }
76
113
  }
77
114
 
78
115
  function toPageInfo(
79
- posts: BlogPost[],
80
- postsPerPage: number,
81
- pageNumber: number,
82
- params: URLSearchParams,
116
+ posts: BlogPost[],
117
+ postsPerPage: number,
118
+ pageNumber: number,
119
+ params: URLSearchParams,
83
120
  ): 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);
121
+ const totalPosts = posts.length;
122
+ const totalPages = Math.ceil(totalPosts / postsPerPage);
123
+ const hasNextPage = totalPages > pageNumber;
124
+ const hasPrevPage = pageNumber > 1;
125
+ const nextPage = new URLSearchParams(params);
126
+ const previousPage = new URLSearchParams(params);
90
127
 
91
- if (hasNextPage) nextPage.set("page", (pageNumber + 1).toString());
92
- if (hasPrevPage) previousPage.set("page", (pageNumber - 1).toString());
128
+ if (hasNextPage) nextPage.set("page", (pageNumber + 1).toString());
129
+ if (hasPrevPage) previousPage.set("page", (pageNumber - 1).toString());
93
130
 
94
- return {
95
- nextPage: hasNextPage ? `?${nextPage}` : undefined,
96
- previousPage: hasPrevPage ? `?${previousPage}` : undefined,
97
- currentPage: pageNumber,
98
- records: totalPosts,
99
- recordPerPage: postsPerPage,
100
- };
131
+ return {
132
+ nextPage: hasNextPage ? `?${nextPage}` : undefined,
133
+ previousPage: hasPrevPage ? `?${previousPage}` : undefined,
134
+ currentPage: pageNumber,
135
+ records: totalPosts,
136
+ recordPerPage: postsPerPage,
137
+ };
101
138
  }
@@ -5,21 +5,21 @@ const COLLECTION_PATH = "collections/blog/categories";
5
5
  const ACCESSOR = "category";
6
6
 
7
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";
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
23
  }
24
24
 
25
25
  /**
@@ -27,22 +27,36 @@ export interface Props {
27
27
  * @description Retrieves a list of blog categories.
28
28
  */
29
29
  export default function GetCategories({
30
- count,
31
- slug,
32
- sortBy = "title_desc",
30
+ count,
31
+ slug,
32
+ sortBy = "title_desc",
33
33
  }: Props): Category[] | null {
34
- const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
34
+ const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
35
35
 
36
- if (!categories?.length) return null;
36
+ if (!categories?.length) return null;
37
37
 
38
- if (slug) {
39
- return categories.filter((c) => c.slug === slug);
40
- }
38
+ // Records come straight from the CMS, so name/slug are strings only by
39
+ // convention. A record missing either has no route and no label, so it would
40
+ // render as a blank chip linking nowhere — and would throw in the
41
+ // `localeCompare` sort below.
42
+ const validCategories = categories.filter(
43
+ (c) =>
44
+ typeof c?.name === "string" &&
45
+ c.name.length > 0 &&
46
+ typeof c?.slug === "string" &&
47
+ c.slug.length > 0,
48
+ );
41
49
 
42
- const sortedCategories = categories.sort((a, b) => {
43
- const comparison = a.name.localeCompare(b.name);
44
- return sortBy.endsWith("_desc") ? comparison : -comparison;
45
- });
50
+ if (slug) {
51
+ return validCategories.filter((c) => c.slug === slug);
52
+ }
46
53
 
47
- return count ? sortedCategories.slice(0, count) : sortedCategories;
54
+ if (!validCategories.length) return null;
55
+
56
+ const sortedCategories = validCategories.sort((a, b) => {
57
+ const comparison = a.name.localeCompare(b.name);
58
+ return sortBy.endsWith("_desc") ? comparison : -comparison;
59
+ });
60
+
61
+ return count ? sortedCategories.slice(0, count) : sortedCategories;
48
62
  }
@@ -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
+ }