@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
@@ -1,7 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  vi.mock("@decocms/blocks/cms", () => ({
4
- loadBlocks: vi.fn(),
4
+ loadBlocks: vi.fn(),
5
5
  }));
6
6
 
7
7
  import { loadBlocks } from "@decocms/blocks/cms";
@@ -10,83 +10,83 @@ import { getRecordsByPath } from "../core/records";
10
10
  const mockLoadBlocks = loadBlocks as ReturnType<typeof vi.fn>;
11
11
 
12
12
  beforeEach(() => {
13
- vi.clearAllMocks();
13
+ vi.clearAllMocks();
14
14
  });
15
15
 
16
16
  describe("getRecordsByPath", () => {
17
- it("extracts records matching path prefix", () => {
18
- mockLoadBlocks.mockReturnValue({
19
- "collections/blog/posts/hello": {
20
- name: "collections/blog/posts/hello",
21
- post: { title: "Hello", slug: "hello" },
22
- },
23
- "collections/blog/posts/world": {
24
- name: "collections/blog/posts/world",
25
- post: { title: "World", slug: "world" },
26
- },
27
- });
17
+ it("extracts records matching path prefix", () => {
18
+ mockLoadBlocks.mockReturnValue({
19
+ "collections/blog/posts/hello": {
20
+ name: "collections/blog/posts/hello",
21
+ post: { title: "Hello", slug: "hello" },
22
+ },
23
+ "collections/blog/posts/world": {
24
+ name: "collections/blog/posts/world",
25
+ post: { title: "World", slug: "world" },
26
+ },
27
+ });
28
28
 
29
- const result = getRecordsByPath("collections/blog/posts", "post");
30
- expect(result).toHaveLength(2);
31
- expect(result).toEqual(
32
- expect.arrayContaining([
33
- expect.objectContaining({ title: "Hello", slug: "hello" }),
34
- expect.objectContaining({ title: "World", slug: "world" }),
35
- ]),
36
- );
37
- });
29
+ const result = getRecordsByPath("collections/blog/posts", "post");
30
+ expect(result).toHaveLength(2);
31
+ expect(result).toEqual(
32
+ expect.arrayContaining([
33
+ expect.objectContaining({ title: "Hello", slug: "hello" }),
34
+ expect.objectContaining({ title: "World", slug: "world" }),
35
+ ]),
36
+ );
37
+ });
38
38
 
39
- it("skips non-object blocks", () => {
40
- mockLoadBlocks.mockReturnValue({
41
- "collections/blog/posts/a": "not-an-object",
42
- "collections/blog/posts/b": null,
43
- "collections/blog/posts/c": {
44
- name: "collections/blog/posts/c",
45
- post: { title: "C" },
46
- },
47
- });
39
+ it("skips non-object blocks", () => {
40
+ mockLoadBlocks.mockReturnValue({
41
+ "collections/blog/posts/a": "not-an-object",
42
+ "collections/blog/posts/b": null,
43
+ "collections/blog/posts/c": {
44
+ name: "collections/blog/posts/c",
45
+ post: { title: "C" },
46
+ },
47
+ });
48
48
 
49
- const result = getRecordsByPath("collections/blog/posts", "post");
50
- expect(result).toHaveLength(1);
51
- expect(result[0]).toEqual(expect.objectContaining({ title: "C" }));
52
- });
49
+ const result = getRecordsByPath("collections/blog/posts", "post");
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0]).toEqual(expect.objectContaining({ title: "C" }));
52
+ });
53
53
 
54
- it("skips blocks without the accessor field", () => {
55
- mockLoadBlocks.mockReturnValue({
56
- "collections/blog/posts/x": {
57
- name: "collections/blog/posts/x",
58
- other: { title: "X" },
59
- },
60
- });
54
+ it("skips blocks without the accessor field", () => {
55
+ mockLoadBlocks.mockReturnValue({
56
+ "collections/blog/posts/x": {
57
+ name: "collections/blog/posts/x",
58
+ other: { title: "X" },
59
+ },
60
+ });
61
61
 
62
- const result = getRecordsByPath("collections/blog/posts", "post");
63
- expect(result).toEqual([]);
64
- });
62
+ const result = getRecordsByPath("collections/blog/posts", "post");
63
+ expect(result).toEqual([]);
64
+ });
65
65
 
66
- it("derives id from block name", () => {
67
- mockLoadBlocks.mockReturnValue({
68
- "collections/blog/posts/my-post": {
69
- name: "collections/blog/posts/my-post",
70
- post: { title: "My Post" },
71
- },
72
- });
66
+ it("derives id from block name", () => {
67
+ mockLoadBlocks.mockReturnValue({
68
+ "collections/blog/posts/my-post": {
69
+ name: "collections/blog/posts/my-post",
70
+ post: { title: "My Post" },
71
+ },
72
+ });
73
73
 
74
- const result = getRecordsByPath<{ title: string; id?: string }>(
75
- "collections/blog/posts",
76
- "post",
77
- );
78
- expect(result[0].id).toBe("my-post");
79
- });
74
+ const result = getRecordsByPath<{ title: string; id?: string }>(
75
+ "collections/blog/posts",
76
+ "post",
77
+ );
78
+ expect(result[0].id).toBe("my-post");
79
+ });
80
80
 
81
- it("returns empty array when no blocks match", () => {
82
- mockLoadBlocks.mockReturnValue({
83
- "collections/blog/categories/cat": {
84
- name: "collections/blog/categories/cat",
85
- category: { name: "Cat" },
86
- },
87
- });
81
+ it("returns empty array when no blocks match", () => {
82
+ mockLoadBlocks.mockReturnValue({
83
+ "collections/blog/categories/cat": {
84
+ name: "collections/blog/categories/cat",
85
+ category: { name: "Cat" },
86
+ },
87
+ });
88
88
 
89
- const result = getRecordsByPath("collections/blog/posts", "post");
90
- expect(result).toEqual([]);
91
- });
89
+ const result = getRecordsByPath("collections/blog/posts", "post");
90
+ expect(result).toEqual([]);
91
+ });
92
92
  });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Scope: the JSON-LD and canonical output of the two SEO section loaders, plus
3
+ * the `utils/jsonLD.ts` mappers they delegate to. These are the whole reason
4
+ * the SEO sections exist, and none of it was reachable in this package before.
5
+ *
6
+ * Deliberately not covered: rendering the `Seo` component itself (that lives in
7
+ * `@decocms/apps-website`), and the schema/admin wiring.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, it } from "vitest";
11
+ import { configureBlog } from "../client";
12
+ import { loader as seoPostLoader } from "../sections/Seo/SeoBlogPost";
13
+ import { loader as seoListingLoader } from "../sections/Seo/SeoBlogPostListing";
14
+ import type { BlogPost, BlogPostListingPage, BlogPostPage } from "../types";
15
+ import { toBlogPosting, toBreadcrumbList, withCanonicalBase } from "../utils/jsonLD";
16
+
17
+ const post = (overrides: Partial<BlogPost> = {}): BlogPost => ({
18
+ title: "How to brew",
19
+ slug: "how-to-brew",
20
+ date: "2024-06-01",
21
+ excerpt: "A guide",
22
+ ...overrides,
23
+ });
24
+
25
+ const page = (p: BlogPost): BlogPostPage => ({
26
+ "@type": "BlogPostPage",
27
+ post: p,
28
+ seo: { title: p.title, description: p.excerpt },
29
+ });
30
+
31
+ const req = (url = "https://shop.example/blog/coffee/how-to-brew?utm=x") => new Request(url);
32
+
33
+ const nodesOf = (result: { jsonLDs?: unknown[] }) => (result.jsonLDs ?? []) as any[];
34
+
35
+ beforeEach(() => {
36
+ configureBlog({});
37
+ });
38
+
39
+ describe("withCanonicalBase", () => {
40
+ it("strips query and hash, keeping origin + pathname", () => {
41
+ expect(withCanonicalBase("https://a.com/blog/x?q=1#frag")).toBe("https://a.com/blog/x");
42
+ });
43
+
44
+ it("swaps the origin for the configured canonical base", () => {
45
+ expect(withCanonicalBase("https://preview.a.com/blog/x?q=1", "https://www.a.com")).toBe(
46
+ "https://www.a.com/blog/x",
47
+ );
48
+ });
49
+
50
+ it("returns an unparseable relative URL unchanged when no base is configured", () => {
51
+ expect(withCanonicalBase("/blog/x")).toBe("/blog/x");
52
+ });
53
+ });
54
+
55
+ describe("toBlogPosting", () => {
56
+ it("emits the required BlogPosting fields", () => {
57
+ const node = toBlogPosting(post({ dateModified: "2024-07-01" }), "https://a.com/blog/x");
58
+ expect(node["@type"]).toBe("BlogPosting");
59
+ expect(node.headline).toBe("How to brew");
60
+ expect(node.datePublished).toBe("2024-06-01");
61
+ expect(node.dateModified).toBe("2024-07-01");
62
+ expect(node.mainEntityOfPage).toEqual({ "@type": "WebPage", "@id": "https://a.com/blog/x" });
63
+ });
64
+
65
+ it("omits `@context` — the Seo component adds it when serializing", () => {
66
+ expect("@context" in toBlogPosting(post())).toBe(false);
67
+ });
68
+
69
+ it("maps authors, defaulting the type to Person and scoping Person-only props", () => {
70
+ const node = toBlogPosting(
71
+ post({
72
+ authors: [
73
+ { name: "Ana", email: "a@b.com", jobTitle: "Barista", company: "Cafe" },
74
+ { name: "Acme", email: "c@d.com", type: "Organization", jobTitle: "ignored" },
75
+ ],
76
+ }),
77
+ );
78
+ expect(node.author).toEqual([
79
+ {
80
+ "@type": "Person",
81
+ name: "Ana",
82
+ jobTitle: "Barista",
83
+ worksFor: { "@type": "Organization", name: "Cafe" },
84
+ },
85
+ { "@type": "Organization", name: "Acme" },
86
+ ]);
87
+ });
88
+
89
+ it("emits the publisher as an Organization when configured", () => {
90
+ const node = toBlogPosting(post(), undefined, {
91
+ name: "Acme",
92
+ url: "https://a.com",
93
+ logo: "logo.png",
94
+ });
95
+ expect(node.publisher).toEqual({
96
+ "@type": "Organization",
97
+ name: "Acme",
98
+ url: "https://a.com",
99
+ logo: { "@type": "ImageObject", url: "logo.png" },
100
+ });
101
+ });
102
+
103
+ it("drops an aggregateRating stub that would fail the Rich Results Test", () => {
104
+ // ratingValue present but no count → invalid; and a bare @type stub → invalid.
105
+ expect(
106
+ toBlogPosting(post({ aggregateRating: { "@type": "AggregateRating" } })).aggregateRating,
107
+ ).toBeUndefined();
108
+ expect(
109
+ toBlogPosting(post({ aggregateRating: { "@type": "AggregateRating", ratingValue: 4 } }))
110
+ .aggregateRating,
111
+ ).toBeUndefined();
112
+ expect(
113
+ toBlogPosting(
114
+ post({ aggregateRating: { "@type": "AggregateRating", ratingValue: 4, ratingCount: 2 } }),
115
+ ).aggregateRating,
116
+ ).toEqual({ "@type": "AggregateRating", ratingValue: 4, ratingCount: 2 });
117
+ });
118
+
119
+ it("emits timeRequired only for a finite positive readTime", () => {
120
+ expect(toBlogPosting(post({ readTime: 7 })).timeRequired).toBe("PT7M");
121
+ expect(toBlogPosting(post({ readTime: 0 })).timeRequired).toBeUndefined();
122
+ expect(
123
+ toBlogPosting(post({ readTime: Number.POSITIVE_INFINITY })).timeRequired,
124
+ ).toBeUndefined();
125
+ });
126
+ });
127
+
128
+ describe("toBreadcrumbList", () => {
129
+ it("names segments from known categories and humanizes the rest", () => {
130
+ const node = toBreadcrumbList("https://a.com/blog/cold-brew/my-post", {
131
+ currentName: "My Post",
132
+ categories: [{ name: "Cold Brew Coffee", slug: "cold-brew" }],
133
+ });
134
+ expect(node.itemListElement).toEqual([
135
+ { "@type": "ListItem", position: 1, name: "Blog", item: "https://a.com/blog" },
136
+ {
137
+ "@type": "ListItem",
138
+ position: 2,
139
+ name: "Cold Brew Coffee",
140
+ item: "https://a.com/blog/cold-brew",
141
+ },
142
+ { "@type": "ListItem", position: 3, name: "My Post" },
143
+ ]);
144
+ });
145
+
146
+ it("omits `item` on the last entry, as Google allows", () => {
147
+ const node = toBreadcrumbList("https://a.com/blog/x", { currentName: "X" });
148
+ expect("item" in node.itemListElement[node.itemListElement.length - 1]).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("SeoBlogPost loader", () => {
153
+ it("emits BlogPosting + BreadcrumbList against the request URL", () => {
154
+ const result = seoPostLoader({ jsonLD: page(post()) }, req());
155
+ const [posting, breadcrumb] = nodesOf(result);
156
+ expect(posting["@type"]).toBe("BlogPosting");
157
+ expect(breadcrumb["@type"]).toBe("BreadcrumbList");
158
+ // Query string stripped for both canonical and JSON-LD.
159
+ expect(posting.url).toBe("https://shop.example/blog/coffee/how-to-brew");
160
+ });
161
+
162
+ it("rewrites the JSON-LD origin to the configured canonical base", () => {
163
+ configureBlog({ canonicalBaseUrl: "https://www.example.com" });
164
+ const [posting] = nodesOf(seoPostLoader({ jsonLD: page(post()) }, req()));
165
+ expect(posting.url).toBe("https://www.example.com/blog/coffee/how-to-brew");
166
+ });
167
+
168
+ it("includes the configured publisher", () => {
169
+ configureBlog({ publisher: { name: "Acme" } });
170
+ const [posting] = nodesOf(seoPostLoader({ jsonLD: page(post()) }, req()));
171
+ expect(posting.publisher).toEqual({ "@type": "Organization", name: "Acme" });
172
+ });
173
+
174
+ it("applies the site title/description templates", () => {
175
+ configureBlog({ seo: { titleTemplate: "%s | Acme", descriptionTemplate: "%s — read more" } });
176
+ const result = seoPostLoader({ jsonLD: page(post()) }, req());
177
+ expect(result.title).toBe("How to brew | Acme");
178
+ expect(result.description).toBe("A guide — read more");
179
+ });
180
+
181
+ it("prefers explicit overrides over the post's own seo", () => {
182
+ const result = seoPostLoader(
183
+ { jsonLD: page(post()), title: "Override", description: "Desc override" },
184
+ req(),
185
+ );
186
+ expect(result.title).toBe("Override");
187
+ expect(result.description).toBe("Desc override");
188
+ });
189
+
190
+ it("resolves a relative configured canonical against the request URL", () => {
191
+ const result = seoPostLoader(
192
+ { jsonLD: { ...page(post()), seo: { canonical: "/blog/canonical-path" } } },
193
+ req(),
194
+ );
195
+ expect(result.canonical).toBe("https://shop.example/blog/canonical-path");
196
+ });
197
+
198
+ it("noIndexes a null data source and emits no JSON-LD", () => {
199
+ const result = seoPostLoader({ jsonLD: null }, req());
200
+ expect(result.noIndexing).toBe(true);
201
+ expect(result.jsonLDs).toEqual([]);
202
+ });
203
+
204
+ it("propagates the post's own noIndexing — the draft-preview path", () => {
205
+ const result = seoPostLoader({ jsonLD: { ...page(post()), seo: { noIndexing: true } } }, req());
206
+ expect(result.noIndexing).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe("SeoBlogPostListing loader", () => {
211
+ const listing = (overrides: Partial<BlogPostListingPage> = {}): BlogPostListingPage => ({
212
+ posts: [post({ slug: "a", title: "A" }), post({ slug: "b", title: "B" })],
213
+ pageInfo: { currentPage: 1, nextPage: undefined, previousPage: undefined },
214
+ seo: { title: "Coffee" },
215
+ ...overrides,
216
+ });
217
+
218
+ it("emits a Blog node listing every post, plus a BreadcrumbList", () => {
219
+ const [blog, breadcrumb] = nodesOf(
220
+ seoListingLoader({ jsonLD: listing() }, req("https://shop.example/blog/coffee")),
221
+ );
222
+ expect(blog["@type"]).toBe("Blog");
223
+ expect(blog.name).toBe("Coffee");
224
+ expect(blog.blogPost.map((p: { headline: string }) => p.headline)).toEqual(["A", "B"]);
225
+ expect(blog.mainEntityOfPage["@id"]).toBe("https://shop.example/blog/coffee");
226
+ expect(breadcrumb["@type"]).toBe("BreadcrumbList");
227
+ });
228
+
229
+ it("names the breadcrumb leaf after the active category", () => {
230
+ const [, breadcrumb] = nodesOf(
231
+ seoListingLoader(
232
+ { jsonLD: listing({ category: { name: "Cold Brew", slug: "cold-brew" } }) },
233
+ req("https://shop.example/blog/cold-brew"),
234
+ ),
235
+ );
236
+ const leaf = breadcrumb.itemListElement.at(-1);
237
+ expect(leaf.name).toBe("Cold Brew");
238
+ });
239
+
240
+ it("noIndexes a null data source", () => {
241
+ const result = seoListingLoader({ jsonLD: null }, req());
242
+ expect(result.noIndexing).toBe(true);
243
+ expect(result.jsonLDs).toEqual([]);
244
+ });
245
+ });
@@ -0,0 +1,36 @@
1
+ import type { Person } from "@decocms/apps-commerce/types";
2
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
3
+ import { getBlogRecordsAdapter } from "../core/blogRecords";
4
+ import type { Rating } from "../types";
5
+
6
+ export interface Props {
7
+ itemReviewed: string;
8
+ author: Person;
9
+ ratingValue: number;
10
+ additionalType?: string;
11
+ }
12
+
13
+ /**
14
+ * Records this author's rating for a post, replacing their previous one.
15
+ *
16
+ * The "one rating per author per item" rule lives in the adapter rather than
17
+ * here: the Deno app implemented it as a `select ... where itemReviewed = ? and
18
+ * (author like %email% or author like %@id%)` followed by an update, which is a
19
+ * query the site's schema owns. See `core/blogRecords.ts`.
20
+ *
21
+ * Returns `null` when no adapter is registered — ratings are opt-in.
22
+ */
23
+ export default async function submitRating(props: Props): Promise<Rating | null> {
24
+ const upsert = getBlogRecordsAdapter()?.upsertRating;
25
+ if (!upsert) {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ return await upsert(props);
31
+ } catch (e) {
32
+ const error = serializeError(e);
33
+ logger.error(error.message, { error, scope: "blog/submitRating" });
34
+ return null;
35
+ }
36
+ }
@@ -0,0 +1,95 @@
1
+ import type { Person } from "@decocms/apps-commerce/types";
2
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
3
+ import { getBlogRecordsAdapter } from "../core/blogRecords";
4
+ import { getReviewById } from "../core/records";
5
+ import type { Review } from "../types";
6
+
7
+ export interface Props {
8
+ action: "create" | "update";
9
+ id?: string;
10
+ reviewBody?: string;
11
+ reviewHeadline?: string;
12
+ itemReviewed?: string;
13
+ author?: Person;
14
+ /** Review status */
15
+ additionalType?: string;
16
+ isAnonymous?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Creates or updates a review. Returns `null` when no adapter is registered, or
21
+ * when an update targets an id that doesn't exist.
22
+ */
23
+ export default async function submitReview({
24
+ reviewBody,
25
+ reviewHeadline,
26
+ itemReviewed,
27
+ id,
28
+ author,
29
+ action,
30
+ additionalType,
31
+ isAnonymous,
32
+ }: Props): Promise<Review | null> {
33
+ const adapter = getBlogRecordsAdapter();
34
+ if (!adapter) {
35
+ return null;
36
+ }
37
+
38
+ const isoDate = new Date().toISOString();
39
+
40
+ try {
41
+ if (action !== "create") {
42
+ if (!adapter.updateReview) {
43
+ return null;
44
+ }
45
+ // Read-before-write: the update is a partial patch, and the response has
46
+ // to carry the fields the caller didn't send (author, datePublished).
47
+ const storedReview = await getReviewById({ id });
48
+ if (!storedReview || !id) {
49
+ return null;
50
+ }
51
+ const updateRecord = {
52
+ additionalType: additionalType ?? storedReview.additionalType,
53
+ reviewHeadline: reviewHeadline ?? storedReview.reviewHeadline,
54
+ reviewBody: reviewBody ?? storedReview.reviewBody,
55
+ dateModified: isoDate,
56
+ };
57
+
58
+ const updated = await adapter.updateReview(id, updateRecord);
59
+
60
+ return (
61
+ updated ?? {
62
+ ...updateRecord,
63
+ "@type": "Review",
64
+ id,
65
+ itemReviewed: storedReview.itemReviewed,
66
+ author: author ?? storedReview.author,
67
+ datePublished: storedReview.datePublished,
68
+ }
69
+ );
70
+ }
71
+
72
+ if (!adapter.createReview) {
73
+ return null;
74
+ }
75
+
76
+ const insertData = {
77
+ itemReviewed,
78
+ isAnonymous,
79
+ author,
80
+ additionalType,
81
+ reviewHeadline,
82
+ reviewBody,
83
+ datePublished: isoDate,
84
+ dateModified: isoDate,
85
+ };
86
+
87
+ const created = await adapter.createReview(insertData);
88
+
89
+ return created ?? { "@type": "Review", ...insertData };
90
+ } catch (e) {
91
+ const error = serializeError(e);
92
+ logger.error(error.message, { error, scope: "blog/submitReview" });
93
+ return null;
94
+ }
95
+ }
@@ -0,0 +1,28 @@
1
+ import { logger, serializeError } from "@decocms/blocks/sdk/logger";
2
+ import { getBlogRecordsAdapter } from "../core/blogRecords";
3
+
4
+ export interface Props {
5
+ id: string;
6
+ }
7
+
8
+ /**
9
+ * Increments the view counter for a post, returning the new total.
10
+ *
11
+ * Returns `{ count: 0 }` when no adapter is registered: the caller is a
12
+ * fire-and-forget beacon on page view, so a site without the backend should get
13
+ * a well-formed response rather than an error.
14
+ */
15
+ export default async function submitView({ id }: Props): Promise<{ count: number }> {
16
+ const increment = getBlogRecordsAdapter()?.incrementPostView;
17
+ if (!increment) {
18
+ return { count: 0 };
19
+ }
20
+
21
+ try {
22
+ return await increment(id);
23
+ } catch (e) {
24
+ const error = serializeError(e);
25
+ logger.error(error.message, { error, scope: "blog/submitView" });
26
+ return { count: 0 };
27
+ }
28
+ }
package/src/client.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Blog app singleton configuration.
3
+ *
4
+ * Holds what the Deno app carried on `AppContext` (`ctx.categorySlug`,
5
+ * `ctx.pageSlug`, `ctx.canonicalBaseUrl`, `ctx.publisher`, `ctx.seo`). Loaders
6
+ * and sections here take plain `(props, req?)`, so there is no `ctx` to read
7
+ * from — this module is the replacement, matching `configureWebsite` /
8
+ * `getWebsiteConfig` in `@decocms/apps-website`.
9
+ *
10
+ * Uses globalThis to survive Vite module duplication (optimized deps vs raw
11
+ * source imports can create separate module instances).
12
+ */
13
+
14
+ import type { Publisher } from "./types";
15
+
16
+ /**
17
+ * SEO defaults the blog's SEO sections fold into their output.
18
+ *
19
+ * Structurally a subset of `@decocms/apps-website`'s `WebsiteConfig["seo"]`,
20
+ * duplicated rather than imported so this stays a *type-only* relationship
21
+ * across the two apps — an `apps-*` package may not depend on another at
22
+ * runtime, and the website's own getter throws when unconfigured.
23
+ */
24
+ export interface BlogSeoDefaults {
25
+ titleTemplate?: string;
26
+ descriptionTemplate?: string;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export interface BlogConfig {
31
+ /**
32
+ * Slug pattern of the category route, e.g. `/blog/:category*`. Categories can
33
+ * nest, so the route must be a catch-all for subcategories to resolve.
34
+ */
35
+ categorySlug?: string;
36
+ /**
37
+ * Slug pattern of the post route, e.g. `/blog/:category*\/:slug`.
38
+ */
39
+ pageSlug?: string;
40
+ /**
41
+ * Overrides the origin of the url/mainEntityOfPage emitted in the JSON-LD by
42
+ * the SEO sections, which otherwise use the request host.
43
+ */
44
+ canonicalBaseUrl?: string;
45
+ /**
46
+ * Emitted as the publisher of the blog posts in the JSON-LD.
47
+ */
48
+ publisher?: Publisher;
49
+ /**
50
+ * Site-level SEO defaults (title/description templates and passthrough props).
51
+ */
52
+ seo?: BlogSeoDefaults;
53
+ }
54
+
55
+ const G = globalThis as unknown as { __decoBlogConfig?: BlogConfig };
56
+
57
+ export function configureBlog(config: BlogConfig): void {
58
+ G.__decoBlogConfig = config;
59
+ }
60
+
61
+ /**
62
+ * Returns the blog config, or `{}` when the app was never configured.
63
+ *
64
+ * Deliberately non-throwing, unlike `getWebsiteConfig()`: every field here is
65
+ * optional and every consumer has a sensible default, so an unconfigured blog
66
+ * should render with request-derived canonicals rather than 500 the page.
67
+ */
68
+ export function getBlogConfig(): BlogConfig {
69
+ return G.__decoBlogConfig ?? {};
70
+ }