diff --git a/blog/core/handlePosts.ts b/blog/core/handlePosts.ts index 248a2ad05..77c477f28 100644 --- a/blog/core/handlePosts.ts +++ b/blog/core/handlePosts.ts @@ -1,35 +1,8 @@ import { postViews } from "../db/schema.ts"; import { AppContext } from "../mod.ts"; -import { - BlogPost, - isPublishedStatus, - SortBy, - ViewFromDatabase, -} from "../types.ts"; +import { BlogPost, isLivePost, SortBy, ViewFromDatabase } from "../types.ts"; import { VALID_SORT_ORDERS } from "../utils/constants.ts"; - -/** An ISO 8601 date or date-time carrying no timezone designator. */ -const ISO_WITHOUT_TIMEZONE = - /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/; - -/** - * `BlogPost.date` may be a bare `YYYY-MM-DD` or a full ISO 8601 timestamp. - * - * Anything without a timezone designator is pinned to UTC, so ordering never - * depends on the machine timezone. That matters for both shapes: per spec a - * bare date is already UTC, but an offset-less datetime is parsed as *local* - * time, which would otherwise reorder posts near a day boundary from one - * server to the next. - * - * Unparseable values fall back to 0 instead of leaking NaN into the comparator - * (a NaN result is treated as 0, so the post would never move). - */ -const dateToTime = (date: string) => - new Date( - ISO_WITHOUT_TIMEZONE.test(date) - ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` - : date, - ).getTime() || 0; +import { dateToTime } from "../utils/date.ts"; /** * Returns an sorted BlogPost list @@ -189,18 +162,21 @@ export const slicePosts = ( /** * A record without a slug has no route, so it can never be rendered: listing it - * only produces cards linking to the listing itself. Unpublished posts are - * unreachable for a different reason — the CMS doesn't consider them ready — - * but the outcome is the same, so both are dropped here, before slicePosts, so - * `count` still yields `count` renderable posts. + * only produces cards linking to the listing itself. Posts that aren't live are + * unreachable for a different reason — either the CMS doesn't consider them + * ready, or they're scheduled for an instant that hasn't arrived yet — but the + * outcome is the same, so both are dropped here, before slicePosts, so `count` + * still yields `count` renderable posts. + * + * A scheduled post crossing its instant flips this filter on the next request + * that misses cache; nothing re-deploys and no record is rewritten. */ export const filterRoutablePosts = (posts: BlogPost[]) => // Records come straight from the CMS, so `slug` is only a string by // convention: the typeof guard keeps a malformed one from throwing here and // taking the whole listing down with it. posts.filter((post) => - typeof post.slug === "string" && post.slug.trim() && - isPublishedStatus(post.status) + typeof post.slug === "string" && post.slug.trim() && isLivePost(post) ); const filterPosts = ( diff --git a/blog/loaders/BlogPostItem.ts b/blog/loaders/BlogPostItem.ts index 88a340dca..6513104f9 100644 --- a/blog/loaders/BlogPostItem.ts +++ b/blog/loaders/BlogPostItem.ts @@ -1,5 +1,5 @@ import { AppContext } from "../mod.ts"; -import { BlogPost, isPublishedStatus } from "../types.ts"; +import { BlogPost, isLivePost } from "../types.ts"; import { getRecordsByPath } from "../core/records.ts"; import type { RequestURLParam } from "../../website/functions/requestToParam.ts"; @@ -38,10 +38,12 @@ export default async function BlogPostItem( return null; } - // An unpublished post is still served — that page *is* the CMS preview — it - // just must never be indexed. Everything else the post declared under `seo` - // is kept as-is. - return isPublishedStatus(post.status) + // A post that isn't live yet — unpublished, or scheduled for an instant still + // ahead — is still served, because that page *is* the CMS preview. It just + // must never be indexed. Everything else the post declared under `seo` is + // kept as-is, and a scheduled post becomes indexable on its own once its + // instant passes. + return isLivePost(post) ? post : { ...post, seo: { ...post.seo, noIndexing: true } }; } diff --git a/blog/loaders/BlogPostPage.ts b/blog/loaders/BlogPostPage.ts index b8430c5e2..71eba631b 100644 --- a/blog/loaders/BlogPostPage.ts +++ b/blog/loaders/BlogPostPage.ts @@ -1,5 +1,5 @@ import { AppContext } from "../mod.ts"; -import { BlogPost, BlogPostPage, isPublishedStatus } from "../types.ts"; +import { BlogPost, BlogPostPage, isLivePost } from "../types.ts"; import { getRecordsByPath } from "../core/records.ts"; import type { RequestURLParam } from "../../website/functions/requestToParam.ts"; @@ -47,9 +47,11 @@ export default async function BlogPostPageLoader( description: post?.seo?.description || post?.excerpt, canonical: post?.seo?.canonical || url.href, image: post?.seo?.image || post?.image, - // An unpublished post still renders — that page *is* the CMS preview — - // it just must never be indexed, even if the URL leaks. - noIndexing: post?.seo?.noIndexing || !isPublishedStatus(post.status), + // A post that isn't live yet — unpublished, or scheduled for an instant + // still ahead — renders anyway, because that page *is* the CMS preview. + // It just must never be indexed, even if the URL leaks. A scheduled post + // becomes indexable on its own once its instant passes. + noIndexing: post?.seo?.noIndexing || !isLivePost(post), }, }; } diff --git a/blog/tests/blogPostDetail.test.ts b/blog/tests/blogPostDetail.test.ts index 4c579edbf..f5006b803 100644 --- a/blog/tests/blogPostDetail.test.ts +++ b/blog/tests/blogPostDetail.test.ts @@ -97,6 +97,60 @@ Deno.test("BlogPostPage leaves a published post indexable", async () => { assertEquals(page?.seo?.noIndexing, false); }); +const DAY = 86_400_000; + +const fromNow = (offsetMs: number) => + new Date(Date.now() + offsetMs).toISOString(); + +const scheduled = (offsetMs: number): Partial => ({ + ...draft, + status: "scheduled", + scheduledDatetime: fromNow(offsetMs), +}); + +Deno.test("a not-yet-due scheduled post is served, marked noIndexing", async () => { + // Same contract as a draft: the URL works so the CMS can preview it, but it + // must not be indexed before its go-live. + const ctx = ctxWith(scheduled(DAY)); + + const item = await BlogPostItem({ slug: "wip" }, req, ctx); + assertEquals(item?.slug, "wip"); + assertEquals(item?.seo?.noIndexing, true); + + const page = await BlogPostPageLoader({ slug: "wip" }, req, ctx); + assertEquals(page?.post.slug, "wip"); + assertEquals(page?.seo?.noIndexing, true); +}); + +Deno.test("a due scheduled post becomes indexable with no rewrite", async () => { + // The record is identical to the one above apart from its instant — crossing + // it is the entire publication event. + const ctx = ctxWith(scheduled(-DAY)); + + const item = await BlogPostItem({ slug: "wip" }, req, ctx); + assertEquals(item?.seo?.noIndexing, undefined); + + const page = await BlogPostPageLoader({ slug: "wip" }, req, ctx); + assertEquals(page?.seo?.noIndexing, false); +}); + +Deno.test("a scheduled post with a broken instant stays unindexable", async () => { + const ctx = ctxWith({ + ...draft, + status: "scheduled", + scheduledDatetime: "not a date", + }); + + assertEquals( + (await BlogPostItem({ slug: "wip" }, req, ctx))?.seo?.noIndexing, + true, + ); + assertEquals( + (await BlogPostPageLoader({ slug: "wip" }, req, ctx))?.seo?.noIndexing, + true, + ); +}); + Deno.test("every non-published status is served but unindexable", async () => { for ( const status of [ diff --git a/blog/tests/getCategories.test.ts b/blog/tests/getCategories.test.ts new file mode 100644 index 000000000..8fdc54647 --- /dev/null +++ b/blog/tests/getCategories.test.ts @@ -0,0 +1,58 @@ +import { assertEquals } from "@std/assert"; +import GetCategories from "../loaders/GetCategories.ts"; +import { AppContext } from "../mod.ts"; +import { Category } from "../types.ts"; + +const COLLECTION_PATH = "collections/blog/categories"; + +/** + * Categories share `getRecordsByPath` with posts, so they'd be collateral damage + * if the publication filter ever moved down into the records layer. Stubbing + * `ctx.get` the same way the post tests do is enough to pin that down. + */ +const ctxWith = (categories: Record[]) => + ({ + get: () => + Promise.resolve( + Object.fromEntries(categories.map((category) => [ + `${COLLECTION_PATH}/${category.slug}`, + { name: `${COLLECTION_PATH}/${category.slug}`, category }, + ])), + ), + }) as unknown as AppContext; + +const req = new Request("https://example.com/blog"); + +Deno.test("categories have no lifecycle, so none of them is ever filtered out", () => { + // The stray `status` is the point: categories are authored by the same CMS and + // may carry the field, but it means nothing here and must not hide anything. + const ctx = ctxWith([ + { name: "Alpha", slug: "alpha" }, + { name: "Beta", slug: "beta", status: "draft" }, + { name: "Gamma", slug: "gamma", status: "generating" }, + { + name: "Delta", + slug: "delta", + status: "scheduled", + scheduledDatetime: new Date(Date.now() + 86_400_000).toISOString(), + }, + ]); + + return GetCategories({}, req, ctx).then((categories) => { + assertEquals( + categories?.map(({ slug }: Category) => slug).sort(), + ["alpha", "beta", "delta", "gamma"], + ); + }); +}); + +Deno.test("a category is still reachable by slug regardless of any status", async () => { + const categories = await GetCategories( + { slug: "beta" }, + req, + ctxWith([{ name: "Beta", slug: "beta", status: "draft" }]), + ); + + assertEquals(categories?.length, 1); + assertEquals(categories?.[0].slug, "beta"); +}); diff --git a/blog/tests/handlePosts.test.ts b/blog/tests/handlePosts.test.ts index a95dcac98..62e530726 100644 --- a/blog/tests/handlePosts.test.ts +++ b/blog/tests/handlePosts.test.ts @@ -2,7 +2,11 @@ import { assertEquals } from "@std/assert"; import { filterRoutablePosts } from "../core/handlePosts.ts"; import { BlogPost, isPublishedStatus } from "../types.ts"; -const post = (slug: string, status?: string): BlogPost => ({ +const post = ( + slug: string, + status?: string, + scheduledDatetime?: string, +): BlogPost => ({ title: slug, excerpt: "", date: "2026-01-01", @@ -10,8 +14,18 @@ const post = (slug: string, status?: string): BlogPost => ({ // Records come from the CMS, so a site may well have written a string that // isn't in the union. Cast so the tests can exercise exactly that. status: status as BlogPost["status"], + scheduledDatetime, }); +const DAY = 86_400_000; + +/** + * Scheduling is compared against the wall clock, so the fixtures are relative + * to it: a literal date would silently flip from future to past as time passes. + */ +const fromNow = (offsetMs: number) => + new Date(Date.now() + offsetMs).toISOString(); + const listed = (posts: BlogPost[]) => filterRoutablePosts(posts).map(({ slug }) => slug); @@ -55,3 +69,97 @@ Deno.test("unroutable posts are still dropped alongside unpublished ones", () => "live", ]); }); + +Deno.test("a scheduled post is listed once its instant has passed", () => { + assertEquals( + listed([post("due", "scheduled", fromNow(-DAY))]), + ["due"], + ); +}); + +Deno.test("a scheduled post is hidden until its instant arrives", () => { + assertEquals(listed([post("soon", "scheduled", fromNow(DAY))]), []); +}); + +Deno.test("a scheduled post with no instant is hidden", () => { + // Fail closed: the CMS said "scheduled" and never said when, so the post has + // no go-live to have passed. + assertEquals(listed([post("undated", "scheduled")]), []); + assertEquals(listed([post("blank-date", "scheduled", "")]), []); +}); + +Deno.test("a scheduled post with an unparseable instant is hidden", () => { + for (const garbage of ["not a date", "tomorrow", "2026-13-45", "2026"]) { + assertEquals(listed([post("junk", "scheduled", garbage)]), [], garbage); + } +}); + +Deno.test("a loose date string is rejected rather than parsed", () => { + // `Date` accepts all of these, and parses them in *server-local* time — so + // honouring them would publish the same record at a different instant on + // every machine, defeating the UTC pinning. `"0"` is the worst of them: it + // resolves to the year 2000, i.e. to "already live". + for (const loose of ["0", "Sep 1 2026", "2026/09/01", "01-09-2026"]) { + assertEquals(listed([post("loose", "scheduled", loose)]), [], loose); + } +}); + +Deno.test("a calendar overflow is rejected, not rolled forward", () => { + // `Date` slides "Feb 31st" to March 3rd, which would put the post live days + // off the date someone typed. A schedule we can't read exactly is one we + // must not act on. + for (const overflow of ["2026-02-31", "2026-02-30T10:00", "2026-04-31"]) { + assertEquals(listed([post("typo", "scheduled", overflow)]), [], overflow); + } + // The same day in a leap year is real, so it must still be honoured. + assertEquals(listed([post("leap", "scheduled", "2024-02-29")]), ["leap"]); +}); + +Deno.test("an out-of-range time or offset is rejected", () => { + for ( + const bad of [ + "2026-09-01T25:00", + "2026-09-01T10:61", + "2020-01-01T00:00+99:00", + ] + ) { + assertEquals(listed([post("bad", "scheduled", bad)]), [], bad); + } +}); + +Deno.test("the Unix epoch is a real instant, not a parse failure", () => { + // Nobody schedules 1970 on purpose, but the distinction is what proves the + // rejection path keys off an unreadable value rather than off a falsy + // timestamp — the bug class that publishes a typo'd post immediately. + assertEquals( + listed([post("epoch", "scheduled", "1970-01-01T00:00:00Z")]), + ["epoch"], + ); +}); + +Deno.test("a bare date is honoured as midnight UTC", () => { + // Unambiguous ISO, unlike the loose forms above: rejecting it would strand a + // post forever over a missing time. + assertEquals(listed([post("dated", "scheduled", "2020-01-01")]), ["dated"]); + assertEquals(listed([post("future", "scheduled", "2999-01-01")]), []); +}); + +Deno.test("scheduledDatetime is inert unless the status is scheduled", () => { + // A post switched back to `published` keeps its old schedule field; that must + // not un-publish it. And a draft's schedule must not publish it either. + assertEquals( + listed([post("live", "published", fromNow(DAY))]), + ["live"], + ); + assertEquals(listed([post("wip", "draft", fromNow(-DAY))]), []); +}); + +Deno.test("an offset-less instant is read as UTC, not as server local time", () => { + // Two hours ago in UTC, written without a designator. Parsed as *local* time + // this lands in the future for any negative-offset server (e.g. UTC-3), so + // the post would stay hidden there and be listed in UTC — the same record + // going live at different moments depending on which machine served it. + const twoHoursAgo = fromNow(-2 * 60 * 60 * 1000).replace("Z", ""); + + assertEquals(listed([post("due", "scheduled", twoHoursAgo)]), ["due"]); +}); diff --git a/blog/types.ts b/blog/types.ts index 15dac46db..77788a438 100644 --- a/blog/types.ts +++ b/blog/types.ts @@ -1,6 +1,7 @@ import { ImageWidget } from "../admin/widgets.ts"; import { PageInfo, Person, Thing } from "../commerce/types.ts"; import { type Section } from "@deco/deco/blocks"; +import { scheduledTime } from "./utils/date.ts"; /** * @titleBy name @@ -69,6 +70,12 @@ export interface BlogPost { * @description Publication status. Anything other than `published` is kept out of listings and never indexed. Posts with no status are treated as published. */ status?: PostStatus; + /** + * @title Scheduled publication date + * @format datetime + * @description Instant this post goes live, honoured only while status is `scheduled`. Deliberately separate from the editorial date shown and sorted on: the two may diverge. + */ + scheduledDatetime?: string; /** * @title Post Content * @format rich-text @@ -110,17 +117,19 @@ export interface BlogPost { /** * Publication status of a post. `published` (or an absent value, for legacy - * posts) renders on the live site; every other value keeps the post out of + * posts) renders on the live site; `scheduled` renders once its + * `scheduledDatetime` has passed; every other value keeps the post out of * listings and out of the index. * * `generating` and `awaiting_review` are written by the autonomous-blog agent - * while a post is still being produced, which is why the check below is an - * allowlist: a status this app does not recognize is a post the CMS does not + * while a post is still being produced, which is why the checks below are + * allowlists: a status this app does not recognize is a post the CMS does not * consider ready, so it must not leak into a listing. */ export type PostStatus = | "draft" | "published" + | "scheduled" | "archived" | "generating" | "awaiting_review"; @@ -139,6 +148,40 @@ export type PostStatus = export const isPublishedStatus = (status?: string): boolean => !status || status === "published"; +/** + * Whether a post is live *right now* — the read-time half of scheduling. + * + * A scheduled post is merged to production ahead of its go-live instant, so + * nothing rewrites the record when that instant arrives: this comparison is + * what flips it, on whichever request first evaluates it after the fact. + * + * Like `isPublishedStatus`, this is an allowlist and stays one deliberately: + * only an absent/empty status, an explicit `published`, or a `scheduled` post + * whose instant has arrived is live. That makes every status added later — by + * the CMS or by an agent — fail closed on an app version that predates it, + * hiding the post instead of leaking a half-written one. A `status !== "draft"` + * blacklist would lose that property permanently. + */ +export const isLivePost = ( + post: { status?: string; scheduledDatetime?: string }, + now: number = Date.now(), +): boolean => { + if (isPublishedStatus(post.status)) { + return true; + } + if (post.status !== "scheduled") { + return false; + } + const goLive = post.scheduledDatetime + ? scheduledTime(post.scheduledDatetime) + : null; + // A missing or unreadable instant is rejected rather than compared: this is + // the fail-closed guarantee, not a redundant null check. `scheduledTime` + // returns null (not 0) for a bad value precisely so that a real instant which + // happens to be the epoch is still honoured here. + return goLive !== null && goLive <= now; +}; + export interface ExtraProps { key: string; value: string; diff --git a/blog/utils/date.ts b/blog/utils/date.ts new file mode 100644 index 000000000..1299df9db --- /dev/null +++ b/blog/utils/date.ts @@ -0,0 +1,96 @@ +/** An ISO 8601 date or date-time carrying no timezone designator. */ +const ISO_WITHOUT_TIMEZONE = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/; + +/** + * Parses a CMS-authored date or date-time into a timestamp. + * + * `BlogPost.date` may be a bare `YYYY-MM-DD` or a full ISO 8601 timestamp, and + * `scheduledDatetime` is a date-time that may or may not carry an offset. + * + * Anything without a timezone designator is pinned to UTC, so the result never + * depends on the machine timezone. That matters for both shapes: per spec a + * bare date is already UTC, but an offset-less datetime is parsed as *local* + * time, which would otherwise reorder posts near a day boundary from one + * server to the next — and, for a scheduled post, move its go-live instant. + * + * Unparseable values fall back to 0 instead of leaking NaN into a comparator + * (a NaN result is treated as 0, so the post would never move). Callers that + * compare against "now" must reject 0 explicitly rather than let it through as + * a very old date. + */ +export const dateToTime = (date: string) => + new Date( + ISO_WITHOUT_TIMEZONE.test(date) + ? `${date.includes("T") ? date : `${date}T00:00:00`}Z` + : date, + ).getTime() || 0; + +/** + * An ISO 8601 date, optionally with a time and an offset. Anchored, grouped and + * deliberately narrow: `Date` accepts far more than this, and the extras are the + * problem — see `scheduledTime`. + */ +const STRICT_ISO = + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?(Z|[+-]\d{2}:\d{2})?$/; + +const lastDayOfMonth = (year: number, month: number) => + new Date(Date.UTC(year, month, 0)).getUTCDate(); + +/** + * Parses a scheduled go-live instant, or returns `null` if the value isn't one. + * + * Publishing needs a stricter parse than sorting does, because here a + * misreading doesn't reorder a list — it puts a post on the live site at the + * wrong moment. `Date` is lenient in two ways that both do exactly that: + * + * - It accepts non-ISO strings and parses them in *server-local* time, so + * `"Sep 1 2026"` would go live at a different instant on every machine — + * silently defeating the UTC pinning this module exists to guarantee. Worse, + * it accepts strings that aren't dates in any useful sense: `"0"` is the year + * 2000, i.e. already live. + * - It rolls calendar overflow forward instead of rejecting it, so a typo'd + * `"2026-02-31"` publishes on March 3rd. + * + * So the shape is matched against an anchored ISO pattern and the fields are + * range-checked before `Date` ever sees them. Anything else is `null`, and + * callers treat `null` as "not live" — a schedule we can't read is one we must + * not act on. + * + * A bare `YYYY-MM-DD` is accepted as midnight UTC: unlike the cases above it's + * an unambiguous ISO form, and rejecting it would strand a post forever over a + * missing time. `null` is used rather than 0 so that the Unix epoch stays a + * representable instant instead of being indistinguishable from a failure. + */ +export const scheduledTime = (value: string): number | null => { + const match = STRICT_ISO.exec(value); + + if (!match) { + return null; + } + + const [, ...groups] = match; + const [year, month, day] = groups.slice(0, 3).map(Number); + const [hour, minute, second] = groups.slice(3, 6).map((part) => + Number(part ?? 0) + ); + + if ( + month < 1 || month > 12 || + day < 1 || day > lastDayOfMonth(year, month) || + hour > 23 || minute > 59 || second > 59 + ) { + return null; + } + + // Only pin UTC when the value carries no designator of its own; `dateToTime` + // isn't reused here because its 0-on-failure fallback is precisely the + // ambiguity this function exists to remove. + const parsed = new Date( + groups[6] ? value : `${value.includes("T") ? value : `${value}T00:00:00`}Z`, + ).getTime(); + + // Still reachable despite the checks above — an out-of-range offset such as + // `+99:00` matches the pattern but is not a real instant. + return Number.isNaN(parsed) ? null : parsed; +};