@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.
- package/package.json +13 -4
- package/src/README.md +69 -0
- package/src/__tests__/blogRecords.test.ts +270 -0
- package/src/__tests__/date.test.ts +134 -0
- package/src/__tests__/handlePosts.test.ts +230 -230
- package/src/__tests__/loaders.test.ts +154 -154
- package/src/__tests__/mod.test.ts +6 -6
- package/src/__tests__/publicationStatus.test.ts +176 -0
- package/src/__tests__/records.test.ts +68 -68
- package/src/__tests__/seoSections.test.ts +245 -0
- package/src/actions/submitRating.ts +36 -0
- package/src/actions/submitReview.ts +95 -0
- package/src/actions/submitView.ts +28 -0
- package/src/client.ts +69 -0
- package/src/core/blogRecords.ts +94 -0
- package/src/core/handlePosts.ts +154 -63
- package/src/core/records.ts +147 -12
- package/src/index.ts +48 -14
- package/src/loaderMap.ts +28 -24
- package/src/loaders/BlogPostItem.ts +15 -5
- package/src/loaders/BlogPostPage.ts +25 -20
- package/src/loaders/BlogRelatedPosts.ts +45 -45
- package/src/loaders/BlogpostList.ts +73 -0
- package/src/loaders/BlogpostListing.ts +114 -77
- package/src/loaders/GetCategories.ts +42 -28
- package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
- package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
- package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
- package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
- package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
- package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
- package/src/loaders/extensions/types.ts +15 -0
- package/src/manifest.gen.ts +82 -14
- package/src/mod.ts +71 -20
- package/src/registry.ts +5 -5
- package/src/sections/Seo/SeoBlogPost.tsx +75 -0
- package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
- package/src/sections/Template.tsx +96 -0
- package/src/sections/blocks/BlockImage.tsx +21 -0
- package/src/sections/blocks/Callout.tsx +64 -0
- package/src/sections/blocks/CardGroup.tsx +60 -0
- package/src/sections/blocks/Checklist.tsx +58 -0
- package/src/sections/blocks/Code.tsx +29 -0
- package/src/sections/blocks/Comparison.tsx +77 -0
- package/src/sections/blocks/Cta.tsx +22 -0
- package/src/sections/blocks/Divider.tsx +3 -0
- package/src/sections/blocks/Heading.tsx +28 -0
- package/src/sections/blocks/List.tsx +39 -0
- package/src/sections/blocks/Paragraph.tsx +15 -0
- package/src/sections/blocks/Quote.tsx +32 -0
- package/src/sections/blocks/Stat.tsx +23 -0
- package/src/sections/blocks/StatGroup.tsx +38 -0
- package/src/sections/blocks/Steps.tsx +64 -0
- package/src/sections/blocks/Table.tsx +80 -0
- package/src/sections/blocks/Video.tsx +39 -0
- package/src/static/css.ts +178 -0
- package/src/types.ts +312 -77
- package/src/utils/blocksToSections.ts +170 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/date.ts +95 -0
- package/src/utils/hardSanitize.ts +106 -0
- package/src/utils/jsonLD.ts +115 -0
- 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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
15
|
+
const { slug } = props;
|
|
16
|
+
if (!slug) return null;
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
17
|
+
props: Props & { __pageUrl?: string },
|
|
18
|
+
req?: Request,
|
|
18
19
|
): BlogPostPage | null {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const { slug } = props;
|
|
21
|
+
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
+
if (!post) return null;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
): BlogRelatedPosts {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
58
|
+
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
const handledPosts = await handlePosts(posts, pageSort, slug, undefined, term, excludePostSlug);
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
if (!handledPosts) return null;
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
37
|
-
* @description Retrieves a paginated
|
|
56
|
+
* @title BlogPostListing
|
|
57
|
+
* @description Retrieves a paginated listing page of blog posts.
|
|
38
58
|
*/
|
|
39
|
-
export default function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
): BlogPostListingPage | null {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
74
|
+
try {
|
|
75
|
+
const handledPosts = await handlePosts(posts, pageSort, slug, undefined, term);
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
const handledPosts = handlePosts(posts, pageSort, slug, undefined, term);
|
|
77
|
+
if (!handledPosts) return null;
|
|
56
78
|
|
|
57
|
-
|
|
79
|
+
const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
|
|
80
|
+
if (slicedPosts.length === 0) return null;
|
|
58
81
|
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
let categories: Category[] | null = null;
|
|
83
|
+
try {
|
|
84
|
+
categories = loadCategories();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
report("blog/BlogpostListing/categories", e);
|
|
87
|
+
}
|
|
61
88
|
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
116
|
+
posts: BlogPost[],
|
|
117
|
+
postsPerPage: number,
|
|
118
|
+
pageNumber: number,
|
|
119
|
+
params: URLSearchParams,
|
|
83
120
|
): PageInfo {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
128
|
+
if (hasNextPage) nextPage.set("page", (pageNumber + 1).toString());
|
|
129
|
+
if (hasPrevPage) previousPage.set("page", (pageNumber - 1).toString());
|
|
93
130
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
count,
|
|
31
|
+
slug,
|
|
32
|
+
sortBy = "title_desc",
|
|
33
33
|
}: Props): Category[] | null {
|
|
34
|
-
|
|
34
|
+
const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
if (!categories?.length) return null;
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
50
|
+
if (slug) {
|
|
51
|
+
return validCategories.filter((c) => c.slug === slug);
|
|
52
|
+
}
|
|
46
53
|
|
|
47
|
-
|
|
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
|
+
}
|