@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 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": "7.71.6",
3
+ "version": "8.0.0-next.0",
4
4
  "type": "module",
5
- "description": "Deco app: blog content and CMS integration",
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": "7.71.6",
42
- "@decocms/apps-commerce": "7.71.6",
43
- "@decocms/apps-website": "7.71.6"
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/:slug`.
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
+ };
@@ -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 filter by
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
- slug ? posts.filter(({ categories }) => categories?.find((c) => c.slug === slug)) : posts;
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.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug)));
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
- if (typeof slug === "string") {
138
- const firstFilter =
139
- postSlugs && postSlugs.length > 0
140
- ? filterPostsBySlugs(posts, postSlugs)
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(posts, term) : posts;
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 specific category slug.
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
- const handledPosts = await handlePosts(posts, pageSort, slug, postSlugs, term);
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 specific category slug.
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 (slug) {
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 = categories?.find((c) => c.slug === slug) ?? null;
95
- category ??= slicedPosts[0]?.categories?.find((c) => c.slug === slug) ?? null;
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
- canonical: new URL(url.pathname, url.origin).href,
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 sortedCategories = validCategories.sort((a, b) => {
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
- * @example /blog/:category
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/:slug
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
- categories: jsonLD.post.categories,
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(":category", postCategorySlug).replace(":slug", slug);
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(":category", postCategorySlug);
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 && <img className="w-full rounded-2xl bg-cover" src={image} alt={alt ?? title} />}
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({ url, alt, caption, size }: Props) {
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
- <img src={url} alt={alt ?? ""} loading="lazy" className="w-full h-auto block" />
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":
@@ -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