@decocms/apps-blog 7.52.1 → 7.54.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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-blog",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.54.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco app: blog content and CMS integration",
|
|
6
6
|
"repository": {
|
|
@@ -16,10 +16,19 @@
|
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./src/index.ts",
|
|
18
18
|
"./mod": "./src/mod.ts",
|
|
19
|
+
"./client": "./src/client.ts",
|
|
19
20
|
"./types": "./src/types.ts",
|
|
21
|
+
"./manifest.gen": "./src/manifest.gen.ts",
|
|
20
22
|
"./loaderMap": "./src/loaderMap.ts",
|
|
21
23
|
"./loaders/*": "./src/loaders/*.ts",
|
|
24
|
+
"./loaders/extensions/*": "./src/loaders/extensions/*.ts",
|
|
25
|
+
"./actions/*": "./src/actions/*.ts",
|
|
22
26
|
"./core/*": "./src/core/*.ts",
|
|
27
|
+
"./utils/*": "./src/utils/*.ts",
|
|
28
|
+
"./sections/*": "./src/sections/*.tsx",
|
|
29
|
+
"./sections/Seo/*": "./src/sections/Seo/*.tsx",
|
|
30
|
+
"./sections/blocks/*": "./src/sections/blocks/*.tsx",
|
|
31
|
+
"./static/*": "./src/static/*.ts",
|
|
23
32
|
"./registry": "./src/registry.ts"
|
|
24
33
|
},
|
|
25
34
|
"scripts": {
|
|
@@ -29,9 +38,9 @@
|
|
|
29
38
|
"lint:unused": "knip"
|
|
30
39
|
},
|
|
31
40
|
"dependencies": {
|
|
32
|
-
"@decocms/blocks": "7.
|
|
33
|
-
"@decocms/apps-commerce": "7.
|
|
34
|
-
"@decocms/apps-website": "7.
|
|
41
|
+
"@decocms/blocks": "7.54.0",
|
|
42
|
+
"@decocms/apps-commerce": "7.54.0",
|
|
43
|
+
"@decocms/apps-website": "7.54.0"
|
|
35
44
|
},
|
|
36
45
|
"peerDependencies": {
|
|
37
46
|
"react": "^19.0.0",
|
package/src/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# `@decocms/apps-blog` — port status
|
|
2
|
+
|
|
3
|
+
Source of truth: [`deco-cx/apps/blog`](https://github.com/deco-cx/apps/tree/main/blog).
|
|
4
|
+
This package was originally copied from `apps-start`'s already-reduced blog port
|
|
5
|
+
(Task 13 of `docs/apps-monorepo-migration-plan.md`), not from the Deno original,
|
|
6
|
+
so a chunk of the upstream surface was missing. This file tracks what is now in
|
|
7
|
+
sync and what deliberately isn't.
|
|
8
|
+
|
|
9
|
+
## Ported
|
|
10
|
+
|
|
11
|
+
| Upstream | Here | Notes |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `types.ts` | `src/types.ts` | Full parity, incl. `PostStatus`, `isPublishedStatus`, `isLivePost`, `Publisher`, `Review`/`Rating`/`AggregateRating`, `Ignore`, `ImageCarousel`. `Section` is redeclared as the opaque `any` type — see the docstring, it's what triggers the admin's section picker. |
|
|
14
|
+
| `utils/{date,constants,jsonLD,sanitizeHtml,hardSanitize,blocksToSections}.ts` | `src/utils/` | Near-literal; pure functions. |
|
|
15
|
+
| `core/handlePosts.ts` | `src/core/handlePosts.ts` | Incl. `filterRoutablePosts` and UTC-pinned date sorting. |
|
|
16
|
+
| `core/records.ts` | `src/core/records.ts` | `getRecordsByPath` uses `loadBlocks()` instead of `ctx.get(resolvables)`. Ratings/reviews go through the adapter (below). |
|
|
17
|
+
| `loaders/*.ts` (9) | `src/loaders/` | `ctx` replaced by `src/client.ts`; `(props, req?)` signatures. |
|
|
18
|
+
| `loaders/extensions/*/[ratings,reviews].ts` (6) | `src/loaders/extensions/` | — |
|
|
19
|
+
| `actions/submit{Rating,Review,View}.ts` | `src/actions/` | Persistence via the adapter. |
|
|
20
|
+
| `sections/Seo/*.tsx` (2) | `src/sections/Seo/` | Reuse `Seo`/`renderTemplateString`/`SEOSection` from `@decocms/apps-website/components/Seo`. |
|
|
21
|
+
| `sections/Template.tsx` | `src/sections/Template.tsx` | `renderSection` replaced by `getSyncComponent` from `@decocms/blocks/cms/client`. |
|
|
22
|
+
| `sections/blocks/*.tsx` (17) | `src/sections/blocks/` | `class` → `className`, SVG attrs camelCased; otherwise unchanged. |
|
|
23
|
+
| `static/css.ts` | `src/static/css.ts` | Verbatim. |
|
|
24
|
+
|
|
25
|
+
## Deliberately not ported
|
|
26
|
+
|
|
27
|
+
| Upstream | Why |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `db/schema.ts` | Drizzle/SQLite table definitions for the `records` app. Neither the app nor `drizzle-orm` exists in this monorepo, and pulling them in would make every site installing the blog carry a DB client. The table/column names now live on the site's side of `src/core/blogRecords.ts`. |
|
|
30
|
+
| `loaders/extensions/{BlogpostList,BlogpostListing,BlogpostPage}.ts` | Three-line delegations to `website/loaders/extension.ts` — the *generic* extension-composition loader. That loader has not been ported into `@decocms/apps-website`; reimplementing it here would put a website-app concern in the wrong package. The 6 concrete ratings/reviews extensions are ported and callable directly. **Tracked gap: port `website/loaders/extension.ts` into `@decocms/apps-website`.** |
|
|
31
|
+
| `manifest.gen.ts` (as generated) | `scripts/generate-manifests.ts` doesn't exist in this repo (deferred, `docs/apps-monorepo-migration-plan.md`). `src/manifest.gen.ts` is hand-maintained — add new loaders/actions/sections to it manually. |
|
|
32
|
+
| `mod.ts`'s `preview` / `PreviewContainer` | `utils/preview.tsx` is Deno-app scaffolding with no equivalent here; `preview` is `undefined`, matching every other `apps-*` package. |
|
|
33
|
+
|
|
34
|
+
## Ratings, reviews and view counts
|
|
35
|
+
|
|
36
|
+
These three features need persistence. Upstream reaches the separate `records`
|
|
37
|
+
app (`await ctx.invoke.records.loaders.drizzle()`); here the dependency is
|
|
38
|
+
inverted, the same way `@decocms/blocks-admin` inverts KV via
|
|
39
|
+
`setFastDeployKVGetter`:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { setBlogRecordsAdapter } from "@decocms/apps-blog";
|
|
43
|
+
|
|
44
|
+
setBlogRecordsAdapter({
|
|
45
|
+
listPostViews: () => db.select(...),
|
|
46
|
+
incrementPostView: (id) => ...,
|
|
47
|
+
// listRatings, upsertRating, listReviews, getReview, createReview, updateReview
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every method is optional. With **no adapter registered**, reads return `[]`,
|
|
52
|
+
writes return `null`, `submitView` returns `{ count: 0 }`, and `view_asc` /
|
|
53
|
+
`view_desc` sorting falls back to date order — so a blog with no comments and no
|
|
54
|
+
view counter works untouched. Note this differs from upstream on one point:
|
|
55
|
+
the Deno app *threw* `"Deco Records not installed!"` on a view sort without the
|
|
56
|
+
backend; degrading was chosen instead, since view sorting is an enhancement and
|
|
57
|
+
a missing backend shouldn't blank a listing.
|
|
58
|
+
|
|
59
|
+
## Divergences from upstream worth knowing
|
|
60
|
+
|
|
61
|
+
- **Title and view sorts are inverted** — `title_asc` yields Z-A, and
|
|
62
|
+
`view_desc` puts the *least*-viewed post first. Both come from upstream's
|
|
63
|
+
comparator (`sortOrder === "desc" ? comparison : -comparison` over an `a - b`
|
|
64
|
+
difference); only the `date` branch, which compares `b - a`, reads the right
|
|
65
|
+
way round. Preserved on purpose so a site moving off the Deno app sees no
|
|
66
|
+
reordering — see the comments in `src/core/handlePosts.ts`. Fix upstream
|
|
67
|
+
first, then here.
|
|
68
|
+
- **`sortPosts([])` throws** (it reads `blogPosts[0]`). Upstream does too;
|
|
69
|
+
`handlePosts` guards the empty case before calling it.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope: the injectable persistence port that replaces the Deno app's `records`
|
|
3
|
+
* dependency — that reads/writes route through the registered adapter, and that
|
|
4
|
+
* everything degrades safely when a site registers none (or a partial one).
|
|
5
|
+
*
|
|
6
|
+
* The degrade path is the important half: it is the default for any site that
|
|
7
|
+
* installs the blog app without a database, so a regression here breaks blogs
|
|
8
|
+
* that never asked for ratings at all.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not covered: any real database. The adapter boundary exists
|
|
11
|
+
* precisely so this package never needs one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import submitRating from "../actions/submitRating";
|
|
16
|
+
import submitReview from "../actions/submitReview";
|
|
17
|
+
import submitView from "../actions/submitView";
|
|
18
|
+
import {
|
|
19
|
+
type BlogRecordsAdapter,
|
|
20
|
+
type CreateReviewInput,
|
|
21
|
+
type ListReviewsOptions,
|
|
22
|
+
setBlogRecordsAdapter,
|
|
23
|
+
type UpdateReviewInput,
|
|
24
|
+
} from "../core/blogRecords";
|
|
25
|
+
import { sortPosts } from "../core/handlePosts";
|
|
26
|
+
import { getRatings, getReviews, getReviewsBySlug } from "../core/records";
|
|
27
|
+
import type { BlogPost, Review } from "../types";
|
|
28
|
+
|
|
29
|
+
const G = globalThis as unknown as { __decoBlogRecordsAdapter?: BlogRecordsAdapter };
|
|
30
|
+
|
|
31
|
+
const post = (slug: string, date = "2024-01-01"): BlogPost => ({
|
|
32
|
+
title: slug,
|
|
33
|
+
slug,
|
|
34
|
+
date,
|
|
35
|
+
excerpt: "",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const clearAdapter = () => {
|
|
39
|
+
delete G.__decoBlogRecordsAdapter;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
beforeEach(clearAdapter);
|
|
43
|
+
afterEach(clearAdapter);
|
|
44
|
+
|
|
45
|
+
describe("with no adapter registered", () => {
|
|
46
|
+
it("getRatings yields a zeroed aggregate rather than throwing", async () => {
|
|
47
|
+
const result = await getRatings({ post: post("a") });
|
|
48
|
+
expect(result.aggregateRating).toEqual({
|
|
49
|
+
"@type": "AggregateRating",
|
|
50
|
+
ratingCount: 0,
|
|
51
|
+
ratingValue: 0,
|
|
52
|
+
bestRating: 5,
|
|
53
|
+
worstRating: 1,
|
|
54
|
+
});
|
|
55
|
+
expect(result.contentRating).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("getReviews yields an empty list and a zero count", async () => {
|
|
59
|
+
const result = await getReviews({ post: post("a") });
|
|
60
|
+
expect(result.review).toEqual([]);
|
|
61
|
+
expect(result.aggregateRating?.reviewCount).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("write actions report failure instead of throwing", async () => {
|
|
65
|
+
expect(
|
|
66
|
+
await submitRating({ itemReviewed: "a", author: { email: "a@b.com" }, ratingValue: 5 }),
|
|
67
|
+
).toBeNull();
|
|
68
|
+
expect(await submitReview({ action: "create", reviewBody: "hi" })).toBeNull();
|
|
69
|
+
// A view beacon gets a well-formed response, not an error.
|
|
70
|
+
expect(await submitView({ id: "a" })).toEqual({ count: 0 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("view sorting falls back to date order instead of throwing", async () => {
|
|
74
|
+
// Upstream threw "Deco Records not installed!" here; degrading is the
|
|
75
|
+
// deliberate difference — see this package's README.
|
|
76
|
+
const posts = [post("old", "2024-01-01"), post("new", "2024-03-01")];
|
|
77
|
+
const sorted = await sortPosts(posts, "view_desc");
|
|
78
|
+
expect(sorted.map((p) => p.slug)).toEqual(["new", "old"]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("with an adapter registered", () => {
|
|
83
|
+
it("getRatings aggregates the adapter's rows and stamps the scale", async () => {
|
|
84
|
+
setBlogRecordsAdapter({
|
|
85
|
+
listRatings: async () => [
|
|
86
|
+
{ "@type": "Rating", ratingValue: 5 },
|
|
87
|
+
{ "@type": "Rating", ratingValue: 3 },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await getRatings({ post: post("a") });
|
|
92
|
+
expect(result.aggregateRating).toMatchObject({ ratingCount: 2, ratingValue: 4 });
|
|
93
|
+
// bestRating/worstRating are a property of this app's UI, not of the row.
|
|
94
|
+
expect(result.contentRating?.[0]).toMatchObject({ bestRating: 5, worstRating: 1 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("getRatings honours ignoreRatings when computing the aggregate", async () => {
|
|
98
|
+
setBlogRecordsAdapter({
|
|
99
|
+
listRatings: async () => [
|
|
100
|
+
{ "@type": "Rating", ratingValue: 5 },
|
|
101
|
+
{ "@type": "Rating", ratingValue: 1, additionalType: "spam" },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const result = await getRatings({
|
|
106
|
+
post: post("a"),
|
|
107
|
+
ignoreRatings: { active: true, markedAs: ["spam"] },
|
|
108
|
+
});
|
|
109
|
+
expect(result.aggregateRating).toMatchObject({ ratingCount: 1, ratingValue: 5 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("onlyAggregate omits the per-rating list", async () => {
|
|
113
|
+
setBlogRecordsAdapter({ listRatings: async () => [{ "@type": "Rating", ratingValue: 4 }] });
|
|
114
|
+
const result = await getRatings({ post: post("a"), onlyAggregate: true });
|
|
115
|
+
expect(result.contentRating).toBeUndefined();
|
|
116
|
+
expect(result.aggregateRating?.ratingValue).toBe(4);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("translates an active ignoreReviews into excludeAdditionalTypes", async () => {
|
|
120
|
+
const listReviews = vi.fn(async (_opts: ListReviewsOptions): Promise<Review[]> => []);
|
|
121
|
+
setBlogRecordsAdapter({ listReviews });
|
|
122
|
+
|
|
123
|
+
await getReviewsBySlug({
|
|
124
|
+
slug: "a",
|
|
125
|
+
ignoreReviews: { active: true, markedAs: ["pending"] },
|
|
126
|
+
orderBy: "date_asc",
|
|
127
|
+
});
|
|
128
|
+
expect(listReviews).toHaveBeenCalledWith({
|
|
129
|
+
slug: "a",
|
|
130
|
+
excludeAdditionalTypes: ["pending"],
|
|
131
|
+
orderBy: "date_asc",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("leaves excludeAdditionalTypes unset when ignoreReviews is inactive or empty", async () => {
|
|
136
|
+
const listReviews = vi.fn(async (_opts: ListReviewsOptions): Promise<Review[]> => []);
|
|
137
|
+
setBlogRecordsAdapter({ listReviews });
|
|
138
|
+
|
|
139
|
+
await getReviewsBySlug({ slug: "a", ignoreReviews: { active: false, markedAs: ["x"] } });
|
|
140
|
+
await getReviewsBySlug({ slug: "a", ignoreReviews: { active: true, markedAs: [] } });
|
|
141
|
+
for (const call of listReviews.mock.calls) {
|
|
142
|
+
expect(call[0]).toMatchObject({ excludeAdditionalTypes: undefined });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("defaults review ordering to date_desc", async () => {
|
|
147
|
+
const listReviews = vi.fn(async (_opts: ListReviewsOptions): Promise<Review[]> => []);
|
|
148
|
+
setBlogRecordsAdapter({ listReviews });
|
|
149
|
+
await getReviewsBySlug({ slug: "a" });
|
|
150
|
+
expect(listReviews).toHaveBeenCalledWith(expect.objectContaining({ orderBy: "date_desc" }));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("stamps view counts onto posts and sorts by them (inverted, per upstream)", async () => {
|
|
154
|
+
setBlogRecordsAdapter({
|
|
155
|
+
listPostViews: async () => [
|
|
156
|
+
{ id: "low", userInteractionCount: 2 },
|
|
157
|
+
{ id: "high", userInteractionCount: 90 },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const posts = [post("low"), post("high")];
|
|
162
|
+
// `view_desc` yields least-viewed-first: upstream's comparator is `a - b`
|
|
163
|
+
// under `desc`. Preserved deliberately — see the comment in handlePosts.ts.
|
|
164
|
+
expect((await sortPosts(posts, "view_desc")).map((p) => p.slug)).toEqual(["low", "high"]);
|
|
165
|
+
expect((await sortPosts(posts, "view_asc")).map((p) => p.slug)).toEqual(["high", "low"]);
|
|
166
|
+
// Either way the counts are attached to the posts, which is what the SEO
|
|
167
|
+
// JSON-LD reads as interactionStatistic.
|
|
168
|
+
expect(posts.find((p) => p.slug === "high")?.interactionStatistic).toEqual({
|
|
169
|
+
"@type": "InteractionCounter",
|
|
170
|
+
userInteractionCount: 90,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("increments a view through the adapter", async () => {
|
|
175
|
+
setBlogRecordsAdapter({ incrementPostView: async () => ({ count: 7 }) });
|
|
176
|
+
expect(await submitView({ id: "a" })).toEqual({ count: 7 });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("swallows an adapter failure on read and returns the empty shape", async () => {
|
|
180
|
+
setBlogRecordsAdapter({
|
|
181
|
+
listRatings: async () => {
|
|
182
|
+
throw new Error("db down");
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const result = await getRatings({ post: post("a") });
|
|
186
|
+
expect(result.contentRating).toEqual([]);
|
|
187
|
+
expect(result.aggregateRating?.ratingCount).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("swallows an adapter failure on write and reports it as null", async () => {
|
|
191
|
+
setBlogRecordsAdapter({
|
|
192
|
+
upsertRating: async () => {
|
|
193
|
+
throw new Error("db down");
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
expect(
|
|
197
|
+
await submitRating({ itemReviewed: "a", author: { email: "a@b.com" }, ratingValue: 5 }),
|
|
198
|
+
).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses to update a review that does not exist", async () => {
|
|
202
|
+
setBlogRecordsAdapter({
|
|
203
|
+
getReview: async () => null,
|
|
204
|
+
updateReview: async () => null,
|
|
205
|
+
});
|
|
206
|
+
expect(await submitReview({ action: "update", id: "missing", reviewBody: "x" })).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("patches an update over the stored review, preserving untouched fields", async () => {
|
|
210
|
+
const updateReview = vi.fn(
|
|
211
|
+
async (_id: string, _patch: UpdateReviewInput): Promise<Review | null> => null,
|
|
212
|
+
);
|
|
213
|
+
setBlogRecordsAdapter({
|
|
214
|
+
getReview: async () => ({
|
|
215
|
+
"@type": "Review",
|
|
216
|
+
id: "r1",
|
|
217
|
+
itemReviewed: "a",
|
|
218
|
+
reviewBody: "old body",
|
|
219
|
+
reviewHeadline: "old headline",
|
|
220
|
+
additionalType: "approved",
|
|
221
|
+
datePublished: "2024-01-01T00:00:00.000Z",
|
|
222
|
+
author: { name: "Ana" },
|
|
223
|
+
}),
|
|
224
|
+
updateReview,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = await submitReview({ action: "update", id: "r1", reviewBody: "new body" });
|
|
228
|
+
|
|
229
|
+
// Only the sent field changes; headline/status/author/datePublished survive.
|
|
230
|
+
expect(updateReview).toHaveBeenCalledWith(
|
|
231
|
+
"r1",
|
|
232
|
+
expect.objectContaining({
|
|
233
|
+
reviewBody: "new body",
|
|
234
|
+
reviewHeadline: "old headline",
|
|
235
|
+
additionalType: "approved",
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
expect(result).toMatchObject({
|
|
239
|
+
"@type": "Review",
|
|
240
|
+
id: "r1",
|
|
241
|
+
reviewBody: "new body",
|
|
242
|
+
datePublished: "2024-01-01T00:00:00.000Z",
|
|
243
|
+
author: { name: "Ana" },
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("creates a review with both timestamps set", async () => {
|
|
248
|
+
const createReview = vi.fn(async (_input: CreateReviewInput): Promise<Review | null> => null);
|
|
249
|
+
setBlogRecordsAdapter({ createReview });
|
|
250
|
+
|
|
251
|
+
const result = await submitReview({
|
|
252
|
+
action: "create",
|
|
253
|
+
itemReviewed: "a",
|
|
254
|
+
reviewBody: "great",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const arg = createReview.mock.calls[0][0];
|
|
258
|
+
expect(arg.datePublished).toBe(arg.dateModified);
|
|
259
|
+
expect(result).toMatchObject({ "@type": "Review", reviewBody: "great" });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("returns null when the adapter implements only part of the port", async () => {
|
|
263
|
+
// A site that wants view counts and nothing else.
|
|
264
|
+
setBlogRecordsAdapter({ incrementPostView: async () => ({ count: 1 }) });
|
|
265
|
+
expect(await submitReview({ action: "create", reviewBody: "x" })).toBeNull();
|
|
266
|
+
expect(
|
|
267
|
+
await submitRating({ itemReviewed: "a", author: { email: "a@b.com" }, ratingValue: 5 }),
|
|
268
|
+
).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope: `utils/date.ts` — the UTC pinning that makes post ordering and
|
|
3
|
+
* scheduled go-live instants independent of the server's timezone, plus
|
|
4
|
+
* `scheduledTime`'s strict parse.
|
|
5
|
+
*
|
|
6
|
+
* The timezone cases below are the point of the module. They are written by
|
|
7
|
+
* comparing timestamps rather than by mutating `process.env.TZ` mid-run, which
|
|
8
|
+
* V8 caches per-isolate and would not reliably take effect; the repo-level
|
|
9
|
+
* check is `TZ=... bun run test` producing identical output, and the assertions
|
|
10
|
+
* here encode the invariant that makes that true.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { sortPosts } from "../core/handlePosts";
|
|
15
|
+
import type { BlogPost } from "../types";
|
|
16
|
+
import { dateToTime, scheduledTime } from "../utils/date";
|
|
17
|
+
|
|
18
|
+
const post = (slug: string, date: string): BlogPost => ({
|
|
19
|
+
title: slug,
|
|
20
|
+
slug,
|
|
21
|
+
date,
|
|
22
|
+
excerpt: "",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("dateToTime", () => {
|
|
26
|
+
it("reads a bare date as midnight UTC, not midnight local", () => {
|
|
27
|
+
expect(dateToTime("2024-06-01")).toBe(Date.UTC(2024, 5, 1));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("pins an offset-less datetime to UTC", () => {
|
|
31
|
+
// Plain `new Date("2024-06-01T00:00:00")` is *local* time — in any zone
|
|
32
|
+
// west of UTC that lands on May 31st, reordering posts across a day
|
|
33
|
+
// boundary depending on which server answered the request.
|
|
34
|
+
expect(dateToTime("2024-06-01T00:00:00")).toBe(Date.UTC(2024, 5, 1));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("honours an explicit offset instead of overriding it", () => {
|
|
38
|
+
expect(dateToTime("2024-06-01T00:00:00-03:00")).toBe(Date.UTC(2024, 5, 1, 3));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns 0 rather than NaN for an unparseable value", () => {
|
|
42
|
+
expect(dateToTime("not a date")).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("orders two dates one day apart identically regardless of host offset", () => {
|
|
46
|
+
// Both values are absolute instants, so the comparison cannot shift with
|
|
47
|
+
// the host timezone — this is the property the sort comparator relies on.
|
|
48
|
+
expect(dateToTime("2024-06-02") - dateToTime("2024-06-01")).toBe(24 * 60 * 60 * 1000);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("sortPosts date ordering is timezone-independent", () => {
|
|
53
|
+
it("keeps day-boundary dates in order", async () => {
|
|
54
|
+
const posts = [
|
|
55
|
+
post("may-31", "2024-05-31"),
|
|
56
|
+
post("jun-02", "2024-06-02"),
|
|
57
|
+
post("jun-01", "2024-06-01"),
|
|
58
|
+
];
|
|
59
|
+
const sorted = await sortPosts(posts, "date_desc");
|
|
60
|
+
expect(sorted.map((p) => p.slug)).toEqual(["jun-02", "jun-01", "may-31"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not reorder datetimes that differ only by their offset", async () => {
|
|
64
|
+
// Same instant expressed two ways; neither may sort ahead of the other by
|
|
65
|
+
// accident of the host zone.
|
|
66
|
+
const posts = [
|
|
67
|
+
post("utc", "2024-06-01T03:00:00Z"),
|
|
68
|
+
post("offset", "2024-06-01T00:00:00-03:00"),
|
|
69
|
+
];
|
|
70
|
+
const sorted = await sortPosts(posts, "date_desc");
|
|
71
|
+
expect(dateToTime(sorted[0].date)).toBe(dateToTime(sorted[1].date));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("orders a mix of bare dates and full ISO timestamps correctly", async () => {
|
|
75
|
+
// The concrete regression this module fixes: the previous implementation
|
|
76
|
+
// built its timestamp as `new Date(\`${date}T00:00:00\`)`, which yields
|
|
77
|
+
// `Invalid Date` → NaN for any value that already carries a time. A NaN
|
|
78
|
+
// comparison is treated as 0, so every full-ISO post compared "equal" to
|
|
79
|
+
// everything and the listing order became arbitrary.
|
|
80
|
+
const posts = [
|
|
81
|
+
post("bare-old", "2024-01-01"),
|
|
82
|
+
post("iso-newest", "2024-12-01T09:30:00Z"),
|
|
83
|
+
post("bare-mid", "2024-06-01"),
|
|
84
|
+
post("iso-offset", "2024-03-01T00:00:00-03:00"),
|
|
85
|
+
];
|
|
86
|
+
const sorted = await sortPosts(posts, "date_desc");
|
|
87
|
+
expect(sorted.map((p) => p.slug)).toEqual(["iso-newest", "bare-mid", "iso-offset", "bare-old"]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("scheduledTime", () => {
|
|
92
|
+
it("accepts a bare date as midnight UTC", () => {
|
|
93
|
+
expect(scheduledTime("2026-09-01")).toBe(Date.UTC(2026, 8, 1));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("accepts a full ISO instant, with or without an offset", () => {
|
|
97
|
+
expect(scheduledTime("2026-09-01T12:30:00Z")).toBe(Date.UTC(2026, 8, 1, 12, 30));
|
|
98
|
+
expect(scheduledTime("2026-09-01T12:30:00-03:00")).toBe(Date.UTC(2026, 8, 1, 15, 30));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects a non-ISO string that `Date` would happily parse in local time", () => {
|
|
102
|
+
expect(scheduledTime("Sep 1 2026")).toBeNull();
|
|
103
|
+
// "0" is the year 2000 to `Date` — i.e. already live.
|
|
104
|
+
expect(scheduledTime("0")).toBeNull();
|
|
105
|
+
expect(scheduledTime("")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects calendar overflow instead of rolling it forward", () => {
|
|
109
|
+
expect(scheduledTime("2026-02-31")).toBeNull();
|
|
110
|
+
expect(scheduledTime("2026-13-01")).toBeNull();
|
|
111
|
+
expect(scheduledTime("2026-00-10")).toBeNull();
|
|
112
|
+
expect(scheduledTime("2026-01-00")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("accepts Feb 29 in a leap year and rejects it otherwise", () => {
|
|
116
|
+
expect(scheduledTime("2024-02-29")).toBe(Date.UTC(2024, 1, 29));
|
|
117
|
+
expect(scheduledTime("2026-02-29")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects out-of-range times", () => {
|
|
121
|
+
expect(scheduledTime("2026-09-01T24:00:00")).toBeNull();
|
|
122
|
+
expect(scheduledTime("2026-09-01T12:60:00")).toBeNull();
|
|
123
|
+
expect(scheduledTime("2026-09-01T12:00:61")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects an offset that matches the pattern but is not a real instant", () => {
|
|
127
|
+
expect(scheduledTime("2026-09-01T12:00:00+99:00")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null, not 0, on failure — so the epoch stays representable", () => {
|
|
131
|
+
expect(scheduledTime("garbage")).toBeNull();
|
|
132
|
+
expect(scheduledTime("1970-01-01T00:00:00Z")).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|