@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.
- package/README.md +21 -0
- package/package.json +14 -5
- package/src/README.md +69 -0
- package/src/__tests__/blogRecords.test.ts +270 -0
- package/src/__tests__/categoryTree.test.ts +209 -0
- package/src/__tests__/date.test.ts +134 -0
- package/src/__tests__/handlePosts.test.ts +256 -230
- package/src/__tests__/loaders.test.ts +198 -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 +70 -0
- package/src/core/blogRecords.ts +94 -0
- package/src/core/categoryTree.ts +210 -0
- package/src/core/handlePosts.ts +160 -64
- 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 +61 -24
- package/src/loaders/BlogRelatedPosts.ts +45 -45
- package/src/loaders/BlogpostList.ts +93 -0
- package/src/loaders/BlogpostListing.ts +151 -85
- package/src/loaders/GetCategories.ts +51 -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 +73 -20
- package/src/registry.ts +5 -5
- package/src/sections/Seo/SeoBlogPost.tsx +77 -0
- package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
- package/src/sections/Template.tsx +106 -0
- package/src/sections/blocks/BlockImage.tsx +42 -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 +333 -77
- package/src/utils/blocksToSections.ts +175 -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 +118 -0
- package/src/utils/sanitizeHtml.ts +37 -0
package/src/core/records.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
16
|
+
const blocks = loadBlocks() as Record<string, Record<string, unknown>>;
|
|
17
|
+
const results: T[] = [];
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
24
|
+
const record = value[accessor] as T | undefined;
|
|
25
|
+
if (!record) continue;
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", "");
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
results.push({ ...record, id } as T);
|
|
30
|
+
}
|
|
28
31
|
|
|
29
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
21
|
+
props: Props & { __pageUrl?: string },
|
|
22
|
+
req?: Request,
|
|
18
23
|
): BlogPostPage | null {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|