@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.
Files changed (66) hide show
  1. package/README.md +21 -0
  2. package/package.json +14 -5
  3. package/src/README.md +69 -0
  4. package/src/__tests__/blogRecords.test.ts +270 -0
  5. package/src/__tests__/categoryTree.test.ts +209 -0
  6. package/src/__tests__/date.test.ts +134 -0
  7. package/src/__tests__/handlePosts.test.ts +256 -230
  8. package/src/__tests__/loaders.test.ts +198 -154
  9. package/src/__tests__/mod.test.ts +6 -6
  10. package/src/__tests__/publicationStatus.test.ts +176 -0
  11. package/src/__tests__/records.test.ts +68 -68
  12. package/src/__tests__/seoSections.test.ts +245 -0
  13. package/src/actions/submitRating.ts +36 -0
  14. package/src/actions/submitReview.ts +95 -0
  15. package/src/actions/submitView.ts +28 -0
  16. package/src/client.ts +70 -0
  17. package/src/core/blogRecords.ts +94 -0
  18. package/src/core/categoryTree.ts +210 -0
  19. package/src/core/handlePosts.ts +160 -64
  20. package/src/core/records.ts +147 -12
  21. package/src/index.ts +48 -14
  22. package/src/loaderMap.ts +28 -24
  23. package/src/loaders/BlogPostItem.ts +15 -5
  24. package/src/loaders/BlogPostPage.ts +61 -24
  25. package/src/loaders/BlogRelatedPosts.ts +45 -45
  26. package/src/loaders/BlogpostList.ts +93 -0
  27. package/src/loaders/BlogpostListing.ts +151 -85
  28. package/src/loaders/GetCategories.ts +51 -28
  29. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  30. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  31. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  32. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  33. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  34. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  35. package/src/loaders/extensions/types.ts +15 -0
  36. package/src/manifest.gen.ts +82 -14
  37. package/src/mod.ts +73 -20
  38. package/src/registry.ts +5 -5
  39. package/src/sections/Seo/SeoBlogPost.tsx +77 -0
  40. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  41. package/src/sections/Template.tsx +106 -0
  42. package/src/sections/blocks/BlockImage.tsx +42 -0
  43. package/src/sections/blocks/Callout.tsx +64 -0
  44. package/src/sections/blocks/CardGroup.tsx +60 -0
  45. package/src/sections/blocks/Checklist.tsx +58 -0
  46. package/src/sections/blocks/Code.tsx +29 -0
  47. package/src/sections/blocks/Comparison.tsx +77 -0
  48. package/src/sections/blocks/Cta.tsx +22 -0
  49. package/src/sections/blocks/Divider.tsx +3 -0
  50. package/src/sections/blocks/Heading.tsx +28 -0
  51. package/src/sections/blocks/List.tsx +39 -0
  52. package/src/sections/blocks/Paragraph.tsx +15 -0
  53. package/src/sections/blocks/Quote.tsx +32 -0
  54. package/src/sections/blocks/Stat.tsx +23 -0
  55. package/src/sections/blocks/StatGroup.tsx +38 -0
  56. package/src/sections/blocks/Steps.tsx +64 -0
  57. package/src/sections/blocks/Table.tsx +80 -0
  58. package/src/sections/blocks/Video.tsx +39 -0
  59. package/src/static/css.ts +178 -0
  60. package/src/types.ts +333 -77
  61. package/src/utils/blocksToSections.ts +175 -0
  62. package/src/utils/constants.ts +1 -0
  63. package/src/utils/date.ts +95 -0
  64. package/src/utils/hardSanitize.ts +106 -0
  65. package/src/utils/jsonLD.ts +118 -0
  66. 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
+ };
@@ -1,88 +1,184 @@
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. */
29
- export const filterPostsByCategory = (posts: BlogPost[], slug?: string): BlogPost[] =>
30
- slug ? posts.filter(({ categories }) => categories?.find((c) => c.slug === slug)) : posts;
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
- posts.filter(({ slug }) => postSlugs.includes(slug));
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
- posts.filter(({ content, excerpt, title }) =>
39
- [content, excerpt, title].some((field) => field?.toLowerCase().includes(term.toLowerCase())),
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
- posts.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug)));
112
+ filterPostsByCategory(posts, slugs);
45
113
 
46
114
  /** Slice posts for pagination. */
47
115
  export const slicePosts = (
48
- posts: BlogPost[],
49
- pageNumber: number,
50
- postsPerPage: number,
116
+ posts: BlogPost[],
117
+ pageNumber: number,
118
+ postsPerPage: number,
51
119
  ): BlogPost[] => {
52
- const startIndex = (pageNumber - 1) * postsPerPage;
53
- return posts.slice(startIndex, startIndex + postsPerPage);
120
+ const startIndex = (pageNumber - 1) * postsPerPage;
121
+ return posts.slice(startIndex, startIndex + postsPerPage);
54
122
  };
55
123
 
56
124
  /**
57
- * Combined filter & sort pipeline (no slice).
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 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);
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
  }