feat(blog): add draft/published lifecycle to blog posts - #1658
Conversation
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>
Tagging OptionsShould a new tag be published when this PR is merged?
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe PR adds optional draft status to ChangesDraft blog post handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: 🔵 Low · up to Draft posts remain directly servable for CMS preview and are marked noindex; consuming routes should ensure drafts require preview authorization so unpublished content is not exposed publicly. This is a bounded follow-up risk and the PR is otherwise mergeable with explicit owner awareness. Sequence Diagram(s)sequenceDiagram
participant Request
participant BlogPostItem
participant AppContext
participant BlogPost
Request->>BlogPostItem: Request post by slug
BlogPostItem->>AppContext: Get blog post collection
AppContext->>BlogPost: Resolve matching post
BlogPost-->>BlogPostItem: Return post or no match
BlogPostItem-->>Request: Return post with draft noIndexing or null
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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>
#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>
…1659) * feat(blog): filter posts by status and support scheduled publishing A post can now ship to production ahead of time: the record carries `status: "scheduled"` plus a `scheduledDatetime`, and the loaders decide at read time whether its instant has arrived. There is no cron, no worker and no second commit — crossing the instant is the entire publication event, and it takes effect on the first request that evaluates it after the fact. `isLivePost` composes on top of `isPublishedStatus` and stays an allowlist, so any status added later still fails closed on an app version that predates it. A missing or unparseable `scheduledDatetime` hides the post rather than publishing it: the parse collapses garbage to 0, which would otherwise read as "went live in 1970". `dateToTime` moves to `blog/utils/date.ts` so the scheduling comparison reuses the same UTC pinning as date sorting. Without it an offset-less datetime parses as server-local time, and the same record would go live at different moments depending on which machine served the request. Detail pages keep the behaviour introduced in #1658: a post that isn't live is still served, because that page is the CMS preview, and is forced `noindex` until it is. A scheduled post therefore becomes indexable on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(blog): validate a scheduled instant strictly before publishing `Date` is lenient in two ways that both put a post live at the wrong moment, and `isLivePost` was inheriting both from `dateToTime`: - Non-ISO strings parse in *server-local* time, so `"Sep 1 2026"` would go live at a different instant on every machine, silently defeating the UTC pinning the module exists to guarantee. `"0"` is worse: it resolves to the year 2000, i.e. to "already live". - Calendar overflow rolls forward instead of failing, so a typo'd `"2026-02-31"` publishes on March 3rd. `scheduledTime` now matches an anchored ISO pattern and range-checks the fields before `Date` sees them, returning null for anything else. Publishing needs a stricter parse than sorting does: a misread instant here doesn't reorder a list, it changes what is on the live site. `dateToTime` keeps its lenient 0-on-failure contract for the sort comparator, which wants a total order and no NaN — so the sort path is untouched. Returning null rather than 0 also makes the Unix epoch a representable instant instead of being indistinguishable from a parse failure. A bare `YYYY-MM-DD` is still honoured as midnight UTC: unlike the loose forms it is unambiguous ISO, and rejecting it would strand a post forever over a missing time. 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>
What is this Contribution About?
Adds a draft/published lifecycle to the
blog/app.The status vocabulary comes from #1603 —
draft | published | archived | generating | awaiting_review, exported asPostStatusalongsideisPublishedStatus, under the same names and in the same file (blog/types.ts) as that PR, so whichever lands second is a near-trivial merge.generatingandawaiting_revieware what the autonomous-blog agent writes while a post is still being produced.Absent means published. Every post on every site running this app today has no
statusfield, soisPublishedStatusreturns true for an absent value — and for"", which is what an unset CMS field serializes to. Requiring an explicit"published"would empty every blog in production on the version bump.Everything else is an allowlist. Only absent and
"published"are live; any other value, including one this app doesn't recognize yet, is kept out of listings. With a defined CMS vocabulary that's the safe direction: shipping a half-generated post is worse than hiding one.Lists exclude unpublished posts. Done in
filterRoutablePosts, already the choke point for "should this post be reachable". All three list loaders (BlogpostList,BlogRelatedPosts,BlogpostListing) inherit it throughfilterPosts→handlePosts; none of them changed.Detail loaders keep serving unpublished posts, unindexable.
BlogPostPageandBlogPostItemdo not hide them — serving the page is how the CMS previews unpublished work. They forceseo.noIndexing = trueinstead, using the existingSeo.noIndexingfield. Anyseothe post already carries is preserved; onlynoIndexingis overridden.Files
blog/types.tsstatus?: PostStatusonBlogPost;PostStatus+isPublishedStatusblog/core/handlePosts.tsfilterRoutablePostsdrops unpublished postsblog/loaders/BlogPostPage.tsnoIndexing: post?.seo?.noIndexing || !isPublishedStatus(post.status)blog/loaders/BlogPostItem.ts{ ...post, seo: { ...post.seo, noIndexing: true } }when unpublishedblog/tests/Relationship to #1603
#1603 (
feat/autonomous-blog-compatibility) carries the same status feature bundled with the shoppable product blocks. It's hadCHANGES_REQUESTEDsince 14 Jul, so this PR splits the status half out to land on its own. Two deliberate differences, both open to being switched to that PR's approach if reviewers prefer it:getRecordsByPath, which hides unpublished posts from every loader including the single-post page, then re-admits them via anincludeUnpublishedflag. This filters infilterRoutablePosts, which is list-only, so the detail loaders never need the opt-in.?preview=trueon the single-post URL to show a draft — which only works once Studio appends it. Here the single-post page always renders, so CMS preview works before any Studio change ships.The
PostStatus/isPublishedStatusdefinitions are deliberately identical to #1603's, so the overlap is confined to those two behavioural choices.Verification
grepfor astatusprop on blog records: the only hits areReview/submitReview's unrelated "Review status" comments. No record type already carried astatus, so no site's existing string gets reinterpreted here.filterRoutablePostscallers: exactly one —filterPosts. Nothing else in the repo imports it, so the choke point holds.deno run -A --no-lock jsr:@deco/deco/scripts/bundle) and confirmed no manifest changes — the earlier CI failure was a test file sitting inblog/loaders/, which the bundler registers as a block. Tests now live inblog/tests/, which isn't a block directory.Tests
The repo had no
Deno.testanywhere, so there was no existing tier to match; these use plainDeno.test+@std/assert(already in the import map).blog/tests/handlePosts.test.ts— absent status appears (the upgrade-safety case),""appears,"published"appears, each of the four non-published statuses is dropped, an unrecognized string is dropped, and slug-less records are still dropped alongside unpublished ones.blog/tests/blogPostDetail.test.ts— both detail loaders against a stubbedctx.get: every non-published status is still returned withseo.noIndexingtrue, a draft that already has anseoobject keeps its other fields, and a published post is left indexable and untouched.13 tests pass.
deno task check(fmt + lint +deno check **/mod.ts) is clean — it runs as the pre-commit hook.Rollout
This reaches a site only after an
appsrelease plus a version bump in that site — not on a Studio deploy. The editor UI (decocms/studio#4302) and the spire-agent migration that writespost.statusship separately.Issue Link
Loom Video
n/a — no visual change; behaviour is covered by the tests above.
Demonstration Link
Branch:
blog-draft-published-status🤖 Generated with Claude Code