Skip to content

feat(blog): add draft/published lifecycle to blog posts - #1658

Merged
aka-sacci-ccr merged 3 commits into
mainfrom
blog-draft-published-status
Aug 20, 2026
Merged

feat(blog): add draft/published lifecycle to blog posts#1658
aka-sacci-ccr merged 3 commits into
mainfrom
blog-draft-published-status

Conversation

@aka-sacci-ccr

@aka-sacci-ccr aka-sacci-ccr commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What is this Contribution About?

Adds a draft/published lifecycle to the blog/ app.

The status vocabulary comes from #1603draft | published | archived | generating | awaiting_review, exported as PostStatus alongside isPublishedStatus, under the same names and in the same file (blog/types.ts) as that PR, so whichever lands second is a near-trivial merge. generating and awaiting_review are 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 status field, so isPublishedStatus returns 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 through filterPostshandlePosts; none of them changed.

Detail loaders keep serving unpublished posts, unindexable. BlogPostPage and BlogPostItem do not hide them — serving the page is how the CMS previews unpublished work. They force seo.noIndexing = true instead, using the existing Seo.noIndexing field. Any seo the post already carries is preserved; only noIndexing is overridden.

Files

File Change
blog/types.ts status?: PostStatus on BlogPost; PostStatus + isPublishedStatus
blog/core/handlePosts.ts filterRoutablePosts drops unpublished posts
blog/loaders/BlogPostPage.ts noIndexing: post?.seo?.noIndexing || !isPublishedStatus(post.status)
blog/loaders/BlogPostItem.ts returns { ...post, seo: { ...post.seo, noIndexing: true } } when unpublished
blog/tests/ new; see below

Relationship to #1603

#1603 (feat/autonomous-blog-compatibility) carries the same status feature bundled with the shoppable product blocks. It's had CHANGES_REQUESTED since 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:

The PostStatus / isPublishedStatus definitions are deliberately identical to #1603's, so the overlap is confined to those two behavioural choices.

Verification

  • grep for a status prop on blog records: the only hits are Review/submitReview's unrelated "Review status" comments. No record type already carried a status, so no site's existing string gets reinterpreted here.
  • filterRoutablePosts callers: exactly one — filterPosts. Nothing else in the repo imports it, so the choke point holds.
  • Ran the deco bundler locally (deno run -A --no-lock jsr:@deco/deco/scripts/bundle) and confirmed no manifest changes — the earlier CI failure was a test file sitting in blog/loaders/, which the bundler registers as a block. Tests now live in blog/tests/, which isn't a block directory.

Tests

The repo had no Deno.test anywhere, so there was no existing tier to match; these use plain Deno.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 stubbed ctx.get: every non-published status is still returned with seo.noIndexing true, a draft that already has an seo object 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 apps release plus a version bump in that site — not on a Studio deploy. The editor UI (decocms/studio#4302) and the spire-agent migration that writes post.status ship separately.

Issue Link

  • Issue: n/a

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

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>
@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 0.161.1 update
  • 🎉 for Minor 0.162.0 update
  • 🚀 for Major 1.0.0 update

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10a23b47-4bed-41fc-a77d-36369fd78ecc

📥 Commits

Reviewing files that changed from the base of the PR and between 3670ea3 and 5bbe17b.

📒 Files selected for processing (6)
  • blog/core/handlePosts.test.ts
  • blog/core/handlePosts.ts
  • blog/loaders/BlogPostItem.ts
  • blog/loaders/BlogPostPage.ts
  • blog/loaders/blogPostDetail.test.ts
  • blog/types.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The PR adds optional draft status to BlogPost, excludes drafts from post routing, and marks drafts as non-indexable in BlogPostItem and BlogPostPageLoader. Tests cover routing, draft handling, SEO preservation, published posts, and missing posts.

Changes

Draft blog post handling

Layer / File(s) Summary
Status contract and routing filter
blog/types.ts, blog/core/handlePosts.ts, blog/core/handlePosts.test.ts
BlogPost accepts optional "draft" and "published" statuses. Only "draft" is treated as a draft. Draft and malformed posts are excluded from routing.
Detail loader draft handling
blog/loaders/BlogPostItem.ts, blog/loaders/BlogPostPage.ts, blog/loaders/blogPostDetail.test.ts
Draft detail responses set seo.noIndexing to true while preserving other SEO fields. Missing posts return null. Tests cover draft and published behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 5bbe1

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding draft and published lifecycle support to blog posts.
Description check ✅ Passed The description explains the behavior, implementation, tests, verification, rollout, and demonstration branch; the issue link is marked n/a.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch blog-draft-published-status

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Re-trigger cubic

decobot and others added 2 commits August 20, 2026 15:27
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>
@aka-sacci-ccr
aka-sacci-ccr merged commit 96e13ca into main Aug 20, 2026
3 checks passed
@aka-sacci-ccr
aka-sacci-ccr deleted the blog-draft-published-status branch August 20, 2026 18:54
aka-sacci-ccr added a commit that referenced this pull request Aug 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant