@decocms/apps-blog 7.84.0 → 7.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-blog",
3
- "version": "7.84.0",
3
+ "version": "7.85.0",
4
4
  "type": "module",
5
5
  "description": "Deco app: blog content and CMS integration",
6
6
  "repository": {
@@ -38,9 +38,9 @@
38
38
  "lint:unused": "knip"
39
39
  },
40
40
  "dependencies": {
41
- "@decocms/blocks": "7.84.0",
42
- "@decocms/apps-commerce": "7.84.0",
43
- "@decocms/apps-website": "7.84.0"
41
+ "@decocms/blocks": "7.85.0",
42
+ "@decocms/apps-commerce": "7.85.0",
43
+ "@decocms/apps-website": "7.85.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "react": "^19.0.0",
package/src/README.md CHANGED
@@ -56,6 +56,19 @@ the Deno app *threw* `"Deco Records not installed!"` on a view sort without the
56
56
  backend; degrading was chosen instead, since view sorting is an enhancement and
57
57
  a missing backend shouldn't blank a listing.
58
58
 
59
+ ## Draft preview (`?draft=true`)
60
+
61
+ A post that isn't live (see `isLivePost`) only renders on its own page when
62
+ the URL carries `?draft=true`: `BlogPostPage` and `BlogPostItem` answer `null`
63
+ for it otherwise, as for a slug that doesn't exist, and mark it `noIndexing`
64
+ in a preview. Only the literal `true` turns it on. Listings are unaffected:
65
+ they never show a non-live post. `isDraftPreview(url)` is exported for a site
66
+ that renders its own post page.
67
+
68
+ Anyone who knows the parameter can open an unpublished post: it is a
69
+ convenience for previews, not access control. Same behaviour as
70
+ `deco-cx/apps` (deco-cx/apps#1698).
71
+
59
72
  ## Divergences from upstream worth knowing
60
73
 
61
74
  - **Title and view sorts are inverted** — `title_asc` yields Z-A, and
@@ -4,8 +4,8 @@
4
4
  * Scope: the read-time publication gate — `isPublishedStatus` / `isLivePost`,
5
5
  * `filterRoutablePosts`, and the fact that the gate runs *before* pagination so
6
6
  * `count` yields `count` renderable posts. Also covers the counterpart on the
7
- * detail loaders: a non-live post still renders (it is the CMS preview) but is
8
- * marked `noIndexing`.
7
+ * detail loaders: a non-live post only renders in a `?draft=true` preview, and
8
+ * then is marked `noIndexing`.
9
9
  *
10
10
  * Deliberately not covered here: the strict-parsing edge cases of
11
11
  * `scheduledTime` (see `date.test.ts`) and anything requiring a records
@@ -145,29 +145,65 @@ describe("listing pagination applies the gate before slicing", () => {
145
145
  });
146
146
  });
147
147
 
148
- describe("detail loaders serve non-live posts as previews, unindexed", () => {
148
+ describe("detail loaders serve non-live posts only in a ?draft=true preview", () => {
149
149
  beforeEach(() => {
150
150
  vi.clearAllMocks();
151
151
  });
152
152
 
153
- it("BlogPostPage marks a draft noIndexing but still renders it", () => {
153
+ const preview = new Request("http://localhost/blog/d?draft=true");
154
+
155
+ it("BlogPostPage renders a draft in preview, marked noIndexing", () => {
154
156
  mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft" })]);
155
- const result = BlogPostPageLoader({ slug: "d" });
157
+ const result = BlogPostPageLoader({ slug: "d" }, preview);
156
158
  expect(result?.post.slug).toBe("d");
157
159
  expect(result?.seo?.noIndexing).toBe(true);
158
160
  });
159
161
 
162
+ it("BlogPostPage answers null for a draft without the flag", () => {
163
+ mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft" })]);
164
+ expect(BlogPostPageLoader({ slug: "d" })).toBeNull();
165
+ for (const query of ["", "?draft=1", "?draft=false", "?draft", "?__draft=true"]) {
166
+ const req = new Request(`http://localhost/blog/d${query}`);
167
+ expect(BlogPostPageLoader({ slug: "d" }, req)).toBeNull();
168
+ }
169
+ });
170
+
171
+ it("BlogPostPage gates a scheduled post until its instant, unless previewed", () => {
172
+ mockGetRecords.mockReturnValue([
173
+ post({ slug: "s", status: "scheduled", scheduledDatetime: iso(HOUR) }),
174
+ post({ slug: "p", status: "scheduled", scheduledDatetime: iso(-HOUR) }),
175
+ ]);
176
+ expect(BlogPostPageLoader({ slug: "s" })).toBeNull();
177
+ expect(BlogPostPageLoader({ slug: "s" }, preview)?.seo?.noIndexing).toBe(true);
178
+ expect(BlogPostPageLoader({ slug: "p" })?.seo?.noIndexing).toBe(false);
179
+ });
180
+
181
+ it("BlogPostPage reads the flag from __pageUrl when there is no request", () => {
182
+ mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft" })]);
183
+ const result = BlogPostPageLoader({
184
+ slug: "d",
185
+ __pageUrl: "http://localhost/blog/d?draft=true",
186
+ });
187
+ expect(result?.post.slug).toBe("d");
188
+ });
189
+
160
190
  it("BlogPostPage leaves a live post indexable", () => {
161
191
  mockGetRecords.mockReturnValue([post({ slug: "p", status: "published" })]);
162
192
  expect(BlogPostPageLoader({ slug: "p" })?.seo?.noIndexing).toBe(false);
163
193
  });
164
194
 
165
- it("BlogPostItem stamps noIndexing without discarding the post's own seo", () => {
195
+ it("BlogPostItem stamps noIndexing in preview without discarding the post's own seo", () => {
166
196
  mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft", seo: { title: "Kept" } })]);
167
- const result = BlogPostItem({ slug: "d" });
197
+ const result = BlogPostItem({ slug: "d" }, preview);
168
198
  expect(result?.seo).toEqual({ title: "Kept", noIndexing: true });
169
199
  });
170
200
 
201
+ it("BlogPostItem answers null for a draft without the flag", () => {
202
+ mockGetRecords.mockReturnValue([post({ slug: "d", status: "draft" })]);
203
+ expect(BlogPostItem({ slug: "d" })).toBeNull();
204
+ expect(BlogPostItem({ slug: "d" }, new Request("http://localhost/blog/d"))).toBeNull();
205
+ });
206
+
171
207
  it("BlogPostItem returns a live post untouched", () => {
172
208
  const live = post({ slug: "p" });
173
209
  mockGetRecords.mockReturnValue([live]);
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export type {
53
53
  export { isLivePost, isPublishedStatus } from "./types";
54
54
  export { blocksToSections } from "./utils/blocksToSections";
55
55
  export { dateToTime, scheduledTime } from "./utils/date";
56
+ export { DRAFT_PREVIEW_PARAM, isDraftPreview } from "./utils/draftPreview";
56
57
  export { hardSanitize } from "./utils/hardSanitize";
57
58
  export { toBlogPosting, toBreadcrumbList, toOrganization, withCanonicalBase } from "./utils/jsonLD";
58
59
  export { sanitizeHref, sanitizeHtml } from "./utils/sanitizeHtml";
@@ -1,6 +1,7 @@
1
1
  import { getRecordsByPath } from "../core/records";
2
2
  import type { BlogPost } from "../types";
3
3
  import { isLivePost } from "../types";
4
+ import { isDraftPreview } from "../utils/draftPreview";
4
5
 
5
6
  export interface Props {
6
7
  slug: string;
@@ -11,7 +12,10 @@ export interface Props {
11
12
  * @description Fetches a single blog post by slug. Returns the BlogPost
12
13
  * directly (not wrapped in BlogPostPage).
13
14
  */
14
- export default function BlogPostItem(props: Props & { __pageUrl?: string }): BlogPost | null {
15
+ export default function BlogPostItem(
16
+ props: Props & { __pageUrl?: string },
17
+ req?: Request,
18
+ ): BlogPost | null {
15
19
  const { slug } = props;
16
20
  if (!slug) return null;
17
21
 
@@ -19,11 +23,13 @@ export default function BlogPostItem(props: Props & { __pageUrl?: string }): Blo
19
23
  const post = posts.find((p) => p?.slug === slug);
20
24
 
21
25
  if (!post) return null;
26
+ if (isLivePost(post)) return post;
22
27
 
23
28
  // A post that isn't live yet — unpublished, or scheduled for an instant still
24
- // ahead — is still served, because that page *is* the CMS preview. It just
25
- // must never be indexed. Everything else the post declared under `seo` is
26
- // kept as-is, and a scheduled post becomes indexable on its own once its
27
- // instant passes.
28
- return isLivePost(post) ? post : { ...post, seo: { ...post.seo, noIndexing: true } };
29
+ // ahead — is only served in a `?draft=true` preview, and then must never be
30
+ // indexed. Everything else the post declared under `seo` is kept as-is, and a
31
+ // scheduled post becomes indexable on its own once its instant passes.
32
+ const url = new URL(req?.url ?? props.__pageUrl ?? "http://localhost/");
33
+ if (!isDraftPreview(url)) return null;
34
+ return { ...post, seo: { ...post.seo, noIndexing: true } };
29
35
  }
@@ -3,6 +3,7 @@ import { ancestorsOf, indexCategories, withCategoryPath } from "../core/category
3
3
  import { getRecordsByPath } from "../core/records";
4
4
  import type { BlogPost, BlogPostPage, Category } from "../types";
5
5
  import { isLivePost } from "../types";
6
+ import { isDraftPreview } from "../utils/draftPreview";
6
7
 
7
8
  const COLLECTION_PATH = "collections/blog/posts";
8
9
  const ACCESSOR = "post";
@@ -30,6 +31,11 @@ export default function BlogPostPageLoader(
30
31
 
31
32
  if (!post) return null;
32
33
 
34
+ // A post that isn't live yet — unpublished, or scheduled for an instant still
35
+ // ahead — only renders in a `?draft=true` preview. Without it the page
36
+ // answers as if the post didn't exist.
37
+ if (!isLivePost(post) && !isDraftPreview(url)) return null;
38
+
33
39
  // The post lives under the full path of its primary category, so the
34
40
  // breadcrumb and the canonical need that category's ancestor chain. A broken
35
41
  // categories collection must not take the post page down with it.
@@ -66,10 +72,9 @@ export default function BlogPostPageLoader(
66
72
  description: post?.seo?.description || post?.excerpt,
67
73
  canonical: post?.seo?.canonical || canonical || url.href,
68
74
  image: post?.seo?.image || post?.image,
69
- // A post that isn't live yet — unpublished, or scheduled for an instant
70
- // still ahead — renders anyway, because that page *is* the CMS preview.
71
- // It just must never be indexed, even if the URL leaks. A scheduled post
72
- // becomes indexable on its own once its instant passes.
75
+ // A non-live post reaching this point is a draft preview: it must never
76
+ // be indexed, even if the URL leaks. A scheduled post becomes indexable
77
+ // on its own once its instant passes.
73
78
  noIndexing: post?.seo?.noIndexing || !isLivePost(post),
74
79
  },
75
80
  };
package/src/types.ts CHANGED
@@ -95,7 +95,7 @@ export interface BlogPost {
95
95
  slug: string;
96
96
  /**
97
97
  * @title Status
98
- * @description Publication status. Anything other than `published` is kept out of listings and never indexed. Posts with no status are treated as published.
98
+ * @description Publication status. A post is live with no status, as `published`, or as `scheduled` once its scheduled date has passed. Any other post (a draft, a `scheduled` post before its date, or any other status) is kept out of listings and never indexed, and its page only renders with `?draft=true` in the URL.
99
99
  */
100
100
  status?: PostStatus;
101
101
  /**
@@ -146,8 +146,9 @@ export interface BlogPost {
146
146
  /**
147
147
  * Publication status of a post. `published` (or an absent value, for legacy
148
148
  * posts) renders on the live site; `scheduled` renders once its
149
- * `scheduledDatetime` has passed; every other value keeps the post out of
150
- * listings and out of the index.
149
+ * `scheduledDatetime` has passed. Until then, and for every other value, the
150
+ * post is kept out of listings and out of the index, and its own page only
151
+ * renders in a `?draft=true` preview.
151
152
  *
152
153
  * `generating` and `awaiting_review` are written by the autonomous-blog agent
153
154
  * while a post is still being produced, which is why the checks below are
@@ -0,0 +1,13 @@
1
+ /** Query parameter that opens a post page in draft preview. */
2
+ export const DRAFT_PREVIEW_PARAM = "draft";
3
+
4
+ /**
5
+ * Whether the page was opened as a draft preview (`?draft=true`).
6
+ *
7
+ * A post that isn't live (a draft, a post still being generated, a scheduled
8
+ * post whose instant hasn't arrived) only renders on its own page in a
9
+ * preview; anywhere else the page answers as if the post didn't exist. Only the
10
+ * literal `true` turns it on: any other value, or none, is the live site.
11
+ */
12
+ export const isDraftPreview = (url: URL): boolean =>
13
+ url.searchParams.get(DRAFT_PREVIEW_PARAM) === "true";