@decocms/apps-blog 7.52.1 → 7.53.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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injectable persistence port for the blog's ratings, reviews and view counts.
|
|
3
|
+
*
|
|
4
|
+
* In `deco-cx/apps` these three features talk to the separate `records` app —
|
|
5
|
+
* a Turso/libsql database driven through `drizzle-orm`, reached as
|
|
6
|
+
* `await ctx.invoke.records.loaders.drizzle()`. Neither that app nor drizzle
|
|
7
|
+
* exists in this monorepo, and adding them would make every site that installs
|
|
8
|
+
* the blog app carry a database client it probably doesn't use.
|
|
9
|
+
*
|
|
10
|
+
* So the dependency is inverted, the same way `@decocms/blocks-admin` inverts
|
|
11
|
+
* its KV dependency via `setFastDeployKVGetter`: this package declares the
|
|
12
|
+
* operations it needs, and the site injects an implementation at boot. The port
|
|
13
|
+
* is expressed as *domain* operations rather than as a query builder, so the
|
|
14
|
+
* table and column names stay on the site's side of the boundary — which is
|
|
15
|
+
* also why `db/schema.ts` is deliberately not ported.
|
|
16
|
+
*
|
|
17
|
+
* With no adapter registered every read degrades to empty and every write to
|
|
18
|
+
* `null`, mirroring what the Deno app does when the `records` app isn't
|
|
19
|
+
* installed. That's the common case: a blog with no comments or view counter
|
|
20
|
+
* works untouched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Person } from "@decocms/apps-commerce/types";
|
|
24
|
+
import type { Rating, Review, ViewFromDatabase } from "../types";
|
|
25
|
+
|
|
26
|
+
export interface ListReviewsOptions {
|
|
27
|
+
slug: string;
|
|
28
|
+
/**
|
|
29
|
+
* `additionalType` values to exclude — the moderation states a site wants
|
|
30
|
+
* kept out of the public list.
|
|
31
|
+
*/
|
|
32
|
+
excludeAdditionalTypes?: string[];
|
|
33
|
+
orderBy: "date_asc" | "date_desc";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UpsertRatingInput {
|
|
37
|
+
itemReviewed: string;
|
|
38
|
+
author: Person;
|
|
39
|
+
ratingValue: number;
|
|
40
|
+
additionalType?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CreateReviewInput {
|
|
44
|
+
itemReviewed?: string;
|
|
45
|
+
author?: Person;
|
|
46
|
+
reviewHeadline?: string;
|
|
47
|
+
reviewBody?: string;
|
|
48
|
+
additionalType?: string;
|
|
49
|
+
isAnonymous?: boolean;
|
|
50
|
+
datePublished: string;
|
|
51
|
+
dateModified: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface UpdateReviewInput {
|
|
55
|
+
reviewHeadline?: string;
|
|
56
|
+
reviewBody?: string;
|
|
57
|
+
additionalType?: string;
|
|
58
|
+
dateModified: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every method is optional: a site that only wants view counts implements
|
|
63
|
+
* `listPostViews`/`incrementPostView` and leaves the rest out.
|
|
64
|
+
*/
|
|
65
|
+
export interface BlogRecordsAdapter {
|
|
66
|
+
listRatings?(slug: string): Promise<Rating[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Creates the rating, or replaces the value of this author's existing rating
|
|
69
|
+
* for the same item — one rating per author per post.
|
|
70
|
+
*/
|
|
71
|
+
upsertRating?(input: UpsertRatingInput): Promise<Rating | null>;
|
|
72
|
+
listReviews?(options: ListReviewsOptions): Promise<Review[]>;
|
|
73
|
+
getReview?(id: string): Promise<Review | null>;
|
|
74
|
+
createReview?(input: CreateReviewInput): Promise<Review | null>;
|
|
75
|
+
updateReview?(id: string, patch: UpdateReviewInput): Promise<Review | null>;
|
|
76
|
+
listPostViews?(): Promise<ViewFromDatabase[]>;
|
|
77
|
+
/** Increments (or creates at 1) the view counter, returning the new total. */
|
|
78
|
+
incrementPostView?(id: string): Promise<{ count: number }>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const G = globalThis as unknown as { __decoBlogRecordsAdapter?: BlogRecordsAdapter };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Registers the persistence backend for ratings, reviews and view counts.
|
|
85
|
+
* Call once at boot, before any request is served.
|
|
86
|
+
*/
|
|
87
|
+
export function setBlogRecordsAdapter(adapter: BlogRecordsAdapter): void {
|
|
88
|
+
G.__decoBlogRecordsAdapter = adapter;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The registered adapter, or `undefined` when the site never injected one. */
|
|
92
|
+
export function getBlogRecordsAdapter(): BlogRecordsAdapter | undefined {
|
|
93
|
+
return G.__decoBlogRecordsAdapter;
|
|
94
|
+
}
|
package/src/core/handlePosts.ts
CHANGED
|
@@ -1,88 +1,179 @@
|
|
|
1
1
|
import type { BlogPost, SortBy } from "../types";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { isLivePost } from "../types";
|
|
3
|
+
import { VALID_SORT_ORDERS } from "../utils/constants";
|
|
4
|
+
import { dateToTime } from "../utils/date";
|
|
5
|
+
import { getBlogRecordsAdapter } from "./blogRecords";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* Returns a sorted BlogPost list.
|
|
9
|
+
*
|
|
10
|
+
* @param blogPosts Posts to be sorted
|
|
11
|
+
* @param sortBy Sort option ("date_desc" | "date_asc" | "title_asc" | "title_desc" | "view_asc" | "view_desc")
|
|
8
12
|
*/
|
|
9
|
-
export const sortPosts = (blogPosts: BlogPost[], sortBy: SortBy): BlogPost[] => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
export const sortPosts = async (blogPosts: BlogPost[], sortBy: SortBy): Promise<BlogPost[]> => {
|
|
14
|
+
const splittedSort = sortBy.split("_");
|
|
15
|
+
|
|
16
|
+
if (splittedSort[0] === "view") {
|
|
17
|
+
const views = await getBlogRecordsAdapter()?.listPostViews?.();
|
|
18
|
+
|
|
19
|
+
// No adapter (or no rows): fall through to the date sort rather than
|
|
20
|
+
// returning an arbitrary order. The Deno app threw here when the `records`
|
|
21
|
+
// app was missing; degrading is the deliberate difference — view sorting is
|
|
22
|
+
// an enhancement, and a blog without the backend should still render.
|
|
23
|
+
if (views?.length) {
|
|
24
|
+
// Act like a real extension: stamp the counts onto the posts.
|
|
25
|
+
for (const view of views) {
|
|
26
|
+
const post = blogPosts.find(({ slug }) => slug === view.id);
|
|
27
|
+
if (post) {
|
|
28
|
+
post.interactionStatistic = {
|
|
29
|
+
"@type": "InteractionCounter",
|
|
30
|
+
userInteractionCount: view.userInteractionCount,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const viewSortOrder = VALID_SORT_ORDERS.includes(splittedSort[1]) ? splittedSort[1] : "desc";
|
|
36
|
+
|
|
37
|
+
return [...blogPosts].sort((a, b) => {
|
|
38
|
+
const countOfA = a?.interactionStatistic?.userInteractionCount;
|
|
39
|
+
const countOfB = b?.interactionStatistic?.userInteractionCount;
|
|
40
|
+
if (!countOfA && !countOfB) {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
// NOTE: inverted, like the title branch below — `view_desc` puts the
|
|
44
|
+
// *least* viewed post first. Verbatim from upstream
|
|
45
|
+
// (deco-cx/apps blog/core/handlePosts.ts); only the `date` branch, which
|
|
46
|
+
// compares `b - a`, reads the right way round. Kept as-is so a site
|
|
47
|
+
// moving off the Deno app sees no reordering; fix it upstream first.
|
|
48
|
+
const comparison = (countOfA ?? 0) - (countOfB ?? 0);
|
|
49
|
+
return viewSortOrder === "desc" ? comparison : -comparison;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sortMethod = (splittedSort[0] in blogPosts[0] ? splittedSort[0] : "date") as keyof BlogPost;
|
|
55
|
+
const sortOrder = VALID_SORT_ORDERS.includes(splittedSort[1]) ? splittedSort[1] : "desc";
|
|
56
|
+
|
|
57
|
+
return [...blogPosts].sort((a, b) => {
|
|
58
|
+
if (!a[sortMethod] && !b[sortMethod]) {
|
|
59
|
+
return 0; // Both lack the sort key — consider them equal
|
|
60
|
+
}
|
|
61
|
+
if (!a[sortMethod]) {
|
|
62
|
+
return 1; // a lacks the sort key — put it after b
|
|
63
|
+
}
|
|
64
|
+
if (!b[sortMethod]) {
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
// NOTE: for the non-date keys this reads inverted — `title_asc` yields Z-A.
|
|
68
|
+
// That is upstream's behaviour (deco-cx/apps blog/core/handlePosts.ts) and
|
|
69
|
+
// existing content relies on it, so it is preserved here on purpose rather
|
|
70
|
+
// than "fixed" as a drive-by. Don't flip it without changing upstream too.
|
|
71
|
+
const comparison =
|
|
72
|
+
sortMethod === "date"
|
|
73
|
+
? dateToTime(b.date) - dateToTime(a.date)
|
|
74
|
+
: (a[sortMethod]?.toString().localeCompare(b[sortMethod]?.toString() ?? "") ?? 0);
|
|
75
|
+
return sortOrder === "desc" ? comparison : -comparison;
|
|
76
|
+
});
|
|
26
77
|
};
|
|
27
78
|
|
|
28
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Returns a filtered BlogPost list.
|
|
81
|
+
*
|
|
82
|
+
* @param posts Posts to be handled
|
|
83
|
+
* @param slug Category slug to filter by
|
|
84
|
+
*/
|
|
29
85
|
export const filterPostsByCategory = (posts: BlogPost[], slug?: string): BlogPost[] =>
|
|
30
|
-
|
|
86
|
+
slug ? posts.filter(({ categories }) => categories?.find((c) => c.slug === slug)) : posts;
|
|
31
87
|
|
|
32
88
|
/** Filter posts whose slug is in the given list. */
|
|
33
89
|
export const filterPostsBySlugs = (posts: BlogPost[], postSlugs: string[]): BlogPost[] =>
|
|
34
|
-
|
|
90
|
+
posts.filter(({ slug }) => postSlugs.includes(slug));
|
|
35
91
|
|
|
36
92
|
/** Filter posts matching a search term (title, excerpt, content). */
|
|
37
93
|
export const filterPostsByTerm = (posts: BlogPost[], term: string): BlogPost[] =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
94
|
+
posts.filter(({ content, excerpt, title }) =>
|
|
95
|
+
[content, excerpt, title].some((field) => field?.toLowerCase().includes(term.toLowerCase())),
|
|
96
|
+
);
|
|
41
97
|
|
|
42
98
|
/** Filter posts whose categories overlap with the given slug array. */
|
|
43
99
|
export const filterRelatedPosts = (posts: BlogPost[], slugs: string[]): BlogPost[] =>
|
|
44
|
-
|
|
100
|
+
posts.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug)));
|
|
45
101
|
|
|
46
102
|
/** Slice posts for pagination. */
|
|
47
103
|
export const slicePosts = (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
104
|
+
posts: BlogPost[],
|
|
105
|
+
pageNumber: number,
|
|
106
|
+
postsPerPage: number,
|
|
107
|
+
): BlogPost[] => {
|
|
108
|
+
const startIndex = (pageNumber - 1) * postsPerPage;
|
|
109
|
+
return posts.slice(startIndex, startIndex + postsPerPage);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A record without a slug has no route, so it can never be rendered: listing it
|
|
114
|
+
* only produces cards linking to the listing itself. Posts that aren't live are
|
|
115
|
+
* unreachable for a different reason — either the CMS doesn't consider them
|
|
116
|
+
* ready, or they're scheduled for an instant that hasn't arrived yet — but the
|
|
117
|
+
* outcome is the same, so both are dropped here, before slicePosts, so `count`
|
|
118
|
+
* still yields `count` renderable posts.
|
|
119
|
+
*
|
|
120
|
+
* A scheduled post crossing its instant flips this filter on the next request
|
|
121
|
+
* that misses cache; nothing re-deploys and no record is rewritten.
|
|
122
|
+
*/
|
|
123
|
+
export const filterRoutablePosts = (posts: BlogPost[]): BlogPost[] =>
|
|
124
|
+
// Records come straight from the CMS, so `slug` is only a string by
|
|
125
|
+
// convention: the typeof guard keeps a malformed one from throwing here and
|
|
126
|
+
// taking the whole listing down with it.
|
|
127
|
+
posts.filter((post) => typeof post.slug === "string" && post.slug.trim() && isLivePost(post));
|
|
128
|
+
|
|
129
|
+
const filterPosts = (
|
|
130
|
+
allPosts: BlogPost[],
|
|
131
|
+
slug?: string | string[],
|
|
132
|
+
postSlugs?: string[],
|
|
133
|
+
term?: string,
|
|
51
134
|
): BlogPost[] => {
|
|
52
|
-
|
|
53
|
-
|
|
135
|
+
const posts = filterRoutablePosts(allPosts);
|
|
136
|
+
|
|
137
|
+
if (typeof slug === "string") {
|
|
138
|
+
const firstFilter =
|
|
139
|
+
postSlugs && postSlugs.length > 0
|
|
140
|
+
? filterPostsBySlugs(posts, postSlugs)
|
|
141
|
+
: filterPostsByCategory(posts, slug);
|
|
142
|
+
|
|
143
|
+
return term ? filterPostsByTerm(firstFilter, term) : firstFilter;
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(slug)) {
|
|
146
|
+
return filterRelatedPosts(posts, slug);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return term ? filterPostsByTerm(posts, term) : posts;
|
|
54
150
|
};
|
|
55
151
|
|
|
56
152
|
/**
|
|
57
|
-
*
|
|
153
|
+
* Returns a filtered and sorted BlogPost list. It does not slice.
|
|
154
|
+
*
|
|
155
|
+
* @param posts Posts to be handled
|
|
156
|
+
* @param sortBy Sort option
|
|
157
|
+
* @param slug Category slug, or an array of slugs, to filter by
|
|
158
|
+
* @param postSlugs Specific slugs to filter by
|
|
159
|
+
* @param term Term to filter by
|
|
160
|
+
* @param excludePostSlug Slug to exclude
|
|
58
161
|
*/
|
|
59
|
-
export default function handlePosts(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
): BlogPost[] | null {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
filtered = filterRelatedPosts(posts, slug);
|
|
77
|
-
} else {
|
|
78
|
-
filtered = term ? filterPostsByTerm(posts, term) : posts;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (excludePostSlug) {
|
|
82
|
-
filtered = filtered.filter(({ slug: s }) => s !== excludePostSlug);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (!filtered || filtered.length === 0) return null;
|
|
86
|
-
|
|
87
|
-
return sortPosts(filtered, sortBy);
|
|
162
|
+
export default async function handlePosts(
|
|
163
|
+
posts: BlogPost[],
|
|
164
|
+
sortBy: SortBy,
|
|
165
|
+
slug?: string | string[],
|
|
166
|
+
postSlugs?: string[],
|
|
167
|
+
term?: string,
|
|
168
|
+
excludePostSlug?: string,
|
|
169
|
+
): Promise<BlogPost[] | null> {
|
|
170
|
+
const filteredPosts = filterPosts(posts, slug, postSlugs, term).filter(
|
|
171
|
+
({ slug: postSlug }) => postSlug !== excludePostSlug,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (filteredPosts.length === 0) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return sortPosts(filteredPosts, sortBy);
|
|
88
179
|
}
|
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
|
}
|