@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { Category } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Categories come from a decofile a human edits by hand, so `parentSlug` can
|
|
5
|
+
* point at nothing, at itself, or back into a cycle. Every walk below is
|
|
6
|
+
* bounded twice — by a visited set and by this ceiling — so a malformed record
|
|
7
|
+
* can never hang a request.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_CATEGORY_DEPTH = 4;
|
|
10
|
+
|
|
11
|
+
const isValid = (c?: Category | null): c is Category =>
|
|
12
|
+
!!c &&
|
|
13
|
+
typeof c.slug === "string" &&
|
|
14
|
+
c.slug.length > 0 &&
|
|
15
|
+
typeof c.name === "string" &&
|
|
16
|
+
c.name.length > 0;
|
|
17
|
+
|
|
18
|
+
/** Slug -> category. First entry wins when a slug is duplicated. */
|
|
19
|
+
export const indexCategories = (
|
|
20
|
+
categories: Category[] | null | undefined,
|
|
21
|
+
): Map<string, Category> => {
|
|
22
|
+
const index = new Map<string, Category>();
|
|
23
|
+
for (const category of categories ?? []) {
|
|
24
|
+
if (isValid(category) && !index.has(category.slug)) {
|
|
25
|
+
index.set(category.slug, category);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return index;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const parentOf = (category: Category, index: Map<string, Category>): Category | undefined => {
|
|
32
|
+
const parentSlug = category.parentSlug;
|
|
33
|
+
if (typeof parentSlug !== "string" || !parentSlug || parentSlug === category.slug) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return index.get(parentSlug);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The ancestor chain of `slug`, root first and the category itself last —
|
|
41
|
+
* exactly the breadcrumb sequence.
|
|
42
|
+
*
|
|
43
|
+
* Returns null whenever the chain cannot be trusted: the slug is unknown, the
|
|
44
|
+
* walk ran into a cycle, or it went past MAX_CATEGORY_DEPTH. A truncated chain
|
|
45
|
+
* is worse than none — it would name a breadcrumb and a canonical URL missing
|
|
46
|
+
* their topmost segments — so callers fall back to the flat behaviour instead.
|
|
47
|
+
* A dangling `parentSlug` or a self-reference is not an error: the walk just
|
|
48
|
+
* ends there, treating the category as a root.
|
|
49
|
+
*/
|
|
50
|
+
export const ancestorsOf = (slug: string, index: Map<string, Category>): Category[] | null => {
|
|
51
|
+
const start = index.get(slug);
|
|
52
|
+
if (!start) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const chain: Category[] = [];
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
let current: Category | undefined = start;
|
|
59
|
+
|
|
60
|
+
while (current) {
|
|
61
|
+
if (seen.has(current.slug) || chain.length >= MAX_CATEGORY_DEPTH) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
seen.add(current.slug);
|
|
65
|
+
chain.push(current);
|
|
66
|
+
current = parentOf(current, index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return chain.reverse();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Children indexed by parent slug, skipping self-references. */
|
|
73
|
+
const childrenIndex = (categories: Category[]): Map<string, Category[]> => {
|
|
74
|
+
const children = new Map<string, Category[]>();
|
|
75
|
+
for (const category of categories) {
|
|
76
|
+
const parentSlug = category.parentSlug;
|
|
77
|
+
if (
|
|
78
|
+
!isValid(category) ||
|
|
79
|
+
typeof parentSlug !== "string" ||
|
|
80
|
+
!parentSlug ||
|
|
81
|
+
parentSlug === category.slug
|
|
82
|
+
) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const siblings = children.get(parentSlug);
|
|
86
|
+
siblings ? siblings.push(category) : children.set(parentSlug, [category]);
|
|
87
|
+
}
|
|
88
|
+
return children;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `slug` plus every category below it. Breadth-first with a visited set, so a
|
|
93
|
+
* cycle yields a finite set instead of looping. Used to make a parent category
|
|
94
|
+
* list its children's posts.
|
|
95
|
+
*/
|
|
96
|
+
export const descendantSlugs = (
|
|
97
|
+
slug: string,
|
|
98
|
+
categories: Category[] | null | undefined,
|
|
99
|
+
): string[] => {
|
|
100
|
+
const children = childrenIndex(categories ?? []);
|
|
101
|
+
const seen = new Set<string>([slug]);
|
|
102
|
+
let frontier = [slug];
|
|
103
|
+
|
|
104
|
+
for (let depth = 0; depth < MAX_CATEGORY_DEPTH && frontier.length; depth++) {
|
|
105
|
+
const next: string[] = [];
|
|
106
|
+
for (const parent of frontier) {
|
|
107
|
+
for (const child of children.get(parent) ?? []) {
|
|
108
|
+
if (!seen.has(child.slug)) {
|
|
109
|
+
seen.add(child.slug);
|
|
110
|
+
next.push(child.slug);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
frontier = next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [...seen];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Direct children of `slug`, name-sorted. */
|
|
121
|
+
export const childrenOf = (slug: string, categories: Category[] | null | undefined): Category[] =>
|
|
122
|
+
(childrenIndex(categories ?? []).get(slug) ?? []).sort((a, b) => a.name.localeCompare(b.name));
|
|
123
|
+
|
|
124
|
+
/** "pai/filho" — the URL path a category chain occupies. */
|
|
125
|
+
export const categoryPathname = (chain: Category[]): string => chain.map((c) => c.slug).join("/");
|
|
126
|
+
|
|
127
|
+
interface PathOptions {
|
|
128
|
+
/**
|
|
129
|
+
* The category segments the router handed the loader. They must be the tail
|
|
130
|
+
* of the pathname, otherwise the slug was configured by hand and the URL
|
|
131
|
+
* carries no category to rewrite.
|
|
132
|
+
*/
|
|
133
|
+
requested?: string[];
|
|
134
|
+
/**
|
|
135
|
+
* Known category slugs, for routes that don't hand the category over — the
|
|
136
|
+
* category segments are the trailing ones that name a real category.
|
|
137
|
+
*/
|
|
138
|
+
knownSlugs?: Set<string>;
|
|
139
|
+
/** Segment that stays at the end of the path — a post slug. */
|
|
140
|
+
trailing?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Rewrites the category portion of a URL with the canonical chain, keeping the
|
|
145
|
+
* route prefix (`/blog`) and an optional trailing post slug. A request that
|
|
146
|
+
* reaches a child through a stale or wrong path still renders; this is what
|
|
147
|
+
* points its canonical at the one true URL so Google consolidates instead of
|
|
148
|
+
* seeing duplicates.
|
|
149
|
+
*
|
|
150
|
+
* Returns null when the URL has no category portion to replace — a route
|
|
151
|
+
* without a category segment, or a slug that didn't come from the route. The
|
|
152
|
+
* caller keeps the request URL rather than inventing a path that 404s.
|
|
153
|
+
*/
|
|
154
|
+
export const withCategoryPath = (
|
|
155
|
+
url: string | URL,
|
|
156
|
+
chain: Category[],
|
|
157
|
+
{ requested, knownSlugs, trailing }: PathOptions = {},
|
|
158
|
+
): string | null => {
|
|
159
|
+
if (chain.length === 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parsed = new URL(url);
|
|
164
|
+
let segments = parsed.pathname.split("/").filter(Boolean);
|
|
165
|
+
|
|
166
|
+
if (trailing) {
|
|
167
|
+
if (segments[segments.length - 1] !== trailing) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
segments = segments.slice(0, -1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (requested) {
|
|
174
|
+
const size = requested.length;
|
|
175
|
+
const tail = segments.slice(Math.max(segments.length - size, 0));
|
|
176
|
+
if (size === 0 || tail.join("/") !== requested.join("/")) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
segments = segments.slice(0, segments.length - size);
|
|
180
|
+
} else if (knownSlugs) {
|
|
181
|
+
const before = segments.length;
|
|
182
|
+
// Bounded by the chain length: the request can carry at most as many
|
|
183
|
+
// category segments as the canonical chain has. Without the bound, a
|
|
184
|
+
// category whose slug collides with the route prefix — `/blog/blog/a-post`
|
|
185
|
+
// for a category slugged "blog" — strips the prefix too and canonicalizes
|
|
186
|
+
// to a path the site never serves.
|
|
187
|
+
while (
|
|
188
|
+
segments.length &&
|
|
189
|
+
before - segments.length < chain.length &&
|
|
190
|
+
knownSlugs.has(segments[segments.length - 1])
|
|
191
|
+
) {
|
|
192
|
+
segments = segments.slice(0, -1);
|
|
193
|
+
}
|
|
194
|
+
if (segments.length === before) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Slugs are authored by hand, so a stray "/", "?" or "#" would otherwise stop
|
|
202
|
+
// being one path segment and start being a new segment, a query or a
|
|
203
|
+
// fragment. Encoded, a malformed slug yields a 404 instead of a canonical
|
|
204
|
+
// pointing at a different page.
|
|
205
|
+
const pathname = `/${[...segments, ...chain.map((c) => c.slug), ...(trailing ? [trailing] : [])]
|
|
206
|
+
.map(encodeURIComponent)
|
|
207
|
+
.join("/")}`;
|
|
208
|
+
|
|
209
|
+
return new URL(pathname, parsed.origin).href;
|
|
210
|
+
};
|
package/src/core/handlePosts.ts
CHANGED
|
@@ -1,88 +1,184 @@
|
|
|
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
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Returns a filtered BlogPost list.
|
|
81
|
+
*
|
|
82
|
+
* @param posts Posts to be handled
|
|
83
|
+
* @param slug Category slug, or a list of slugs a post may belong to any of.
|
|
84
|
+
* A list is how a parent category pulls in its descendants' posts.
|
|
85
|
+
*
|
|
86
|
+
* An omitted slug means "no category filter" and keeps every post. An *empty
|
|
87
|
+
* list* is the opposite — an explicit, empty set of categories to match, which
|
|
88
|
+
* nothing belongs to. Upstream conflates the two and answers every post to
|
|
89
|
+
* both, which turns a related-posts widget for a post with no categories into
|
|
90
|
+
* a second listing of the whole blog.
|
|
91
|
+
*/
|
|
92
|
+
export const filterPostsByCategory = (posts: BlogPost[], slug?: string | string[]): BlogPost[] => {
|
|
93
|
+
if (slug === undefined || slug === "") {
|
|
94
|
+
return posts;
|
|
95
|
+
}
|
|
96
|
+
const slugs = new Set(Array.isArray(slug) ? slug : [slug]);
|
|
97
|
+
return posts.filter(({ categories }) => categories?.some((c) => slugs.has(c?.slug)));
|
|
98
|
+
};
|
|
31
99
|
|
|
32
100
|
/** Filter posts whose slug is in the given list. */
|
|
33
101
|
export const filterPostsBySlugs = (posts: BlogPost[], postSlugs: string[]): BlogPost[] =>
|
|
34
|
-
|
|
102
|
+
posts.filter(({ slug }) => postSlugs.includes(slug));
|
|
35
103
|
|
|
36
104
|
/** Filter posts matching a search term (title, excerpt, content). */
|
|
37
105
|
export const filterPostsByTerm = (posts: BlogPost[], term: string): BlogPost[] =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
106
|
+
posts.filter(({ content, excerpt, title }) =>
|
|
107
|
+
[content, excerpt, title].some((field) => field?.toLowerCase().includes(term.toLowerCase())),
|
|
108
|
+
);
|
|
41
109
|
|
|
42
110
|
/** Filter posts whose categories overlap with the given slug array. */
|
|
43
111
|
export const filterRelatedPosts = (posts: BlogPost[], slugs: string[]): BlogPost[] =>
|
|
44
|
-
|
|
112
|
+
filterPostsByCategory(posts, slugs);
|
|
45
113
|
|
|
46
114
|
/** Slice posts for pagination. */
|
|
47
115
|
export const slicePosts = (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
116
|
+
posts: BlogPost[],
|
|
117
|
+
pageNumber: number,
|
|
118
|
+
postsPerPage: number,
|
|
51
119
|
): BlogPost[] => {
|
|
52
|
-
|
|
53
|
-
|
|
120
|
+
const startIndex = (pageNumber - 1) * postsPerPage;
|
|
121
|
+
return posts.slice(startIndex, startIndex + postsPerPage);
|
|
54
122
|
};
|
|
55
123
|
|
|
56
124
|
/**
|
|
57
|
-
*
|
|
125
|
+
* A record without a slug has no route, so it can never be rendered: listing it
|
|
126
|
+
* only produces cards linking to the listing itself. Posts that aren't live are
|
|
127
|
+
* unreachable for a different reason — either the CMS doesn't consider them
|
|
128
|
+
* ready, or they're scheduled for an instant that hasn't arrived yet — but the
|
|
129
|
+
* outcome is the same, so both are dropped here, before slicePosts, so `count`
|
|
130
|
+
* still yields `count` renderable posts.
|
|
131
|
+
*
|
|
132
|
+
* A scheduled post crossing its instant flips this filter on the next request
|
|
133
|
+
* that misses cache; nothing re-deploys and no record is rewritten.
|
|
58
134
|
*/
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
135
|
+
export const filterRoutablePosts = (posts: BlogPost[]): BlogPost[] =>
|
|
136
|
+
// Records come straight from the CMS, so `slug` is only a string by
|
|
137
|
+
// convention: the typeof guard keeps a malformed one from throwing here and
|
|
138
|
+
// taking the whole listing down with it.
|
|
139
|
+
posts.filter((post) => typeof post.slug === "string" && post.slug.trim() && isLivePost(post));
|
|
140
|
+
|
|
141
|
+
const filterPosts = (
|
|
142
|
+
allPosts: BlogPost[],
|
|
143
|
+
slug?: string | string[],
|
|
144
|
+
postSlugs?: string[],
|
|
145
|
+
term?: string,
|
|
146
|
+
): BlogPost[] => {
|
|
147
|
+
const posts = filterRoutablePosts(allPosts);
|
|
148
|
+
|
|
149
|
+
const byCategory =
|
|
150
|
+
postSlugs && postSlugs.length > 0
|
|
151
|
+
? filterPostsBySlugs(posts, postSlugs)
|
|
152
|
+
: filterPostsByCategory(posts, slug);
|
|
153
|
+
|
|
154
|
+
return term ? filterPostsByTerm(byCategory, term) : byCategory;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Returns a filtered and sorted BlogPost list. It does not slice.
|
|
159
|
+
*
|
|
160
|
+
* @param posts Posts to be handled
|
|
161
|
+
* @param sortBy Sort option
|
|
162
|
+
* @param slug Category slug, or an array of slugs, to filter by
|
|
163
|
+
* @param postSlugs Specific slugs to filter by
|
|
164
|
+
* @param term Term to filter by
|
|
165
|
+
* @param excludePostSlug Slug to exclude
|
|
166
|
+
*/
|
|
167
|
+
export default async function handlePosts(
|
|
168
|
+
posts: BlogPost[],
|
|
169
|
+
sortBy: SortBy,
|
|
170
|
+
slug?: string | string[],
|
|
171
|
+
postSlugs?: string[],
|
|
172
|
+
term?: string,
|
|
173
|
+
excludePostSlug?: string,
|
|
174
|
+
): Promise<BlogPost[] | null> {
|
|
175
|
+
const filteredPosts = filterPosts(posts, slug, postSlugs, term).filter(
|
|
176
|
+
({ slug: postSlug }) => postSlug !== excludePostSlug,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (filteredPosts.length === 0) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return sortPosts(filteredPosts, sortBy);
|
|
88
184
|
}
|