@decocms/apps-blog 7.71.6 → 8.0.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 +5 -5
- package/src/__tests__/categoryTree.test.ts +209 -0
- package/src/__tests__/handlePosts.test.ts +26 -0
- package/src/__tests__/loaders.test.ts +44 -0
- package/src/client.ts +3 -2
- package/src/core/categoryTree.ts +210 -0
- package/src/core/handlePosts.ts +21 -16
- package/src/loaders/BlogPostPage.ts +34 -2
- package/src/loaders/BlogpostList.ts +23 -3
- package/src/loaders/BlogpostListing.ts +41 -12
- package/src/loaders/GetCategories.ts +10 -1
- package/src/mod.ts +5 -3
- package/src/sections/Seo/SeoBlogPost.tsx +3 -1
- package/src/sections/Template.tsx +13 -3
- package/src/sections/blocks/BlockImage.tsx +23 -2
- package/src/types.ts +21 -0
- package/src/utils/blocksToSections.ts +5 -0
- package/src/utils/jsonLD.ts +3 -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": "
|
|
3
|
+
"version": "8.0.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",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"lint:unused": "knip"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@decocms/blocks": "
|
|
42
|
-
"@decocms/apps-commerce": "
|
|
43
|
-
"@decocms/apps-website": "
|
|
41
|
+
"@decocms/blocks": "8.0.0-next.0",
|
|
42
|
+
"@decocms/apps-commerce": "8.0.0-next.0",
|
|
43
|
+
"@decocms/apps-website": "8.0.0-next.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"react": "^19.0.0",
|
|
@@ -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
|
+
});
|
|
@@ -123,6 +123,27 @@ describe("filterPostsByCategory", () => {
|
|
|
123
123
|
const result = filterPostsByCategory(posts, "tech");
|
|
124
124
|
expect(result.find((p) => p.slug === "b")).toBeTruthy();
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A parent category lists its descendants' posts by expanding its slug into
|
|
129
|
+
* the whole subtree, so the filter has to match on any slug of a list.
|
|
130
|
+
*/
|
|
131
|
+
it("matches a post in any slug of a list", () => {
|
|
132
|
+
const inCategory = (slug: string) =>
|
|
133
|
+
makePost({ slug: `post-${slug}`, categories: [{ name: slug, slug }] });
|
|
134
|
+
const tree = [inCategory("root"), inCategory("child"), inCategory("other")];
|
|
135
|
+
|
|
136
|
+
expect(filterPostsByCategory(tree, ["root", "child"]).map((p) => p.slug)).toEqual([
|
|
137
|
+
"post-root",
|
|
138
|
+
"post-child",
|
|
139
|
+
]);
|
|
140
|
+
expect(filterPostsByCategory(tree, "child").map((p) => p.slug)).toEqual(["post-child"]);
|
|
141
|
+
// No slug at all means no category filter, same as before.
|
|
142
|
+
expect(filterPostsByCategory(tree)).toHaveLength(3);
|
|
143
|
+
// An empty list is an explicit empty set of categories, which nothing
|
|
144
|
+
// belongs to — diverges from upstream, which answers every post here.
|
|
145
|
+
expect(filterPostsByCategory(tree, [])).toEqual([]);
|
|
146
|
+
});
|
|
126
147
|
});
|
|
127
148
|
|
|
128
149
|
// ---------------------------------------------------------------------------
|
|
@@ -193,6 +214,11 @@ describe("filterRelatedPosts", () => {
|
|
|
193
214
|
const result = filterRelatedPosts(posts, ["news"]);
|
|
194
215
|
expect(result.find((p) => p.slug === "c")).toBeUndefined();
|
|
195
216
|
});
|
|
217
|
+
|
|
218
|
+
// Opposite of filterPostsByCategory, which reads an empty list as "no filter".
|
|
219
|
+
it("relates nothing on an empty slug list", () => {
|
|
220
|
+
expect(filterRelatedPosts(posts, [])).toEqual([]);
|
|
221
|
+
});
|
|
196
222
|
});
|
|
197
223
|
|
|
198
224
|
// ---------------------------------------------------------------------------
|
|
@@ -100,6 +100,22 @@ describe("BlogRelatedPostsLoader", () => {
|
|
|
100
100
|
mockGetRecords.mockReturnValue([]);
|
|
101
101
|
expect(await BlogRelatedPostsLoader({})).toBeNull();
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
// A post with no categories arrives here as `slug: []`. Answering the whole
|
|
105
|
+
// blog would turn "related posts" into a second listing of everything.
|
|
106
|
+
it("returns null for an empty category list, not every post", async () => {
|
|
107
|
+
expect(await BlogRelatedPostsLoader({ slug: [] })).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("still relates on a non-empty category list", async () => {
|
|
111
|
+
mockGetRecords.mockReturnValue([
|
|
112
|
+
makePost({ slug: "in", categories: [{ name: "News", slug: "news" }] }),
|
|
113
|
+
makePost({ slug: "out", categories: [{ name: "Tech", slug: "tech" }] }),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const result = await BlogRelatedPostsLoader({ slug: ["news"] });
|
|
117
|
+
expect(result?.map((p) => p.slug)).toEqual(["in"]);
|
|
118
|
+
});
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
// ---------------------------------------------------------------------------
|
|
@@ -185,6 +201,34 @@ describe("GetCategories", () => {
|
|
|
185
201
|
mockGetRecords.mockReturnValue([]);
|
|
186
202
|
expect(GetCategories({})).toBeNull();
|
|
187
203
|
});
|
|
204
|
+
|
|
205
|
+
describe("parentSlug", () => {
|
|
206
|
+
const nested: Category[] = [
|
|
207
|
+
{ name: "Root", slug: "root" },
|
|
208
|
+
{ name: "Zeta", slug: "zeta", parentSlug: "root" },
|
|
209
|
+
{ name: "Alpha", slug: "alpha", parentSlug: "root" },
|
|
210
|
+
{ name: "Deep", slug: "deep", parentSlug: "alpha" },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
mockGetRecords.mockReturnValue([...nested]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns only direct children, not the whole subtree", () => {
|
|
218
|
+
const result = GetCategories({ parentSlug: "root" });
|
|
219
|
+
expect(result!.map((c) => c.slug)).toEqual(["Alpha", "Zeta"].map((n) => n.toLowerCase()));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("slices the scoped children by count", () => {
|
|
223
|
+
expect(GetCategories({ parentSlug: "root", count: 1 })!.map((c) => c.slug)).toEqual([
|
|
224
|
+
"alpha",
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns an empty list for a category with no children", () => {
|
|
229
|
+
expect(GetCategories({ parentSlug: "deep" })).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
188
232
|
});
|
|
189
233
|
|
|
190
234
|
// ---------------------------------------------------------------------------
|
package/src/client.ts
CHANGED
|
@@ -29,11 +29,12 @@ export interface BlogSeoDefaults {
|
|
|
29
29
|
|
|
30
30
|
export interface BlogConfig {
|
|
31
31
|
/**
|
|
32
|
-
* Slug pattern of the category route, e.g. `/blog/:category
|
|
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.
|
|
33
34
|
*/
|
|
34
35
|
categorySlug?: string;
|
|
35
36
|
/**
|
|
36
|
-
* Slug pattern of the post route, e.g. `/blog/:category
|
|
37
|
+
* Slug pattern of the post route, e.g. `/blog/:category*\/:slug`.
|
|
37
38
|
*/
|
|
38
39
|
pageSlug?: string;
|
|
39
40
|
/**
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { Category } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Categories come from a decofile a human edits by hand, so `parentSlug` can
|
|
5
|
+
* point at nothing, at itself, or back into a cycle. Every walk below is
|
|
6
|
+
* bounded twice — by a visited set and by this ceiling — so a malformed record
|
|
7
|
+
* can never hang a request.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_CATEGORY_DEPTH = 4;
|
|
10
|
+
|
|
11
|
+
const isValid = (c?: Category | null): c is Category =>
|
|
12
|
+
!!c &&
|
|
13
|
+
typeof c.slug === "string" &&
|
|
14
|
+
c.slug.length > 0 &&
|
|
15
|
+
typeof c.name === "string" &&
|
|
16
|
+
c.name.length > 0;
|
|
17
|
+
|
|
18
|
+
/** Slug -> category. First entry wins when a slug is duplicated. */
|
|
19
|
+
export const indexCategories = (
|
|
20
|
+
categories: Category[] | null | undefined,
|
|
21
|
+
): Map<string, Category> => {
|
|
22
|
+
const index = new Map<string, Category>();
|
|
23
|
+
for (const category of categories ?? []) {
|
|
24
|
+
if (isValid(category) && !index.has(category.slug)) {
|
|
25
|
+
index.set(category.slug, category);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return index;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const parentOf = (category: Category, index: Map<string, Category>): Category | undefined => {
|
|
32
|
+
const parentSlug = category.parentSlug;
|
|
33
|
+
if (typeof parentSlug !== "string" || !parentSlug || parentSlug === category.slug) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return index.get(parentSlug);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The ancestor chain of `slug`, root first and the category itself last —
|
|
41
|
+
* exactly the breadcrumb sequence.
|
|
42
|
+
*
|
|
43
|
+
* Returns null whenever the chain cannot be trusted: the slug is unknown, the
|
|
44
|
+
* walk ran into a cycle, or it went past MAX_CATEGORY_DEPTH. A truncated chain
|
|
45
|
+
* is worse than none — it would name a breadcrumb and a canonical URL missing
|
|
46
|
+
* their topmost segments — so callers fall back to the flat behaviour instead.
|
|
47
|
+
* A dangling `parentSlug` or a self-reference is not an error: the walk just
|
|
48
|
+
* ends there, treating the category as a root.
|
|
49
|
+
*/
|
|
50
|
+
export const ancestorsOf = (slug: string, index: Map<string, Category>): Category[] | null => {
|
|
51
|
+
const start = index.get(slug);
|
|
52
|
+
if (!start) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const chain: Category[] = [];
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
let current: Category | undefined = start;
|
|
59
|
+
|
|
60
|
+
while (current) {
|
|
61
|
+
if (seen.has(current.slug) || chain.length >= MAX_CATEGORY_DEPTH) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
seen.add(current.slug);
|
|
65
|
+
chain.push(current);
|
|
66
|
+
current = parentOf(current, index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return chain.reverse();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Children indexed by parent slug, skipping self-references. */
|
|
73
|
+
const childrenIndex = (categories: Category[]): Map<string, Category[]> => {
|
|
74
|
+
const children = new Map<string, Category[]>();
|
|
75
|
+
for (const category of categories) {
|
|
76
|
+
const parentSlug = category.parentSlug;
|
|
77
|
+
if (
|
|
78
|
+
!isValid(category) ||
|
|
79
|
+
typeof parentSlug !== "string" ||
|
|
80
|
+
!parentSlug ||
|
|
81
|
+
parentSlug === category.slug
|
|
82
|
+
) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const siblings = children.get(parentSlug);
|
|
86
|
+
siblings ? siblings.push(category) : children.set(parentSlug, [category]);
|
|
87
|
+
}
|
|
88
|
+
return children;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `slug` plus every category below it. Breadth-first with a visited set, so a
|
|
93
|
+
* cycle yields a finite set instead of looping. Used to make a parent category
|
|
94
|
+
* list its children's posts.
|
|
95
|
+
*/
|
|
96
|
+
export const descendantSlugs = (
|
|
97
|
+
slug: string,
|
|
98
|
+
categories: Category[] | null | undefined,
|
|
99
|
+
): string[] => {
|
|
100
|
+
const children = childrenIndex(categories ?? []);
|
|
101
|
+
const seen = new Set<string>([slug]);
|
|
102
|
+
let frontier = [slug];
|
|
103
|
+
|
|
104
|
+
for (let depth = 0; depth < MAX_CATEGORY_DEPTH && frontier.length; depth++) {
|
|
105
|
+
const next: string[] = [];
|
|
106
|
+
for (const parent of frontier) {
|
|
107
|
+
for (const child of children.get(parent) ?? []) {
|
|
108
|
+
if (!seen.has(child.slug)) {
|
|
109
|
+
seen.add(child.slug);
|
|
110
|
+
next.push(child.slug);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
frontier = next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [...seen];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Direct children of `slug`, name-sorted. */
|
|
121
|
+
export const childrenOf = (slug: string, categories: Category[] | null | undefined): Category[] =>
|
|
122
|
+
(childrenIndex(categories ?? []).get(slug) ?? []).sort((a, b) => a.name.localeCompare(b.name));
|
|
123
|
+
|
|
124
|
+
/** "pai/filho" — the URL path a category chain occupies. */
|
|
125
|
+
export const categoryPathname = (chain: Category[]): string => chain.map((c) => c.slug).join("/");
|
|
126
|
+
|
|
127
|
+
interface PathOptions {
|
|
128
|
+
/**
|
|
129
|
+
* The category segments the router handed the loader. They must be the tail
|
|
130
|
+
* of the pathname, otherwise the slug was configured by hand and the URL
|
|
131
|
+
* carries no category to rewrite.
|
|
132
|
+
*/
|
|
133
|
+
requested?: string[];
|
|
134
|
+
/**
|
|
135
|
+
* Known category slugs, for routes that don't hand the category over — the
|
|
136
|
+
* category segments are the trailing ones that name a real category.
|
|
137
|
+
*/
|
|
138
|
+
knownSlugs?: Set<string>;
|
|
139
|
+
/** Segment that stays at the end of the path — a post slug. */
|
|
140
|
+
trailing?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Rewrites the category portion of a URL with the canonical chain, keeping the
|
|
145
|
+
* route prefix (`/blog`) and an optional trailing post slug. A request that
|
|
146
|
+
* reaches a child through a stale or wrong path still renders; this is what
|
|
147
|
+
* points its canonical at the one true URL so Google consolidates instead of
|
|
148
|
+
* seeing duplicates.
|
|
149
|
+
*
|
|
150
|
+
* Returns null when the URL has no category portion to replace — a route
|
|
151
|
+
* without a category segment, or a slug that didn't come from the route. The
|
|
152
|
+
* caller keeps the request URL rather than inventing a path that 404s.
|
|
153
|
+
*/
|
|
154
|
+
export const withCategoryPath = (
|
|
155
|
+
url: string | URL,
|
|
156
|
+
chain: Category[],
|
|
157
|
+
{ requested, knownSlugs, trailing }: PathOptions = {},
|
|
158
|
+
): string | null => {
|
|
159
|
+
if (chain.length === 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parsed = new URL(url);
|
|
164
|
+
let segments = parsed.pathname.split("/").filter(Boolean);
|
|
165
|
+
|
|
166
|
+
if (trailing) {
|
|
167
|
+
if (segments[segments.length - 1] !== trailing) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
segments = segments.slice(0, -1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (requested) {
|
|
174
|
+
const size = requested.length;
|
|
175
|
+
const tail = segments.slice(Math.max(segments.length - size, 0));
|
|
176
|
+
if (size === 0 || tail.join("/") !== requested.join("/")) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
segments = segments.slice(0, segments.length - size);
|
|
180
|
+
} else if (knownSlugs) {
|
|
181
|
+
const before = segments.length;
|
|
182
|
+
// Bounded by the chain length: the request can carry at most as many
|
|
183
|
+
// category segments as the canonical chain has. Without the bound, a
|
|
184
|
+
// category whose slug collides with the route prefix — `/blog/blog/a-post`
|
|
185
|
+
// for a category slugged "blog" — strips the prefix too and canonicalizes
|
|
186
|
+
// to a path the site never serves.
|
|
187
|
+
while (
|
|
188
|
+
segments.length &&
|
|
189
|
+
before - segments.length < chain.length &&
|
|
190
|
+
knownSlugs.has(segments[segments.length - 1])
|
|
191
|
+
) {
|
|
192
|
+
segments = segments.slice(0, -1);
|
|
193
|
+
}
|
|
194
|
+
if (segments.length === before) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Slugs are authored by hand, so a stray "/", "?" or "#" would otherwise stop
|
|
202
|
+
// being one path segment and start being a new segment, a query or a
|
|
203
|
+
// fragment. Encoded, a malformed slug yields a 404 instead of a canonical
|
|
204
|
+
// pointing at a different page.
|
|
205
|
+
const pathname = `/${[...segments, ...chain.map((c) => c.slug), ...(trailing ? [trailing] : [])]
|
|
206
|
+
.map(encodeURIComponent)
|
|
207
|
+
.join("/")}`;
|
|
208
|
+
|
|
209
|
+
return new URL(pathname, parsed.origin).href;
|
|
210
|
+
};
|
package/src/core/handlePosts.ts
CHANGED
|
@@ -80,10 +80,22 @@ export const sortPosts = async (blogPosts: BlogPost[], sortBy: SortBy): Promise<
|
|
|
80
80
|
* Returns a filtered BlogPost list.
|
|
81
81
|
*
|
|
82
82
|
* @param posts Posts to be handled
|
|
83
|
-
* @param slug Category slug to
|
|
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.
|
|
84
91
|
*/
|
|
85
|
-
export const filterPostsByCategory = (posts: BlogPost[], slug?: string): BlogPost[] =>
|
|
86
|
-
|
|
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
|
+
};
|
|
87
99
|
|
|
88
100
|
/** Filter posts whose slug is in the given list. */
|
|
89
101
|
export const filterPostsBySlugs = (posts: BlogPost[], postSlugs: string[]): BlogPost[] =>
|
|
@@ -97,7 +109,7 @@ export const filterPostsByTerm = (posts: BlogPost[], term: string): BlogPost[] =
|
|
|
97
109
|
|
|
98
110
|
/** Filter posts whose categories overlap with the given slug array. */
|
|
99
111
|
export const filterRelatedPosts = (posts: BlogPost[], slugs: string[]): BlogPost[] =>
|
|
100
|
-
posts
|
|
112
|
+
filterPostsByCategory(posts, slugs);
|
|
101
113
|
|
|
102
114
|
/** Slice posts for pagination. */
|
|
103
115
|
export const slicePosts = (
|
|
@@ -134,19 +146,12 @@ const filterPosts = (
|
|
|
134
146
|
): BlogPost[] => {
|
|
135
147
|
const posts = filterRoutablePosts(allPosts);
|
|
136
148
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
: filterPostsByCategory(posts, slug);
|
|
142
|
-
|
|
143
|
-
return term ? filterPostsByTerm(firstFilter, term) : firstFilter;
|
|
144
|
-
}
|
|
145
|
-
if (Array.isArray(slug)) {
|
|
146
|
-
return filterRelatedPosts(posts, slug);
|
|
147
|
-
}
|
|
149
|
+
const byCategory =
|
|
150
|
+
postSlugs && postSlugs.length > 0
|
|
151
|
+
? filterPostsBySlugs(posts, postSlugs)
|
|
152
|
+
: filterPostsByCategory(posts, slug);
|
|
148
153
|
|
|
149
|
-
return term ? filterPostsByTerm(
|
|
154
|
+
return term ? filterPostsByTerm(byCategory, term) : byCategory;
|
|
150
155
|
};
|
|
151
156
|
|
|
152
157
|
/**
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { logger, serializeError } from "@decocms/blocks/sdk/logger";
|
|
2
|
+
import { ancestorsOf, indexCategories, withCategoryPath } from "../core/categoryTree";
|
|
1
3
|
import { getRecordsByPath } from "../core/records";
|
|
2
|
-
import type { BlogPost, BlogPostPage } from "../types";
|
|
4
|
+
import type { BlogPost, BlogPostPage, Category } from "../types";
|
|
3
5
|
import { isLivePost } from "../types";
|
|
4
6
|
|
|
5
7
|
const COLLECTION_PATH = "collections/blog/posts";
|
|
6
8
|
const ACCESSOR = "post";
|
|
9
|
+
const CATEGORIES_PATH = "collections/blog/categories";
|
|
10
|
+
const CATEGORY_ACCESSOR = "category";
|
|
7
11
|
|
|
8
12
|
export interface Props {
|
|
9
13
|
slug: string;
|
|
@@ -26,13 +30,41 @@ export default function BlogPostPageLoader(
|
|
|
26
30
|
|
|
27
31
|
if (!post) return null;
|
|
28
32
|
|
|
33
|
+
// The post lives under the full path of its primary category, so the
|
|
34
|
+
// breadcrumb and the canonical need that category's ancestor chain. A broken
|
|
35
|
+
// categories collection must not take the post page down with it.
|
|
36
|
+
let categories: Category[] = [];
|
|
37
|
+
try {
|
|
38
|
+
categories = getRecordsByPath<Category>(CATEGORIES_PATH, CATEGORY_ACCESSOR) ?? [];
|
|
39
|
+
} catch (e) {
|
|
40
|
+
const error = serializeError(e);
|
|
41
|
+
logger.error(error.message, { error, scope: "blog/BlogPostPage/categories" });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const primarySlug = post.categories?.[0]?.slug;
|
|
45
|
+
const chain =
|
|
46
|
+
typeof primarySlug === "string" && primarySlug
|
|
47
|
+
? ancestorsOf(primarySlug, indexCategories(categories))
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
// Only a chain that came out of the records is trustworthy enough to name a
|
|
51
|
+
// canonical URL; withCategoryPath itself returns null when the route has no
|
|
52
|
+
// category segment to replace, so a /blog/:slug site keeps its own URL.
|
|
53
|
+
const canonical = chain
|
|
54
|
+
? withCategoryPath(url, chain, {
|
|
55
|
+
knownSlugs: new Set(categories.map((c) => c?.slug)),
|
|
56
|
+
trailing: post.slug,
|
|
57
|
+
})
|
|
58
|
+
: null;
|
|
59
|
+
|
|
29
60
|
return {
|
|
30
61
|
"@type": "BlogPostPage",
|
|
31
62
|
post,
|
|
63
|
+
categories: chain ?? (post.categories?.[0] ? [post.categories[0]] : null),
|
|
32
64
|
seo: {
|
|
33
65
|
title: post?.seo?.title || post?.title,
|
|
34
66
|
description: post?.seo?.description || post?.excerpt,
|
|
35
|
-
canonical: post?.seo?.canonical || url.href,
|
|
67
|
+
canonical: post?.seo?.canonical || canonical || url.href,
|
|
36
68
|
image: post?.seo?.image || post?.image,
|
|
37
69
|
// A post that isn't live yet — unpublished, or scheduled for an instant
|
|
38
70
|
// still ahead — renders anyway, because that page *is* the CMS preview.
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { logger, serializeError } from "@decocms/blocks/sdk/logger";
|
|
2
|
+
import { descendantSlugs } from "../core/categoryTree";
|
|
2
3
|
import handlePosts, { slicePosts } from "../core/handlePosts";
|
|
3
4
|
import { getRecordsByPath } from "../core/records";
|
|
4
|
-
import type { BlogPost, SortBy } from "../types";
|
|
5
|
+
import type { BlogPost, Category, SortBy } from "../types";
|
|
5
6
|
|
|
6
7
|
const COLLECTION_PATH = "collections/blog/posts";
|
|
7
8
|
const ACCESSOR = "post";
|
|
9
|
+
const CATEGORIES_PATH = "collections/blog/categories";
|
|
10
|
+
const CATEGORY_ACCESSOR = "category";
|
|
8
11
|
|
|
9
12
|
export interface Props {
|
|
10
13
|
/**
|
|
@@ -19,7 +22,9 @@ export interface Props {
|
|
|
19
22
|
page?: number;
|
|
20
23
|
/**
|
|
21
24
|
* @title Category Slug
|
|
22
|
-
* @description Filter by a
|
|
25
|
+
* @description Filter by a category slug. May be a full path
|
|
26
|
+
* ("parent/child"); posts of every subcategory below the last segment are
|
|
27
|
+
* included.
|
|
23
28
|
*/
|
|
24
29
|
slug?: string;
|
|
25
30
|
/**
|
|
@@ -58,7 +63,22 @@ export default async function BlogPostList(
|
|
|
58
63
|
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
59
64
|
|
|
60
65
|
try {
|
|
61
|
-
|
|
66
|
+
// A parent category also lists its descendants' posts, so the slug expands
|
|
67
|
+
// into the whole subtree below it.
|
|
68
|
+
const leafSlug = (slug ?? "").split("/").filter(Boolean).pop();
|
|
69
|
+
let categorySlugs: string | string[] | undefined = leafSlug;
|
|
70
|
+
|
|
71
|
+
if (leafSlug && !postSlugs?.length) {
|
|
72
|
+
try {
|
|
73
|
+
const categories = getRecordsByPath<Category>(CATEGORIES_PATH, CATEGORY_ACCESSOR);
|
|
74
|
+
categorySlugs = descendantSlugs(leafSlug, categories);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
const error = serializeError(e);
|
|
77
|
+
logger.error(error.message, { error, scope: "blog/BlogpostList/categories" });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handledPosts = await handlePosts(posts, pageSort, categorySlugs, postSlugs, term);
|
|
62
82
|
|
|
63
83
|
if (!handledPosts) return null;
|
|
64
84
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { logger, serializeError } from "@decocms/blocks/sdk/logger";
|
|
2
|
+
import {
|
|
3
|
+
ancestorsOf,
|
|
4
|
+
descendantSlugs,
|
|
5
|
+
indexCategories,
|
|
6
|
+
withCategoryPath,
|
|
7
|
+
} from "../core/categoryTree";
|
|
2
8
|
import handlePosts, { slicePosts } from "../core/handlePosts";
|
|
3
9
|
import { getRecordsByPath } from "../core/records";
|
|
4
10
|
import type { BlogPost, BlogPostListingPage, Category, PageInfo, SortBy } from "../types";
|
|
@@ -11,7 +17,9 @@ const CATEGORY_ACCESSOR = "category";
|
|
|
11
17
|
export interface Props {
|
|
12
18
|
/**
|
|
13
19
|
* @title Category Slug
|
|
14
|
-
* @description Filter by a
|
|
20
|
+
* @description Filter by a category slug. May be a full path
|
|
21
|
+
* ("parent/child"); posts of every subcategory below the last segment are
|
|
22
|
+
* included.
|
|
15
23
|
*/
|
|
16
24
|
slug?: string;
|
|
17
25
|
/**
|
|
@@ -72,13 +80,6 @@ export default async function BlogPostListing(
|
|
|
72
80
|
const posts = getRecordsByPath<BlogPost>(COLLECTION_PATH, ACCESSOR);
|
|
73
81
|
|
|
74
82
|
try {
|
|
75
|
-
const handledPosts = await handlePosts(posts, pageSort, slug, undefined, term);
|
|
76
|
-
|
|
77
|
-
if (!handledPosts) return null;
|
|
78
|
-
|
|
79
|
-
const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
|
|
80
|
-
if (slicedPosts.length === 0) return null;
|
|
81
|
-
|
|
82
83
|
let categories: Category[] | null = null;
|
|
83
84
|
try {
|
|
84
85
|
categories = loadCategories();
|
|
@@ -86,24 +87,52 @@ export default async function BlogPostListing(
|
|
|
86
87
|
report("blog/BlogpostListing/categories", e);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// The slug prop carries the whole category path ("pai/filho") when the
|
|
91
|
+
// site routes the listing as a catch-all; only the leaf identifies the
|
|
92
|
+
// category, the segments before it are its ancestors.
|
|
93
|
+
const requestedSegments = (slug ?? "").split("/").filter(Boolean);
|
|
94
|
+
const leafSlug = requestedSegments[requestedSegments.length - 1];
|
|
95
|
+
|
|
96
|
+
const index = indexCategories(categories);
|
|
97
|
+
const chain = leafSlug ? ancestorsOf(leafSlug, index) : null;
|
|
98
|
+
|
|
99
|
+
// A parent lists its own posts plus every descendant's.
|
|
100
|
+
const categorySlugs = leafSlug ? descendantSlugs(leafSlug, categories) : undefined;
|
|
101
|
+
|
|
102
|
+
const handledPosts = await handlePosts(posts, pageSort, categorySlugs, undefined, term);
|
|
103
|
+
|
|
104
|
+
if (!handledPosts) return null;
|
|
105
|
+
|
|
106
|
+
const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage);
|
|
107
|
+
if (slicedPosts.length === 0) return null;
|
|
108
|
+
|
|
89
109
|
let category: Category | null = null;
|
|
90
|
-
if (
|
|
110
|
+
if (leafSlug) {
|
|
91
111
|
// Prefer the standalone category record (it carries description and
|
|
92
112
|
// sections); fall back to the denormalized copy embedded in a post,
|
|
93
113
|
// which is all a site that never created category records will have.
|
|
94
|
-
category =
|
|
95
|
-
|
|
114
|
+
category =
|
|
115
|
+
index.get(leafSlug) ??
|
|
116
|
+
slicedPosts[0]?.categories?.find((c) => c?.slug === leafSlug) ??
|
|
117
|
+
null;
|
|
96
118
|
}
|
|
97
119
|
|
|
120
|
+
// Only a chain that came out of the records is trustworthy enough to name
|
|
121
|
+
// a canonical URL; a missing or broken one keeps the flat behaviour.
|
|
122
|
+
const canonical = chain ? withCategoryPath(url, chain, { requested: requestedSegments }) : null;
|
|
123
|
+
|
|
98
124
|
return {
|
|
99
125
|
posts: slicedPosts,
|
|
100
126
|
category,
|
|
101
127
|
categories,
|
|
128
|
+
categoryPath: chain ?? (category ? [category] : null),
|
|
102
129
|
pageInfo: toPageInfo(handledPosts, postsPerPage, pageNumber, params),
|
|
103
130
|
seo: {
|
|
104
131
|
title: category?.name ?? "",
|
|
105
132
|
description: category?.description,
|
|
106
|
-
|
|
133
|
+
// Reached through a stale or wrong path, the page still renders and
|
|
134
|
+
// points at the one canonical URL instead of 404ing.
|
|
135
|
+
canonical: canonical ?? new URL(url.pathname, url.origin).href,
|
|
107
136
|
},
|
|
108
137
|
};
|
|
109
138
|
} catch (e) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { childrenOf } from "../core/categoryTree";
|
|
1
2
|
import { getRecordsByPath } from "../core/records";
|
|
2
3
|
import type { Category } from "../types";
|
|
3
4
|
|
|
@@ -10,6 +11,11 @@ export interface Props {
|
|
|
10
11
|
* @description Get the category data from a specific slug.
|
|
11
12
|
*/
|
|
12
13
|
slug?: string;
|
|
14
|
+
/**
|
|
15
|
+
* @title Parent category slug
|
|
16
|
+
* @description Return only the direct children of this category.
|
|
17
|
+
*/
|
|
18
|
+
parentSlug?: string;
|
|
13
19
|
/**
|
|
14
20
|
* @title Items count
|
|
15
21
|
* @description Number of categories to return
|
|
@@ -29,6 +35,7 @@ export interface Props {
|
|
|
29
35
|
export default function GetCategories({
|
|
30
36
|
count,
|
|
31
37
|
slug,
|
|
38
|
+
parentSlug,
|
|
32
39
|
sortBy = "title_desc",
|
|
33
40
|
}: Props): Category[] | null {
|
|
34
41
|
const categories = getRecordsByPath<Category>(COLLECTION_PATH, ACCESSOR);
|
|
@@ -53,7 +60,9 @@ export default function GetCategories({
|
|
|
53
60
|
|
|
54
61
|
if (!validCategories.length) return null;
|
|
55
62
|
|
|
56
|
-
const
|
|
63
|
+
const scoped = parentSlug ? childrenOf(parentSlug, validCategories) : validCategories;
|
|
64
|
+
|
|
65
|
+
const sortedCategories = scoped.sort((a, b) => {
|
|
57
66
|
const comparison = a.name.localeCompare(b.name);
|
|
58
67
|
return sortBy.endsWith("_desc") ? comparison : -comparison;
|
|
59
68
|
});
|
package/src/mod.ts
CHANGED
|
@@ -22,14 +22,16 @@ import type { Publisher } from "./types";
|
|
|
22
22
|
export interface Props {
|
|
23
23
|
/**
|
|
24
24
|
* @title Category Slug
|
|
25
|
-
* @description The slug of the Categories of the blogposts. Use :category
|
|
26
|
-
*
|
|
25
|
+
* @description The slug of the Categories of the blogposts. Use :category.
|
|
26
|
+
* Categories can nest, so the route must be a catch-all for subcategories to
|
|
27
|
+
* resolve.
|
|
28
|
+
* @example /blog/:category*
|
|
27
29
|
*/
|
|
28
30
|
categorySlug?: string;
|
|
29
31
|
/**
|
|
30
32
|
* @title Page Slug
|
|
31
33
|
* @description The slug of the BlogPostPage to embed. Use :category and :slug.
|
|
32
|
-
* @example /blog/:category
|
|
34
|
+
* @example /blog/:category*\/:slug
|
|
33
35
|
*/
|
|
34
36
|
pageSlug?: string;
|
|
35
37
|
/**
|
|
@@ -52,7 +52,9 @@ export function loader(props: Props, req?: Request, seo?: BlogSeoDefaults) {
|
|
|
52
52
|
toBlogPosting(jsonLD.post, pageUrl, publisher),
|
|
53
53
|
toBreadcrumbList(pageUrl, {
|
|
54
54
|
currentName: jsonLD.post.title,
|
|
55
|
-
|
|
55
|
+
// The ancestor chain, so every category segment of the canonical path
|
|
56
|
+
// resolves to its real name instead of a humanized slug.
|
|
57
|
+
categories: jsonLD.categories ?? jsonLD.post.categories,
|
|
56
58
|
}),
|
|
57
59
|
]
|
|
58
60
|
: [];
|
|
@@ -51,22 +51,27 @@ export default function Template({ post }: Props) {
|
|
|
51
51
|
excerpt = "Excerpt",
|
|
52
52
|
date,
|
|
53
53
|
image,
|
|
54
|
+
mobileImage,
|
|
54
55
|
alt,
|
|
55
56
|
sections,
|
|
56
57
|
slug,
|
|
57
58
|
categories,
|
|
58
59
|
} = post;
|
|
59
60
|
|
|
61
|
+
// Only the leaf slug, not the ancestor path: resolving the chain needs
|
|
62
|
+
// `getRecordsByPath`, which pulls the server-only `@decocms/blocks/cms`
|
|
63
|
+
// entry into this client-reachable section. The preview iframe lands on the
|
|
64
|
+
// flat URL, which still renders.
|
|
60
65
|
const postCategorySlug = categories?.[0]?.slug ?? "";
|
|
61
66
|
|
|
62
67
|
if (pageSlug) {
|
|
63
|
-
const resolvedUrl = pageSlug.replace(
|
|
68
|
+
const resolvedUrl = pageSlug.replace(/:category\*?/, postCategorySlug).replace(":slug", slug);
|
|
64
69
|
|
|
65
70
|
return <iframe src={resolvedUrl} style={iframeStyle} title={title} />;
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
if (categorySlug) {
|
|
69
|
-
const resolvedUrl = categorySlug.replace(
|
|
74
|
+
const resolvedUrl = categorySlug.replace(/:category\*?/, postCategorySlug);
|
|
70
75
|
|
|
71
76
|
return <iframe src={resolvedUrl} style={iframeStyle} title={title} />;
|
|
72
77
|
}
|
|
@@ -87,7 +92,12 @@ export default function Template({ post }: Props) {
|
|
|
87
92
|
})
|
|
88
93
|
: ""}
|
|
89
94
|
</p>
|
|
90
|
-
{image &&
|
|
95
|
+
{image && (
|
|
96
|
+
<picture>
|
|
97
|
+
{mobileImage && <source media="(max-width: 767px)" srcSet={mobileImage} />}
|
|
98
|
+
<img className="w-full rounded-2xl bg-cover" src={image} alt={alt ?? title} />
|
|
99
|
+
</picture>
|
|
100
|
+
)}
|
|
91
101
|
<div dangerouslySetInnerHTML={{ __html: content as string }} />
|
|
92
102
|
<div className="content-sections">{sections?.map(renderSection)}</div>
|
|
93
103
|
</div>
|
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
export type Props = {
|
|
2
2
|
url: string;
|
|
3
|
+
/** @description Optional mobile-optimized image. Served below 768px. */
|
|
4
|
+
mobileUrl?: string;
|
|
3
5
|
alt?: string;
|
|
4
6
|
caption?: string;
|
|
5
7
|
size?: "full" | "normal";
|
|
8
|
+
/** @description Loads eagerly with high fetch priority. Use for above-the-fold images. */
|
|
9
|
+
highPriority?: boolean;
|
|
6
10
|
};
|
|
7
11
|
|
|
8
|
-
export default function BlockImage({
|
|
12
|
+
export default function BlockImage({
|
|
13
|
+
url,
|
|
14
|
+
mobileUrl,
|
|
15
|
+
alt,
|
|
16
|
+
caption,
|
|
17
|
+
size,
|
|
18
|
+
highPriority = false,
|
|
19
|
+
}: Props) {
|
|
9
20
|
const wrapperClass = size === "full" ? "my-8 -mx-[var(--gutter)]" : "my-8";
|
|
10
21
|
|
|
11
22
|
return (
|
|
12
23
|
<figure className={wrapperClass}>
|
|
13
24
|
<div className="overflow-hidden bg-alt">
|
|
14
|
-
<
|
|
25
|
+
<picture>
|
|
26
|
+
{mobileUrl && <source media="(max-width: 767px)" srcSet={mobileUrl} />}
|
|
27
|
+
<img
|
|
28
|
+
src={url}
|
|
29
|
+
alt={alt ?? ""}
|
|
30
|
+
{...(highPriority
|
|
31
|
+
? { loading: "eager" as const, fetchPriority: "high" as const }
|
|
32
|
+
: { loading: "lazy" as const, decoding: "async" as const })}
|
|
33
|
+
className="w-full h-auto block"
|
|
34
|
+
/>
|
|
35
|
+
</picture>
|
|
15
36
|
</div>
|
|
16
37
|
{caption && (
|
|
17
38
|
<figcaption className="text-sm text-tertiary text-center mt-3 italic">{caption}</figcaption>
|
package/src/types.ts
CHANGED
|
@@ -36,12 +36,18 @@ export interface Author {
|
|
|
36
36
|
avatar?: ImageWidget;
|
|
37
37
|
jobTitle?: string;
|
|
38
38
|
company?: string;
|
|
39
|
+
url?: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export interface Category {
|
|
42
43
|
name: string;
|
|
43
44
|
slug: string;
|
|
44
45
|
description?: string;
|
|
46
|
+
/**
|
|
47
|
+
* @title Parent category
|
|
48
|
+
* @description Slug of the parent category. Leave empty for a root category.
|
|
49
|
+
*/
|
|
50
|
+
parentSlug?: string;
|
|
45
51
|
/**
|
|
46
52
|
* @title Sections
|
|
47
53
|
* @label hidden
|
|
@@ -61,6 +67,11 @@ export interface BlogPost {
|
|
|
61
67
|
* @title Alt text for the image
|
|
62
68
|
*/
|
|
63
69
|
alt?: string;
|
|
70
|
+
/**
|
|
71
|
+
* @title Mobile image
|
|
72
|
+
* @description Optional mobile-optimized version of the main image. Served below 768px. Falls back to the main image when empty.
|
|
73
|
+
*/
|
|
74
|
+
mobileImage?: ImageWidget;
|
|
64
75
|
/**
|
|
65
76
|
* @widget blog
|
|
66
77
|
* @collection authors
|
|
@@ -220,6 +231,11 @@ export interface Publisher {
|
|
|
220
231
|
export interface BlogPostPage {
|
|
221
232
|
"@type": "BlogPostPage";
|
|
222
233
|
post: BlogPost;
|
|
234
|
+
/**
|
|
235
|
+
* @title Category path
|
|
236
|
+
* @description Ancestor chain of the post's primary category, root first.
|
|
237
|
+
*/
|
|
238
|
+
categories?: Category[] | null;
|
|
223
239
|
seo?: Seo | null;
|
|
224
240
|
}
|
|
225
241
|
|
|
@@ -237,6 +253,11 @@ export interface BlogPostListingPage {
|
|
|
237
253
|
category?: Category | null;
|
|
238
254
|
/** @title Categories */
|
|
239
255
|
categories?: Category[] | null;
|
|
256
|
+
/**
|
|
257
|
+
* @title Category path
|
|
258
|
+
* @description Ancestor chain of the active category, root first.
|
|
259
|
+
*/
|
|
260
|
+
categoryPath?: Category[] | null;
|
|
240
261
|
pageInfo: PageInfo;
|
|
241
262
|
seo: Seo;
|
|
242
263
|
}
|
|
@@ -123,9 +123,14 @@ function blockToSection(
|
|
|
123
123
|
case "image":
|
|
124
124
|
return toSection(`${BASE}/BlockImage.tsx`, {
|
|
125
125
|
url: content.url,
|
|
126
|
+
mobileUrl: content.mobileUrl,
|
|
126
127
|
alt: content.alt,
|
|
127
128
|
caption: content.caption,
|
|
128
129
|
size: content.size,
|
|
130
|
+
// Upstream does not forward this, which leaves every block-pipeline
|
|
131
|
+
// image lazy-loaded and the prop reachable only on a hand-placed
|
|
132
|
+
// BlockImage section.
|
|
133
|
+
highPriority: content.highPriority,
|
|
129
134
|
});
|
|
130
135
|
|
|
131
136
|
case "video":
|
package/src/utils/jsonLD.ts
CHANGED
|
@@ -5,6 +5,9 @@ const toAuthor = (author: Author) => {
|
|
|
5
5
|
return {
|
|
6
6
|
"@type": type,
|
|
7
7
|
name: author.name,
|
|
8
|
+
// url and image are valid on both Person and Organization
|
|
9
|
+
...(author.url ? { url: author.url } : {}),
|
|
10
|
+
...(author.avatar ? { image: author.avatar } : {}),
|
|
8
11
|
// jobTitle and worksFor are Person-only properties
|
|
9
12
|
...(type === "Person" && author.jobTitle ? { jobTitle: author.jobTitle } : {}),
|
|
10
13
|
...(type === "Person" && author.company
|