@decocms/apps-blog 7.52.0 → 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.
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
@@ -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
+ }
@@ -1,88 +1,179 @@
1
1
  import type { BlogPost, SortBy } from "../types";
2
-
3
- const VALID_SORT_ORDERS = ["asc", "desc"];
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
- * Sort posts by the given criteria.
7
- * Skips view-based sorting (no Drizzle in this port).
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
- const parts = sortBy.split("_");
11
- const sortMethod = (parts[0] in blogPosts[0] ? parts[0] : "date") as keyof BlogPost;
12
- const sortOrder = VALID_SORT_ORDERS.includes(parts[1]) ? parts[1] : "desc";
13
-
14
- return [...blogPosts].sort((a, b) => {
15
- if (!a[sortMethod] && !b[sortMethod]) return 0;
16
- if (!a[sortMethod]) return 1;
17
- if (!b[sortMethod]) return -1;
18
-
19
- const comparison =
20
- sortMethod === "date"
21
- ? new Date(`${b.date}T00:00:00`).getTime() - new Date(`${a.date}T00:00:00`).getTime()
22
- : (a[sortMethod]?.toString().localeCompare(b[sortMethod]?.toString() ?? "") ?? 0);
23
-
24
- return sortOrder === "desc" ? comparison : -comparison;
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
- /** Filter posts by a single category slug. */
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
- slug ? posts.filter(({ categories }) => categories?.find((c) => c.slug === slug)) : posts;
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
- posts.filter(({ slug }) => postSlugs.includes(slug));
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
- posts.filter(({ content, excerpt, title }) =>
39
- [content, excerpt, title].some((field) => field?.toLowerCase().includes(term.toLowerCase())),
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
- posts.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug)));
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
- posts: BlogPost[],
49
- pageNumber: number,
50
- postsPerPage: number,
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
- const startIndex = (pageNumber - 1) * postsPerPage;
53
- return posts.slice(startIndex, startIndex + postsPerPage);
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
- * Combined filter & sort pipeline (no slice).
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
- posts: BlogPost[],
61
- sortBy: SortBy,
62
- slug?: string | string[],
63
- postSlugs?: string[],
64
- term?: string,
65
- excludePostSlug?: string,
66
- ): BlogPost[] | null {
67
- let filtered: BlogPost[];
68
-
69
- if (typeof slug === "string") {
70
- filtered =
71
- postSlugs && postSlugs.length > 0
72
- ? filterPostsBySlugs(posts, postSlugs)
73
- : filterPostsByCategory(posts, slug);
74
- if (term) filtered = filterPostsByTerm(filtered, term);
75
- } else if (Array.isArray(slug)) {
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
  }
@@ -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
- const blocks = loadBlocks() as Record<string, Record<string, unknown>>;
14
- const results: T[] = [];
16
+ const blocks = loadBlocks() as Record<string, Record<string, unknown>>;
17
+ const results: T[] = [];
15
18
 
16
- for (const [key, value] of Object.entries(blocks)) {
17
- if (!key.startsWith(path) || !value || typeof value !== "object") {
18
- continue;
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
- const record = value[accessor] as T | undefined;
22
- if (!record) continue;
24
+ const record = value[accessor] as T | undefined;
25
+ if (!record) continue;
23
26
 
24
- const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", "");
27
+ const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", "");
25
28
 
26
- results.push({ ...record, id } as T);
27
- }
29
+ results.push({ ...record, id } as T);
30
+ }
28
31
 
29
- return results;
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
- createBlogLoaders,
9
- createBlogLoaders as createBlogCommerceLoaders,
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
- Author,
16
- BlogPost,
17
- BlogPostListingPage,
18
- BlogPostPage,
19
- Category,
20
- ExtraProps,
21
- PageInfo,
22
- Seo,
23
- SortBy,
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/blog";
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
- return {
35
- // Loader keys match the Deno app's __resolveType paths
36
- "blog/loaders/BlogPostPage.ts": BlogPostPageLoader,
37
- "blog/loaders/BlogPostPage": BlogPostPageLoader,
38
- "blog/loaders/BlogpostListing.ts": BlogpostListingLoader,
39
- "blog/loaders/BlogpostListing": BlogpostListingLoader,
40
- "blog/loaders/BlogRelatedPosts.ts": BlogRelatedPostsLoader,
41
- "blog/loaders/BlogRelatedPosts": BlogRelatedPostsLoader,
42
- "blog/loaders/GetCategories.ts": GetCategoriesLoader,
43
- "blog/loaders/GetCategories": GetCategoriesLoader,
44
- "blog/loaders/Blogpost.ts": BlogpostLoader,
45
- "blog/loaders/Blogpost": BlogpostLoader,
46
- "blog/loaders/Category.ts": CategoryLoader,
47
- "blog/loaders/Category": CategoryLoader,
48
- "blog/loaders/Author.ts": AuthorLoader,
49
- "blog/loaders/Author": AuthorLoader,
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
- // BlogPostItem: looks up a single post by slug, returns BlogPost
52
- "blog/loaders/BlogPostItem.ts": BlogPostItemLoader,
53
- "blog/loaders/BlogPostItem": BlogPostItemLoader,
54
- "blog/loaders/BlogpostList.ts": BlogpostListingLoader,
55
- "blog/loaders/BlogpostList": BlogpostListingLoader,
56
- };
55
+ return Object.fromEntries(
56
+ Object.entries(byName).flatMap(([key, fn]) => [
57
+ [key, fn],
58
+ [`${key}.ts`, fn],
59
+ ]),
60
+ );
57
61
  }