@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
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# `@decocms/apps-blog` (v7 only)
|
|
2
|
+
|
|
3
|
+
This package is the v7 blog app. It has no next-major surface: the next major
|
|
4
|
+
ships no blog package, and nothing new is added here.
|
|
5
|
+
|
|
6
|
+
In the next major a blog is content plus code the site owns:
|
|
7
|
+
|
|
8
|
+
| v7 (this package) | Next major |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `BlogPost`, `Author`, `Category` and the other types | The blog template's own types. |
|
|
11
|
+
| `getRecordsByPath` (reads the global decofile) | `client.list("post")`, `client.list("author")`, `client.list("category")` over saved blocks, with `where` / `sort` / `limit`. It reads the request's revision, so drafts work. |
|
|
12
|
+
| `handlePosts` (filter, sort, paginate) | `client.list` options, or the template's own helper. |
|
|
13
|
+
| `createBlogLoaders` and the `blog/loaders/*.ts` keys | Data-only blocks (`const post = (props: Post) => props`) in the template's block map. The `blog/loaders/*.ts` names stored by v7 content are registered as aliases, or as converter functions where the old shape differs, and loaders the site used are vendored under their old keys by the migration. |
|
|
14
|
+
| `blog/sections/blocks/*.tsx` (post body blocks) | Data-only blocks plus a renderer in the template. |
|
|
15
|
+
| `sections/Seo/SeoBlogPost*.tsx` | SEO helpers in the template. |
|
|
16
|
+
| Ratings, reviews and view-count actions, and the `loaders/extensions/*` ratings/reviews loaders | Site code (see *Migrating from v7*, loaders and actions). |
|
|
17
|
+
|
|
18
|
+
See the next-major docs: *Data-only blocks* (schema), `client.list` (API
|
|
19
|
+
reference), *Pages and routing*, and *Migrating from v7*.
|
|
20
|
+
|
|
21
|
+
Port notes for the v7 code are in [`src/README.md`](./src/README.md).
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-blog",
|
|
3
|
-
"version": "8.0.0",
|
|
3
|
+
"version": "8.1.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Deco app
|
|
5
|
+
"description": "v7 only: Deco blog app. The next major ships no blog package; blogs use data-only blocks and client.list in their template.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "https://github.com/decocms/blocks.git",
|
|
@@ -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": "8.0.0",
|
|
33
|
-
"@decocms/apps-commerce": "8.0.0",
|
|
34
|
-
"@decocms/apps-website": "8.0.0"
|
|
41
|
+
"@decocms/blocks": "8.1.0-next.0",
|
|
42
|
+
"@decocms/apps-commerce": "8.1.0-next.0",
|
|
43
|
+
"@decocms/apps-website": "8.1.0-next.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,209 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ancestorsOf,
|
|
4
|
+
categoryPathname,
|
|
5
|
+
childrenOf,
|
|
6
|
+
descendantSlugs,
|
|
7
|
+
indexCategories,
|
|
8
|
+
MAX_CATEGORY_DEPTH,
|
|
9
|
+
withCategoryPath,
|
|
10
|
+
} from "../core/categoryTree";
|
|
11
|
+
import type { Category } from "../types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Categories come from a decofile a human types into, so every walk over
|
|
15
|
+
* `parentSlug` has to survive garbage: a slug pointing at nothing, at itself,
|
|
16
|
+
* or around a cycle. None of these may hang a request.
|
|
17
|
+
*/
|
|
18
|
+
const cat = (slug: string, parentSlug?: string): Category => ({
|
|
19
|
+
name: slug.toUpperCase(),
|
|
20
|
+
slug,
|
|
21
|
+
...(parentSlug ? { parentSlug } : {}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const slugsOf = (chain: Category[] | null) => chain?.map((c) => c.slug) ?? null;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// ancestorsOf / indexCategories
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
describe("ancestorsOf", () => {
|
|
30
|
+
it("returns the chain root first", () => {
|
|
31
|
+
const categories = [cat("root"), cat("mid", "root"), cat("leaf", "mid")];
|
|
32
|
+
const chain = ancestorsOf("leaf", indexCategories(categories));
|
|
33
|
+
|
|
34
|
+
expect(slugsOf(chain)).toEqual(["root", "mid", "leaf"]);
|
|
35
|
+
expect(categoryPathname(chain as Category[])).toEqual("root/mid/leaf");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null for an unknown slug", () => {
|
|
39
|
+
expect(ancestorsOf("ghost", indexCategories([cat("root")]))).toEqual(null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("yields no chain on a cycle instead of looping", () => {
|
|
43
|
+
const categories = [cat("a", "b"), cat("b", "a")];
|
|
44
|
+
// Walking up from `a` reaches `b`, whose parent is `a` again. The visited set
|
|
45
|
+
// stops the walk; the chain is reported as unusable rather than truncated,
|
|
46
|
+
// because a truncated one would name a canonical URL missing its top.
|
|
47
|
+
expect(ancestorsOf("a", indexCategories(categories))).toEqual(null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("treats a self-referencing category as a root", () => {
|
|
51
|
+
expect(slugsOf(ancestorsOf("a", indexCategories([cat("a", "a")])))).toEqual(["a"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("stops the walk on a dangling parentSlug", () => {
|
|
55
|
+
const categories = [cat("leaf", "does-not-exist")];
|
|
56
|
+
expect(slugsOf(ancestorsOf("leaf", indexCategories(categories)))).toEqual(["leaf"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects a chain deeper than MAX_CATEGORY_DEPTH", () => {
|
|
60
|
+
const chainOf = (length: number) =>
|
|
61
|
+
Array.from({ length }, (_, i) => cat(`c${i}`, i === 0 ? undefined : `c${i - 1}`));
|
|
62
|
+
|
|
63
|
+
const deepest = chainOf(MAX_CATEGORY_DEPTH);
|
|
64
|
+
expect(ancestorsOf(`c${MAX_CATEGORY_DEPTH - 1}`, indexCategories(deepest))?.length).toEqual(
|
|
65
|
+
MAX_CATEGORY_DEPTH,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// One level past the ceiling: reporting the truncated chain would hand the
|
|
69
|
+
// canonical URL a path missing its topmost segments, so it reports nothing.
|
|
70
|
+
const tooDeep = chainOf(MAX_CATEGORY_DEPTH + 1);
|
|
71
|
+
expect(ancestorsOf(`c${MAX_CATEGORY_DEPTH}`, indexCategories(tooDeep))).toEqual(null);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("indexCategories", () => {
|
|
76
|
+
it("resolves duplicated slugs to the first record", () => {
|
|
77
|
+
const first: Category = { name: "First", slug: "dup" };
|
|
78
|
+
const second: Category = { name: "Second", slug: "dup", parentSlug: "dup" };
|
|
79
|
+
const index = indexCategories([first, second]);
|
|
80
|
+
|
|
81
|
+
expect(index.get("dup")?.name).toEqual("First");
|
|
82
|
+
expect(index.size).toEqual(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// descendantSlugs / childrenOf
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
describe("descendantSlugs", () => {
|
|
90
|
+
it("collects the whole subtree, itself included", () => {
|
|
91
|
+
const categories = [
|
|
92
|
+
cat("root"),
|
|
93
|
+
cat("a", "root"),
|
|
94
|
+
cat("b", "root"),
|
|
95
|
+
cat("a1", "a"),
|
|
96
|
+
cat("other"),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
expect(descendantSlugs("root", categories).sort()).toEqual(["a", "a1", "b", "root"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("stays finite over a cycle", () => {
|
|
103
|
+
const categories = [cat("a", "b"), cat("b", "a")];
|
|
104
|
+
expect(descendantSlugs("a", categories).sort()).toEqual(["a", "b"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("of an unknown slug is just the slug", () => {
|
|
108
|
+
expect(descendantSlugs("ghost", [cat("root")])).toEqual(["ghost"]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("childrenOf", () => {
|
|
113
|
+
it("returns direct children, name sorted", () => {
|
|
114
|
+
const categories = [
|
|
115
|
+
cat("root"),
|
|
116
|
+
cat("zeta", "root"),
|
|
117
|
+
cat("alpha", "root"),
|
|
118
|
+
cat("deep", "alpha"),
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
expect(childrenOf("root", categories).map((c) => c.slug)).toEqual(["alpha", "zeta"]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// withCategoryPath
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
describe("withCategoryPath", () => {
|
|
129
|
+
it("rewrites the requested path with the real chain", () => {
|
|
130
|
+
const chain = [cat("root"), cat("leaf", "root")];
|
|
131
|
+
|
|
132
|
+
// A request that reached the child through a flat path still gets the one
|
|
133
|
+
// canonical URL, so Google consolidates instead of seeing duplicates.
|
|
134
|
+
expect(
|
|
135
|
+
withCategoryPath("https://x.com/blog/leaf?page=2", chain, { requested: ["leaf"] }),
|
|
136
|
+
).toEqual("https://x.com/blog/root/leaf");
|
|
137
|
+
expect(
|
|
138
|
+
withCategoryPath("https://x.com/blog/wrong/leaf", chain, { requested: ["wrong", "leaf"] }),
|
|
139
|
+
).toEqual("https://x.com/blog/root/leaf");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("declines when the slug isn't in the path", () => {
|
|
143
|
+
const chain = [cat("root"), cat("leaf", "root")];
|
|
144
|
+
|
|
145
|
+
// A listing hardcoded to a category on a plain /blog route: the pathname has
|
|
146
|
+
// no category segment, so rewriting it would strip the route prefix and
|
|
147
|
+
// point the canonical at a page that doesn't exist.
|
|
148
|
+
expect(withCategoryPath("https://x.com/blog", chain, { requested: ["leaf"] })).toEqual(null);
|
|
149
|
+
expect(withCategoryPath("https://x.com/blog/other", chain, { requested: ["leaf"] })).toEqual(
|
|
150
|
+
null,
|
|
151
|
+
);
|
|
152
|
+
expect(withCategoryPath("https://x.com/blog/leaf", chain, {})).toEqual(null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps a trailing post slug", () => {
|
|
156
|
+
const chain = [cat("root"), cat("leaf", "root")];
|
|
157
|
+
const knownSlugs = new Set(["root", "leaf"]);
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
withCategoryPath("https://x.com/blog/leaf/my-post", chain, {
|
|
161
|
+
knownSlugs,
|
|
162
|
+
trailing: "my-post",
|
|
163
|
+
}),
|
|
164
|
+
).toEqual("https://x.com/blog/root/leaf/my-post");
|
|
165
|
+
// A post route with no category segment keeps its own URL: injecting the
|
|
166
|
+
// chain would canonicalize to a path the site never serves.
|
|
167
|
+
expect(
|
|
168
|
+
withCategoryPath("https://x.com/blog/my-post", chain, { knownSlugs, trailing: "my-post" }),
|
|
169
|
+
).toEqual(null);
|
|
170
|
+
// The categories collection failed to load, so nothing is a known slug —
|
|
171
|
+
// same outcome, rather than doubling the segment already in the path.
|
|
172
|
+
expect(
|
|
173
|
+
withCategoryPath("https://x.com/blog/leaf/my-post", chain, {
|
|
174
|
+
knownSlugs: new Set<string>(),
|
|
175
|
+
trailing: "my-post",
|
|
176
|
+
}),
|
|
177
|
+
).toEqual(null);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("keeps the route prefix when a category slug collides with it", () => {
|
|
181
|
+
// Category slugged "blog" on a /blog route. An unbounded strip of trailing
|
|
182
|
+
// known slugs would eat the prefix too and canonicalize to /blog/my-post,
|
|
183
|
+
// a path the site never serves.
|
|
184
|
+
const chain = [cat("blog")];
|
|
185
|
+
|
|
186
|
+
expect(
|
|
187
|
+
withCategoryPath("https://x.com/blog/blog/my-post", chain, {
|
|
188
|
+
knownSlugs: new Set(["blog"]),
|
|
189
|
+
trailing: "my-post",
|
|
190
|
+
}),
|
|
191
|
+
).toEqual("https://x.com/blog/blog/my-post");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("encodes each slug as one path segment", () => {
|
|
195
|
+
// Slugs are hand-authored; a stray "?" would otherwise turn the rest of the
|
|
196
|
+
// canonical into a query string pointing at a different page.
|
|
197
|
+
const chain = [cat("a?b"), cat("c/d", "a?b")];
|
|
198
|
+
|
|
199
|
+
expect(withCategoryPath("https://x.com/blog/leaf", chain, { requested: ["leaf"] })).toEqual(
|
|
200
|
+
"https://x.com/blog/a%3Fb/c%2Fd",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rewrites nothing with an empty chain", () => {
|
|
205
|
+
expect(withCategoryPath("https://x.com/blog/leaf?page=2", [], { requested: ["leaf"] })).toEqual(
|
|
206
|
+
null,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|