@decocms/apps-blog 7.84.1 → 7.85.1
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 +4 -4
- package/src/README.md +13 -0
- package/src/__tests__/publicationStatus.test.ts +43 -7
- package/src/index.ts +1 -0
- package/src/loaders/BlogPostItem.ts +12 -6
- package/src/loaders/BlogPostPage.ts +9 -4
- package/src/types.ts +4 -3
- package/src/utils/draftPreview.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-blog",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.85.1",
|
|
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.
|
|
42
|
-
"@decocms/apps-commerce": "7.
|
|
43
|
-
"@decocms/apps-website": "7.
|
|
41
|
+
"@decocms/blocks": "7.85.1",
|
|
42
|
+
"@decocms/apps-commerce": "7.85.1",
|
|
43
|
+
"@decocms/apps-website": "7.85.1"
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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.
|
|
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
|
|
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";
|