Skip to content

Commit 96e13ca

Browse files
aka-sacci-ccrdecobotclaude
authored
feat(blog): add draft/published lifecycle to blog posts (#1658)
* feat(blog): add draft/published lifecycle to blog posts Adds an optional `status?: "draft" | "published"` to `BlogPost`. Only the exact literal "draft" is treated as a draft: absent, "published", and any unexpected string a site set for its own purposes all resolve to published. Every post that exists today has no status field, so resolving absent to draft would empty every blog in production on the version bump. Drafts are dropped in `filterRoutablePosts`, the existing choke point for "is this post reachable", so all three list loaders inherit it. The detail loaders keep serving drafts — that page is how the CMS previews unpublished work — and instead force `seo.noIndexing`, preserving whatever else the post declared under `seo`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(blog): move tests out of loaders/ so the bundler skips them The bundler sweeps every .ts under a block directory and registers it as a block, so blogPostDetail.test.ts was picked up as a loader and failed the manifest's LoaderModule constraint — a test file has no default export. Both blog tests now live in blog/tests/, which is not a block directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(blog): align post status with the vocabulary from #1603 #1603 defines the status vocabulary the autonomous-blog agent and the Studio editor actually write: draft, published, archived, generating, awaiting_review. A two-literal union would have let a post mid-generation go live, since a status this app didn't recognize was treated as published. Adopts that PR's `PostStatus` and `isPublishedStatus`, in types.ts under the same names, so whichever lands second is a near-trivial merge. The check is now an allowlist: only absent (legacy posts) and "published" are live. Absent still means published — that remains the upgrade-safety guarantee, and "" is covered too, since that is what an unset CMS field serializes to. Behaviour otherwise unchanged: lists filter at filterRoutablePosts, and the detail loaders keep serving unpublished posts with seo.noIndexing forced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: decobot <capy@deco.cx> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3670ea3 commit 96e13ca

6 files changed

Lines changed: 243 additions & 8 deletions

File tree

blog/core/handlePosts.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { postViews } from "../db/schema.ts";
22
import { AppContext } from "../mod.ts";
3-
import { BlogPost, SortBy, ViewFromDatabase } from "../types.ts";
3+
import {
4+
BlogPost,
5+
isPublishedStatus,
6+
SortBy,
7+
ViewFromDatabase,
8+
} from "../types.ts";
49
import { VALID_SORT_ORDERS } from "../utils/constants.ts";
510

611
/** An ISO 8601 date or date-time carrying no timezone designator. */
@@ -184,14 +189,19 @@ export const slicePosts = (
184189

185190
/**
186191
* A record without a slug has no route, so it can never be rendered: listing it
187-
* only produces cards linking to the listing itself. Dropped here, before
188-
* slicePosts, so `count` still yields `count` renderable posts.
192+
* only produces cards linking to the listing itself. Unpublished posts are
193+
* unreachable for a different reason — the CMS doesn't consider them ready —
194+
* but the outcome is the same, so both are dropped here, before slicePosts, so
195+
* `count` still yields `count` renderable posts.
189196
*/
190197
export const filterRoutablePosts = (posts: BlogPost[]) =>
191198
// Records come straight from the CMS, so `slug` is only a string by
192199
// convention: the typeof guard keeps a malformed one from throwing here and
193200
// taking the whole listing down with it.
194-
posts.filter(({ slug }) => typeof slug === "string" && slug.trim());
201+
posts.filter((post) =>
202+
typeof post.slug === "string" && post.slug.trim() &&
203+
isPublishedStatus(post.status)
204+
);
195205

196206
const filterPosts = (
197207
allPosts: BlogPost[],

blog/loaders/BlogPostItem.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AppContext } from "../mod.ts";
2-
import { BlogPost } from "../types.ts";
2+
import { BlogPost, isPublishedStatus } from "../types.ts";
33
import { getRecordsByPath } from "../core/records.ts";
44
import type { RequestURLParam } from "../../website/functions/requestToParam.ts";
55

@@ -32,5 +32,16 @@ export default async function BlogPostItem(
3232
ACCESSOR,
3333
);
3434

35-
return posts.find((post) => post.slug === slug) || null;
35+
const post = posts.find((post) => post.slug === slug);
36+
37+
if (!post) {
38+
return null;
39+
}
40+
41+
// An unpublished post is still served — that page *is* the CMS preview — it
42+
// just must never be indexed. Everything else the post declared under `seo`
43+
// is kept as-is.
44+
return isPublishedStatus(post.status)
45+
? post
46+
: { ...post, seo: { ...post.seo, noIndexing: true } };
3647
}

blog/loaders/BlogPostPage.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AppContext } from "../mod.ts";
2-
import { BlogPost, BlogPostPage } from "../types.ts";
2+
import { BlogPost, BlogPostPage, isPublishedStatus } from "../types.ts";
33
import { getRecordsByPath } from "../core/records.ts";
44
import type { RequestURLParam } from "../../website/functions/requestToParam.ts";
55

@@ -47,7 +47,9 @@ export default async function BlogPostPageLoader(
4747
description: post?.seo?.description || post?.excerpt,
4848
canonical: post?.seo?.canonical || url.href,
4949
image: post?.seo?.image || post?.image,
50-
noIndexing: post?.seo?.noIndexing || false,
50+
// An unpublished post still renders — that page *is* the CMS preview —
51+
// it just must never be indexed, even if the URL leaks.
52+
noIndexing: post?.seo?.noIndexing || !isPublishedStatus(post.status),
5153
},
5254
};
5355
}

blog/tests/blogPostDetail.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { assertEquals } from "@std/assert";
2+
import BlogPostItem from "../loaders/BlogPostItem.ts";
3+
import BlogPostPageLoader from "../loaders/BlogPostPage.ts";
4+
import { AppContext } from "../mod.ts";
5+
import { BlogPost } from "../types.ts";
6+
7+
const COLLECTION_PATH = "collections/blog/posts";
8+
9+
/**
10+
* Both loaders read records through `getRecordsByPath`, which resolves
11+
* `{ __resolveType: "resolvables" }` off the context. Stubbing `ctx.get` is
12+
* enough to drive them without a live deco runtime.
13+
*/
14+
const ctxWith = (post: Partial<BlogPost>) =>
15+
({
16+
get: () =>
17+
Promise.resolve({
18+
[`${COLLECTION_PATH}/${post.slug}`]: {
19+
name: `${COLLECTION_PATH}/${post.slug}`,
20+
post,
21+
},
22+
}),
23+
}) as unknown as AppContext;
24+
25+
const draft: Partial<BlogPost> = {
26+
title: "Work in progress",
27+
excerpt: "Not out yet",
28+
date: "2026-01-01",
29+
slug: "wip",
30+
status: "draft",
31+
};
32+
33+
const req = new Request("https://example.com/blog/wip");
34+
35+
Deno.test("BlogPostItem still serves a draft, marked noIndexing", async () => {
36+
const post = await BlogPostItem({ slug: "wip" }, req, ctxWith(draft));
37+
38+
assertEquals(post?.slug, "wip");
39+
assertEquals(post?.seo?.noIndexing, true);
40+
});
41+
42+
Deno.test("BlogPostItem keeps the draft's other seo fields", async () => {
43+
const post = await BlogPostItem(
44+
{ slug: "wip" },
45+
req,
46+
ctxWith({
47+
...draft,
48+
seo: { title: "Custom title", canonical: "https://example.com/canon" },
49+
}),
50+
);
51+
52+
assertEquals(post?.seo?.title, "Custom title");
53+
assertEquals(post?.seo?.canonical, "https://example.com/canon");
54+
assertEquals(post?.seo?.noIndexing, true);
55+
});
56+
57+
Deno.test("BlogPostItem leaves a published post untouched", async () => {
58+
const post = await BlogPostItem(
59+
{ slug: "live" },
60+
req,
61+
ctxWith({ ...draft, slug: "live", status: "published" }),
62+
);
63+
64+
assertEquals(post?.slug, "live");
65+
assertEquals(post?.seo?.noIndexing, undefined);
66+
});
67+
68+
Deno.test("BlogPostPage still serves a draft, marked noIndexing", async () => {
69+
const page = await BlogPostPageLoader({ slug: "wip" }, req, ctxWith(draft));
70+
71+
assertEquals(page?.post.slug, "wip");
72+
assertEquals(page?.seo?.noIndexing, true);
73+
});
74+
75+
Deno.test("BlogPostPage keeps the draft's other seo fields", async () => {
76+
const page = await BlogPostPageLoader(
77+
{ slug: "wip" },
78+
req,
79+
ctxWith({
80+
...draft,
81+
seo: { title: "Custom title", canonical: "https://example.com/canon" },
82+
}),
83+
);
84+
85+
assertEquals(page?.seo?.title, "Custom title");
86+
assertEquals(page?.seo?.canonical, "https://example.com/canon");
87+
assertEquals(page?.seo?.noIndexing, true);
88+
});
89+
90+
Deno.test("BlogPostPage leaves a published post indexable", async () => {
91+
const page = await BlogPostPageLoader(
92+
{ slug: "live" },
93+
req,
94+
ctxWith({ ...draft, slug: "live", status: "published" }),
95+
);
96+
97+
assertEquals(page?.seo?.noIndexing, false);
98+
});
99+
100+
Deno.test("every non-published status is served but unindexable", async () => {
101+
for (
102+
const status of [
103+
"draft",
104+
"archived",
105+
"generating",
106+
"awaiting_review",
107+
] as const
108+
) {
109+
const ctx = ctxWith({ ...draft, status });
110+
111+
const item = await BlogPostItem({ slug: "wip" }, req, ctx);
112+
assertEquals(item?.slug, "wip", status);
113+
assertEquals(item?.seo?.noIndexing, true, status);
114+
115+
const page = await BlogPostPageLoader({ slug: "wip" }, req, ctx);
116+
assertEquals(page?.post.slug, "wip", status);
117+
assertEquals(page?.seo?.noIndexing, true, status);
118+
}
119+
});

blog/tests/handlePosts.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { assertEquals } from "@std/assert";
2+
import { filterRoutablePosts } from "../core/handlePosts.ts";
3+
import { BlogPost, isPublishedStatus } from "../types.ts";
4+
5+
const post = (slug: string, status?: string): BlogPost => ({
6+
title: slug,
7+
excerpt: "",
8+
date: "2026-01-01",
9+
slug,
10+
// Records come from the CMS, so a site may well have written a string that
11+
// isn't in the union. Cast so the tests can exercise exactly that.
12+
status: status as BlogPost["status"],
13+
});
14+
15+
const listed = (posts: BlogPost[]) =>
16+
filterRoutablePosts(posts).map(({ slug }) => slug);
17+
18+
Deno.test("absent status is published", () => {
19+
assertEquals(isPublishedStatus(undefined), true);
20+
assertEquals(listed([post("no-status")]), ["no-status"]);
21+
});
22+
23+
Deno.test('"published" is published', () => {
24+
assertEquals(isPublishedStatus("published"), true);
25+
assertEquals(listed([post("live", "published")]), ["live"]);
26+
});
27+
28+
Deno.test("every non-published status is dropped from lists", () => {
29+
for (const status of ["draft", "archived", "generating", "awaiting_review"]) {
30+
assertEquals(isPublishedStatus(status), false, status);
31+
assertEquals(
32+
listed([post("live"), post(status, status)]),
33+
["live"],
34+
status,
35+
);
36+
}
37+
});
38+
39+
Deno.test("an unrecognized status is treated as not ready, so it is dropped", () => {
40+
// The vocabulary is an allowlist: a status this app doesn't know is a state
41+
// the CMS added, and shipping a half-written post is worse than hiding one.
42+
assertEquals(isPublishedStatus("some_future_state"), false);
43+
assertEquals(listed([post("unknown", "some_future_state")]), []);
44+
});
45+
46+
Deno.test("an empty status is published, not hidden", () => {
47+
// Distinct from the case above: "" is what an unset CMS field serializes to,
48+
// so it has to behave like absent or those posts vanish on upgrade.
49+
assertEquals(isPublishedStatus(""), true);
50+
assertEquals(listed([post("blank", "")]), ["blank"]);
51+
});
52+
53+
Deno.test("unroutable posts are still dropped alongside unpublished ones", () => {
54+
assertEquals(listed([post("live"), post(" "), post("wip", "draft")]), [
55+
"live",
56+
]);
57+
});

blog/types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export interface BlogPost {
6464
*/
6565
dateModified?: string;
6666
slug: string;
67+
/**
68+
* @title Status
69+
* @description Publication status. Anything other than `published` is kept out of listings and never indexed. Posts with no status are treated as published.
70+
*/
71+
status?: PostStatus;
6772
/**
6873
* @title Post Content
6974
* @format rich-text
@@ -103,6 +108,37 @@ export interface BlogPost {
103108
id?: string;
104109
}
105110

111+
/**
112+
* Publication status of a post. `published` (or an absent value, for legacy
113+
* posts) renders on the live site; every other value keeps the post out of
114+
* listings and out of the index.
115+
*
116+
* `generating` and `awaiting_review` are written by the autonomous-blog agent
117+
* while a post is still being produced, which is why the check below is an
118+
* allowlist: a status this app does not recognize is a post the CMS does not
119+
* consider ready, so it must not leak into a listing.
120+
*/
121+
export type PostStatus =
122+
| "draft"
123+
| "published"
124+
| "archived"
125+
| "generating"
126+
| "awaiting_review";
127+
128+
/**
129+
* A post is live when it has no status at all or is explicitly `published`.
130+
*
131+
* The absent case is load-bearing: `status` was added long after the first
132+
* posts were written, so every existing record is missing it. Requiring an
133+
* explicit `published` would empty every blog in production the moment a site
134+
* bumps this app.
135+
*
136+
* Takes a plain `string` so it can also be applied to a raw CMS record, where
137+
* the value is only a `PostStatus` by convention.
138+
*/
139+
export const isPublishedStatus = (status?: string): boolean =>
140+
!status || status === "published";
141+
106142
export interface ExtraProps {
107143
key: string;
108144
value: string;

0 commit comments

Comments
 (0)