Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 11 additions & 35 deletions blog/core/handlePosts.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
12 changes: 7 additions & 5 deletions blog/loaders/BlogPostItem.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 } };
}
10 changes: 6 additions & 4 deletions blog/loaders/BlogPostPage.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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),
},
};
}
54 changes: 54 additions & 0 deletions blog/tests/blogPostDetail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlogPost> => ({
...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 [
Expand Down
58 changes: 58 additions & 0 deletions blog/tests/getCategories.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]) =>
({
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");
});
110 changes: 109 additions & 1 deletion blog/tests/handlePosts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,30 @@ 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",
slug,
// 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);

Expand Down Expand Up @@ -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"]);
});
Loading
Loading