From 53335dca29de9fb5ec84a0b1fee6713489738c84 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 20 Aug 2026 16:14:59 -0300 Subject: [PATCH 1/2] feat(blog): sync loaders, types, and draft lifecycle with deco-cx/apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the recent deco-cx/apps blog work (up to #1658) to the TanStack Start port. The loaders here are sync and have no ctx, so each change is adapted rather than copied. Types: - PostStatus + isPublishedStatus() — an allowlist, so a status this app does not recognize is treated as not ready and never leaks into a listing. An absent status stays published: the field was added long after the first posts, so requiring it would empty every live blog. - BlogPost.status / .dateModified, Author.type, Category.description / .sections, BlogPostListingPage.category / .categories, Publisher. Core: - dateToTime() compares full ISO timestamps and pins offset-less values to UTC, so ordering no longer depends on the machine timezone. The previous `${date}T00:00:00` produced NaN for any date carrying a time. - filterRoutablePosts() drops slugless and unpublished posts before slicePosts, so `count` still yields `count` renderable posts. - getRecordsByPath skips blocks with no string `name` instead of emitting a record with `id: undefined`. Loaders: - BlogPostItem / BlogPostPage still serve an unpublished post — that page is the CMS preview — but force noIndexing so it cannot be indexed. - BlogpostListing always returns the category list and resolves the active category from the collection (so it carries description and sections), falling back to the copy inlined on a post. - GetCategories filters categories missing a name or slug before sorting; the guard is shared with BlogpostListing as isValidCategory. - New BlogpostList loader. The loader map pointed blog/loaders/BlogpostList at BlogpostListing, which returns a BlogPostListingPage rather than the BlogPost[] callers expect. The manifest generator did not list blog, so blog/manifest.gen.ts was stale and missing BlogPostItem. Added and regenerated. Sections, actions, db/schema and the ratings/reviews/views extensions are deliberately left out: they are Preact components or depend on drizzle and ctx.invoke.records, neither of which exists in this port. Co-Authored-By: Claude Opus 5 (1M context) --- blog/__tests__/handlePosts.test.ts | 104 +++++++++++++++ blog/__tests__/loaders.test.ts | 195 +++++++++++++++++++++++++++++ blog/core/handlePosts.ts | 48 ++++++- blog/core/records.ts | 7 +- blog/index.ts | 5 +- blog/loaderMap.ts | 6 +- blog/loaders/BlogPostItem.ts | 13 +- blog/loaders/BlogPostPage.ts | 6 +- blog/loaders/BlogpostList.ts | 70 +++++++++++ blog/loaders/BlogpostListing.ts | 35 +++++- blog/loaders/GetCategories.ts | 17 ++- blog/manifest.gen.ts | 8 +- blog/types.ts | 61 +++++++++ scripts/generate-manifests.ts | 1 + 14 files changed, 556 insertions(+), 20 deletions(-) create mode 100644 blog/loaders/BlogpostList.ts diff --git a/blog/__tests__/handlePosts.test.ts b/blog/__tests__/handlePosts.test.ts index d445648..a1b154d 100644 --- a/blog/__tests__/handlePosts.test.ts +++ b/blog/__tests__/handlePosts.test.ts @@ -4,6 +4,7 @@ import handlePosts, { filterPostsBySlugs, filterPostsByTerm, filterRelatedPosts, + filterRoutablePosts, slicePosts, sortPosts, } from "../core/handlePosts"; @@ -283,3 +284,106 @@ describe("handlePosts", () => { expect(result![0].slug).toBe("c"); }); }); + +// --------------------------------------------------------------------------- +// filterRoutablePosts +// --------------------------------------------------------------------------- +describe("filterRoutablePosts", () => { + it("drops posts with no usable slug", () => { + const posts = [ + makePost({ slug: "ok" }), + makePost({ slug: "" }), + makePost({ slug: " " }), + makePost({ slug: undefined as unknown as string }), + makePost({ slug: 42 as unknown as string }), + ]; + + expect(filterRoutablePosts(posts).map((p) => p.slug)).toEqual(["ok"]); + }); + + it("keeps posts with no status (legacy records) and explicitly published ones", () => { + const posts = [makePost({ slug: "legacy" }), makePost({ slug: "live", status: "published" })]; + + expect(filterRoutablePosts(posts).map((p) => p.slug)).toEqual(["legacy", "live"]); + }); + + it("drops every status other than published", () => { + const posts = (["draft", "archived", "generating", "awaiting_review"] as const).map((status) => + makePost({ slug: status, status }), + ); + + expect(filterRoutablePosts(posts)).toEqual([]); + }); + + it("drops an unrecognized status", () => { + const posts = [makePost({ slug: "weird", status: "whatever" as never })]; + + expect(filterRoutablePosts(posts)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// date sorting across ISO shapes +// --------------------------------------------------------------------------- +describe("sortPosts date parsing", () => { + it("compares full ISO timestamps, not just the day", () => { + const posts = [ + makePost({ slug: "morning", date: "2024-01-01T08:00:00Z" }), + makePost({ slug: "evening", date: "2024-01-01T20:00:00Z" }), + ]; + + expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["evening", "morning"]); + expect(sortPosts(posts, "date_asc").map((p) => p.slug)).toEqual(["morning", "evening"]); + }); + + it("pins offset-less datetimes to UTC so ordering is timezone independent", () => { + const posts = [ + makePost({ slug: "late", date: "2024-01-01T23:30:00" }), + makePost({ slug: "early", date: "2024-01-02T00:30:00" }), + ]; + + expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["early", "late"]); + }); + + it("mixes bare dates and timestamps on the same axis", () => { + const posts = [ + makePost({ slug: "bare", date: "2024-01-02" }), + makePost({ slug: "stamped", date: "2024-01-02T10:00:00Z" }), + ]; + + expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["stamped", "bare"]); + }); + + it("treats an unparseable date as epoch instead of leaking NaN", () => { + const posts = [ + makePost({ slug: "good", date: "2024-01-01" }), + makePost({ slug: "garbage", date: "not-a-date" }), + ]; + + expect(sortPosts(posts, "date_desc").map((p) => p.slug)).toEqual(["good", "garbage"]); + }); +}); + +// --------------------------------------------------------------------------- +// handlePosts drops unroutable posts before anything else +// --------------------------------------------------------------------------- +describe("handlePosts routability", () => { + it("excludes drafts and slugless posts from every filter path", () => { + const posts = [ + makePost({ slug: "live", date: "2024-03-01" }), + makePost({ slug: "draft", status: "draft", date: "2024-04-01" }), + makePost({ slug: "", date: "2024-05-01" }), + ]; + + expect(handlePosts(posts, "date_desc")?.map((p) => p.slug)).toEqual(["live"]); + expect(handlePosts(posts, "date_desc", "news")?.map((p) => p.slug)).toEqual(["live"]); + expect(handlePosts(posts, "date_desc", ["news"])?.map((p) => p.slug)).toEqual(["live"]); + expect( + handlePosts(posts, "date_desc", undefined, undefined, "content")?.map((p) => p.slug), + ).toEqual(["live"]); + }); + + it("returns null when nothing is routable", () => { + expect(handlePosts([makePost({ slug: "d", status: "draft" })], "date_desc")).toBeNull(); + }); +}); diff --git a/blog/__tests__/loaders.test.ts b/blog/__tests__/loaders.test.ts index 14d83a5..e74e0e4 100644 --- a/blog/__tests__/loaders.test.ts +++ b/blog/__tests__/loaders.test.ts @@ -7,6 +7,7 @@ vi.mock("../core/records", () => ({ import { getRecordsByPath } from "../core/records"; import BlogPostItem from "../loaders/BlogPostItem"; import BlogPostPageLoader from "../loaders/BlogPostPage"; +import BlogpostList from "../loaders/BlogpostList"; import BlogpostListing from "../loaders/BlogpostListing"; import BlogRelatedPostsLoader from "../loaders/BlogRelatedPosts"; import GetCategories from "../loaders/GetCategories"; @@ -205,3 +206,197 @@ describe("BlogPostItem", () => { expect(BlogPostItem({ slug: "nonexistent" })).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Draft / published lifecycle +// --------------------------------------------------------------------------- +describe("publication status", () => { + it("BlogpostListing hides unpublished and slugless posts", () => { + mockGetRecords.mockImplementation((path: string) => + path.includes("categories") + ? [] + : [ + makePost({ slug: "live", date: "2024-01-01" }), + makePost({ slug: "draft", status: "draft", date: "2024-02-01" }), + makePost({ slug: "", date: "2024-03-01" }), + ], + ); + + const result = BlogpostListing({}, new Request("https://example.com/blog")); + expect(result?.posts.map((p) => p.slug)).toEqual(["live"]); + expect(result?.pageInfo.records).toBe(1); + }); + + it("BlogPostItem still serves a draft but forces noIndexing", () => { + mockGetRecords.mockReturnValue([makePost({ slug: "draft", status: "draft" })]); + + const result = BlogPostItem({ slug: "draft" }); + expect(result?.slug).toBe("draft"); + expect(result?.seo?.noIndexing).toBe(true); + }); + + it("BlogPostItem preserves the post's own seo fields while forcing noIndexing", () => { + mockGetRecords.mockReturnValue([ + makePost({ slug: "draft", status: "draft", seo: { title: "Kept", noIndexing: false } }), + ]); + + expect(BlogPostItem({ slug: "draft" })?.seo).toEqual({ title: "Kept", noIndexing: true }); + }); + + it("BlogPostItem leaves a published post untouched", () => { + const post = makePost({ slug: "live", status: "published", seo: { title: "T" } }); + mockGetRecords.mockReturnValue([post]); + + expect(BlogPostItem({ slug: "live" })).toBe(post); + }); + + it("BlogPostPage marks a draft noIndexing", () => { + mockGetRecords.mockReturnValue([makePost({ slug: "draft", status: "draft" })]); + + const result = BlogPostPageLoader({ slug: "draft" }, new Request("https://example.com/p")); + expect(result?.seo?.noIndexing).toBe(true); + }); + + it("BlogPostPage leaves a legacy post (no status) indexable", () => { + mockGetRecords.mockReturnValue([makePost({ slug: "legacy" })]); + + const result = BlogPostPageLoader({ slug: "legacy" }, new Request("https://example.com/p")); + expect(result?.seo?.noIndexing).toBe(false); + }); + + it("BlogRelatedPosts hides unpublished posts", () => { + mockGetRecords.mockReturnValue([ + makePost({ slug: "live" }), + makePost({ slug: "draft", status: "draft" }), + ]); + + const result = BlogRelatedPostsLoader({ slug: ["news"] }, new Request("https://example.com/")); + expect(result?.map((p) => p.slug)).toEqual(["live"]); + }); +}); + +// --------------------------------------------------------------------------- +// BlogpostListing categories +// --------------------------------------------------------------------------- +describe("BlogpostListing categories", () => { + const categories: Category[] = [ + { name: "Tech", slug: "tech", description: "All things tech" }, + { name: "News", slug: "news", description: "Fresh news" }, + ]; + + function mockWith(cats: unknown[]) { + mockGetRecords.mockImplementation((path: string) => + path.includes("categories") ? cats : samplePosts, + ); + } + + it("always returns the sorted category list, even with no active category", () => { + mockWith(categories); + + const result = BlogpostListing({}, new Request("https://example.com/blog")); + expect(result?.categories?.map((c) => c.slug)).toEqual(["news", "tech"]); + expect(result?.category).toBeNull(); + }); + + it("resolves the active category from the collection, with description in seo", () => { + mockWith(categories); + + const result = BlogpostListing({ slug: "news" }, new Request("https://example.com/blog/news")); + expect(result?.category).toEqual(categories[1]); + expect(result?.seo.title).toBe("News"); + expect(result?.seo.description).toBe("Fresh news"); + }); + + it("falls back to the category inlined on a post when the collection has no match", () => { + mockWith([]); + + const result = BlogpostListing({ slug: "news" }, new Request("https://example.com/blog/news")); + expect(result?.category).toEqual({ name: "News", slug: "news" }); + expect(result?.categories).toEqual([]); + }); + + it("drops categories missing a name or slug", () => { + mockWith([...categories, { name: "", slug: "empty" }, { name: "No slug" }]); + + const result = BlogpostListing({}, new Request("https://example.com/blog")); + expect(result?.categories?.map((c) => c.slug)).toEqual(["news", "tech"]); + }); + + it("survives a failure reading categories", () => { + mockGetRecords.mockImplementation((path: string) => { + if (path.includes("categories")) throw new Error("boom"); + return samplePosts; + }); + + const result = BlogpostListing({}, new Request("https://example.com/blog")); + expect(result?.posts).toHaveLength(3); + expect(result?.categories).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// GetCategories validity filtering +// --------------------------------------------------------------------------- +describe("GetCategories validity", () => { + it("drops categories with a missing or non-string name/slug before sorting", () => { + mockGetRecords.mockReturnValue([ + { name: "Tech", slug: "tech" }, + { name: "", slug: "empty-name" }, + { name: "No slug" }, + { name: 5, slug: "numeric-name" }, + { name: "News", slug: "news" }, + ]); + + expect(GetCategories({})?.map((c) => c.slug)).toEqual(["news", "tech"]); + }); + + it("returns null when every category is invalid", () => { + mockGetRecords.mockReturnValue([{ name: "" }, { slug: "x" }]); + + expect(GetCategories({})).toBeNull(); + }); + + it("filters an invalid category out of a slug lookup too", () => { + mockGetRecords.mockReturnValue([{ name: "", slug: "tech" }]); + + expect(GetCategories({ slug: "tech" })).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// BlogpostList +// --------------------------------------------------------------------------- +describe("BlogpostList", () => { + it("returns a flat post array, newest first", () => { + const result = BlogpostList({}, new Request("https://example.com/blog")); + expect(result?.map((p) => p.slug)).toEqual(["post-c", "post-b", "post-a"]); + }); + + it("honours postSlugs when a category slug is given", () => { + const result = BlogpostList( + { slug: "news", postSlugs: ["post-a", "post-c"] }, + new Request("https://example.com/blog"), + ); + expect(result?.map((p) => p.slug)).toEqual(["post-c", "post-a"]); + }); + + it("paginates with count and page", () => { + const result = BlogpostList({ count: 2, page: 2 }, new Request("https://example.com/blog")); + expect(result?.map((p) => p.slug)).toEqual(["post-a"]); + }); + + it("returns null when the page is past the end", () => { + expect(BlogpostList({ count: 2, page: 5 }, new Request("https://example.com/blog"))).toBeNull(); + }); + + it("excludes unpublished posts", () => { + mockGetRecords.mockReturnValue([ + makePost({ slug: "live" }), + makePost({ slug: "archived", status: "archived" }), + ]); + + expect(BlogpostList({}, new Request("https://example.com/blog"))?.map((p) => p.slug)).toEqual([ + "live", + ]); + }); +}); diff --git a/blog/core/handlePosts.ts b/blog/core/handlePosts.ts index 8e79e74..5646e97 100644 --- a/blog/core/handlePosts.ts +++ b/blog/core/handlePosts.ts @@ -1,7 +1,27 @@ -import type { BlogPost, SortBy } from "../types"; +import { type BlogPost, isPublishedStatus, type SortBy } from "../types"; const VALID_SORT_ORDERS = ["asc", "desc"]; +/** 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; + /** * Sort posts by the given criteria. * Skips view-based sorting (no Drizzle in this port). @@ -18,7 +38,7 @@ export const sortPosts = (blogPosts: BlogPost[], sortBy: SortBy): BlogPost[] => const comparison = sortMethod === "date" - ? new Date(`${b.date}T00:00:00`).getTime() - new Date(`${a.date}T00:00:00`).getTime() + ? dateToTime(b.date) - dateToTime(a.date) : (a[sortMethod]?.toString().localeCompare(b[sortMethod]?.toString() ?? "") ?? 0); return sortOrder === "desc" ? comparison : -comparison; @@ -43,6 +63,21 @@ export const filterPostsByTerm = (posts: BlogPost[], term: string): BlogPost[] = export const filterRelatedPosts = (posts: BlogPost[], slugs: string[]): BlogPost[] => posts.filter(({ categories }) => categories?.find((c) => slugs.includes(c.slug))); +/** + * 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. + */ +export const filterRoutablePosts = (posts: BlogPost[]): 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), + ); + /** Slice posts for pagination. */ export const slicePosts = ( posts: BlogPost[], @@ -64,18 +99,19 @@ export default function handlePosts( term?: string, excludePostSlug?: string, ): BlogPost[] | null { + const routable = filterRoutablePosts(posts); let filtered: BlogPost[]; if (typeof slug === "string") { filtered = postSlugs && postSlugs.length > 0 - ? filterPostsBySlugs(posts, postSlugs) - : filterPostsByCategory(posts, slug); + ? filterPostsBySlugs(routable, postSlugs) + : filterPostsByCategory(routable, slug); if (term) filtered = filterPostsByTerm(filtered, term); } else if (Array.isArray(slug)) { - filtered = filterRelatedPosts(posts, slug); + filtered = filterRelatedPosts(routable, slug); } else { - filtered = term ? filterPostsByTerm(posts, term) : posts; + filtered = term ? filterPostsByTerm(routable, term) : routable; } if (excludePostSlug) { diff --git a/blog/core/records.ts b/blog/core/records.ts index 874344f..453dbef 100644 --- a/blog/core/records.ts +++ b/blog/core/records.ts @@ -18,10 +18,15 @@ export function getRecordsByPath(path: string, accessor: string): T[] { continue; } + // `name` is what the id is derived from, so a block missing it (or carrying + // a malformed one) is a record with no stable identity — drop it rather + // than emit one with `id: undefined`. + if (typeof value.name !== "string") continue; + const record = value[accessor] as T | undefined; if (!record) continue; - const id = (value.name as string | undefined)?.split(path)[1]?.replace("/", ""); + const id = value.name.split(path)[1]?.replace("/", ""); results.push({ ...record, id } as T); } diff --git a/blog/index.ts b/blog/index.ts index 9a3e167..bb25fbe 100644 --- a/blog/index.ts +++ b/blog/index.ts @@ -2,6 +2,7 @@ * Public API for the blog app. */ +export { filterRoutablePosts } from "./core/handlePosts"; export { getRecordsByPath } from "./core/records"; /** @deprecated Use `createBlogLoaders` instead. */ export { @@ -9,7 +10,6 @@ export { createBlogLoaders as createBlogCommerceLoaders, } from "./loaderMap"; export { configure } from "./mod"; - // Types export type { Author, @@ -19,6 +19,9 @@ export type { Category, ExtraProps, PageInfo, + PostStatus, + Publisher, Seo, SortBy, } from "./types"; +export { isPublishedStatus } from "./types"; diff --git a/blog/loaderMap.ts b/blog/loaderMap.ts index bf24604..a7ac2c7 100644 --- a/blog/loaderMap.ts +++ b/blog/loaderMap.ts @@ -9,6 +9,7 @@ import AuthorLoader from "./loaders/Author"; import BlogPostItemLoader from "./loaders/BlogPostItem"; import BlogPostPageLoader from "./loaders/BlogPostPage"; import BlogpostLoader from "./loaders/Blogpost"; +import BlogpostListLoader from "./loaders/BlogpostList"; import BlogpostListingLoader from "./loaders/BlogpostListing"; import BlogRelatedPostsLoader from "./loaders/BlogRelatedPosts"; import CategoryLoader from "./loaders/Category"; @@ -51,7 +52,8 @@ export function createBlogLoaders(): Record { // BlogPostItem: looks up a single post by slug, returns BlogPost "blog/loaders/BlogPostItem.ts": BlogPostItemLoader, "blog/loaders/BlogPostItem": BlogPostItemLoader, - "blog/loaders/BlogpostList.ts": BlogpostListingLoader, - "blog/loaders/BlogpostList": BlogpostListingLoader, + // BlogpostList: flat BlogPost[] — a different shape from BlogpostListing + "blog/loaders/BlogpostList.ts": BlogpostListLoader, + "blog/loaders/BlogpostList": BlogpostListLoader, }; } diff --git a/blog/loaders/BlogPostItem.ts b/blog/loaders/BlogPostItem.ts index b258f1f..a477510 100644 --- a/blog/loaders/BlogPostItem.ts +++ b/blog/loaders/BlogPostItem.ts @@ -1,5 +1,5 @@ import { getRecordsByPath } from "../core/records"; -import type { BlogPost } from "../types"; +import { type BlogPost, isPublishedStatus } from "../types"; export interface Props { slug: string; @@ -15,5 +15,14 @@ export default function BlogPostItem(props: Props & { __pageUrl?: string }): Blo if (!slug) return null; const posts = getRecordsByPath("collections/blog/posts", "post"); - return posts.find((p) => p?.slug === slug) ?? null; + const post = posts.find((p) => p?.slug === slug); + + if (!post) 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) + ? post + : { ...post, seo: { ...post.seo, noIndexing: true } }; } diff --git a/blog/loaders/BlogPostPage.ts b/blog/loaders/BlogPostPage.ts index a29e84b..432d826 100644 --- a/blog/loaders/BlogPostPage.ts +++ b/blog/loaders/BlogPostPage.ts @@ -1,5 +1,5 @@ import { getRecordsByPath } from "../core/records"; -import type { BlogPost, BlogPostPage } from "../types"; +import { type BlogPost, type BlogPostPage, isPublishedStatus } from "../types"; const COLLECTION_PATH = "collections/blog/posts"; const ACCESSOR = "post"; @@ -33,7 +33,9 @@ export default function BlogPostPageLoader( description: post?.seo?.description || post?.excerpt, canonical: post?.seo?.canonical || url.href, image: post?.seo?.image || post?.image, - noIndexing: post?.seo?.noIndexing || false, + // 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), }, }; } diff --git a/blog/loaders/BlogpostList.ts b/blog/loaders/BlogpostList.ts new file mode 100644 index 0000000..f29b530 --- /dev/null +++ b/blog/loaders/BlogpostList.ts @@ -0,0 +1,70 @@ +import handlePosts, { slicePosts } from "../core/handlePosts"; +import { getRecordsByPath } from "../core/records"; +import type { BlogPost, SortBy } from "../types"; + +const COLLECTION_PATH = "collections/blog/posts"; +const ACCESSOR = "post"; + +export interface Props { + /** + * @title Items per page + * @description Number of posts per page to display. + */ + count?: number; + /** + * @title Page query parameter + * @description The current page number. Defaults to 1. + */ + page?: number; + /** + * @title Category Slug + * @description Filter by a specific category slug. + */ + slug?: string; + /** + * @title Specific post slugs + * @description Filter by specific post slugs. + */ + postSlugs?: string[]; + /** + * @title Page sorting parameter + * @description The sorting option. Default is "date_desc" + */ + sortBy?: SortBy; + /** + * @description Overrides the query term at url + */ + query?: string; +} + +/** + * @title BlogPostList + * @description Retrieves a flat list of blog posts. Unlike BlogpostListing it + * returns the posts alone, with no pageInfo or seo wrapper. + */ +export default function BlogPostList( + props: Props & { __pageUrl?: string }, + req?: Request, +): BlogPost[] | null { + const { page, count, slug, sortBy, postSlugs, query } = props; + const rawUrl = req?.url ?? props.__pageUrl ?? "http://localhost/"; + const url = new URL(rawUrl); + const postsPerPage = Number(count ?? url.searchParams.get("count") ?? 12); + const pageNumber = Number(page ?? url.searchParams.get("page") ?? 1); + const pageSort = sortBy ?? (url.searchParams.get("sortBy") as SortBy) ?? "date_desc"; + const term = query ?? url.searchParams.get("q") ?? undefined; + + const posts = getRecordsByPath(COLLECTION_PATH, ACCESSOR); + + try { + const handledPosts = handlePosts(posts, pageSort, slug, postSlugs, term); + + if (!handledPosts) return null; + + const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage); + return slicedPosts.length > 0 ? slicedPosts : null; + } catch (e) { + console.error("[BlogpostList]", e); + return null; + } +} diff --git a/blog/loaders/BlogpostListing.ts b/blog/loaders/BlogpostListing.ts index 88cef53..5b11f42 100644 --- a/blog/loaders/BlogpostListing.ts +++ b/blog/loaders/BlogpostListing.ts @@ -1,9 +1,12 @@ import handlePosts, { slicePosts } from "../core/handlePosts"; import { getRecordsByPath } from "../core/records"; -import type { BlogPost, BlogPostListingPage, PageInfo, SortBy } from "../types"; +import type { BlogPost, BlogPostListingPage, Category, PageInfo, SortBy } from "../types"; +import { isValidCategory } from "./GetCategories"; const COLLECTION_PATH = "collections/blog/posts"; const ACCESSOR = "post"; +const CATEGORIES_PATH = "collections/blog/categories"; +const CATEGORY_ACCESSOR = "category"; export interface Props { /** @@ -59,13 +62,35 @@ export default function BlogPostList( const slicedPosts = slicePosts(handledPosts, pageNumber, postsPerPage); if (slicedPosts.length === 0) return null; - const category = slicedPosts[0].categories?.find((c) => c.slug === slug); + // Categories are useful to every listing (menus, filter chips), not only + // the filtered ones, so they are always returned. A failure to read them + // must not take the whole listing down with it. + let categories: Category[] | null = null; + try { + categories = loadCategories(); + } catch (e) { + console.error("[BlogpostListing] categories", e); + } + + // The active category comes from the collection, so the listing gets the + // full record (description, sections). The inline copy carried on a post is + // only a fallback for a slug with no matching collection entry. + let category: Category | null = null; + if (slug) { + category = + categories?.find((c) => c.slug === slug) ?? + slicedPosts[0]?.categories?.find((c) => c.slug === slug) ?? + null; + } return { posts: slicedPosts, + category, + categories, pageInfo: toPageInfo(handledPosts, postsPerPage, pageNumber, params), seo: { title: category?.name ?? "", + description: category?.description, canonical: new URL(url.pathname, url.origin).href, }, }; @@ -99,3 +124,9 @@ function toPageInfo( recordPerPage: postsPerPage, }; } + +function loadCategories(): Category[] { + const categories = getRecordsByPath(CATEGORIES_PATH, CATEGORY_ACCESSOR); + + return (categories ?? []).filter(isValidCategory).sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/blog/loaders/GetCategories.ts b/blog/loaders/GetCategories.ts index c26f5b1..be27c67 100644 --- a/blog/loaders/GetCategories.ts +++ b/blog/loaders/GetCategories.ts @@ -35,14 +35,27 @@ export default function GetCategories({ if (!categories?.length) return null; + // Records come straight from the CMS, so `name`/`slug` are only strings by + // convention. A category missing either cannot be linked or labelled, and a + // non-string `name` would throw in the localeCompare below. + const validCategories = categories.filter(isValidCategory); + if (slug) { - return categories.filter((c) => c.slug === slug); + return validCategories.filter((c) => c.slug === slug); } - const sortedCategories = categories.sort((a, b) => { + if (!validCategories.length) return null; + + const sortedCategories = validCategories.sort((a, b) => { const comparison = a.name.localeCompare(b.name); return sortBy.endsWith("_desc") ? comparison : -comparison; }); return count ? sortedCategories.slice(0, count) : sortedCategories; } + +export const isValidCategory = (c?: Category): c is Category => + typeof c?.name === "string" && + c.name.length > 0 && + typeof c?.slug === "string" && + c.slug.length > 0; diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts index 4babb56..cf241dd 100644 --- a/blog/manifest.gen.ts +++ b/blog/manifest.gen.ts @@ -1,8 +1,10 @@ // AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT // This file is checked into source control and updated via: npm run generate:manifests import * as loaders_Author from "./loaders/Author"; +import * as loaders_BlogPostItem from "./loaders/BlogPostItem"; import * as loaders_BlogPostPage from "./loaders/BlogPostPage"; import * as loaders_Blogpost from "./loaders/Blogpost"; +import * as loaders_BlogpostList from "./loaders/BlogpostList"; import * as loaders_BlogpostListing from "./loaders/BlogpostListing"; import * as loaders_BlogRelatedPosts from "./loaders/BlogRelatedPosts"; import * as loaders_Category from "./loaders/Category"; @@ -12,10 +14,12 @@ const manifest = { name: "blog", loaders: { "blog/loaders/Author": loaders_Author, - "blog/loaders/Blogpost": loaders_Blogpost, + "blog/loaders/BlogPostItem": loaders_BlogPostItem, "blog/loaders/BlogPostPage": loaders_BlogPostPage, - "blog/loaders/BlogpostListing": loaders_BlogpostListing, "blog/loaders/BlogRelatedPosts": loaders_BlogRelatedPosts, + "blog/loaders/Blogpost": loaders_Blogpost, + "blog/loaders/BlogpostList": loaders_BlogpostList, + "blog/loaders/BlogpostListing": loaders_BlogpostListing, "blog/loaders/Category": loaders_Category, "blog/loaders/GetCategories": loaders_GetCategories, }, diff --git a/blog/types.ts b/blog/types.ts index 5ee85fa..dc60dbe 100644 --- a/blog/types.ts +++ b/blog/types.ts @@ -7,6 +7,12 @@ import type { ImageWidget } from "../website/types"; export interface Author { name: string; email: string; + /** + * @title Type + * @description Whether the author is a person or an organization. Emitted as the author @type in the JSON-LD. Defaults to Person. + * @default Person + */ + type?: "Person" | "Organization"; avatar?: ImageWidget; jobTitle?: string; company?: string; @@ -15,6 +21,13 @@ export interface Author { export interface Category { name: string; slug: string; + description?: string; + /** + * @title Sections + * @label hidden + * @changeable true + */ + sections?: unknown[]; } export interface BlogPost { @@ -42,7 +55,18 @@ export interface BlogPost { * @format date */ date: string; + /** + * @title Modified date + * @format date + * @description Date of the last relevant content update. Emitted as dateModified in the JSON-LD. + */ + dateModified?: string; slug: string; + /** + * @title Status + * @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 Post Content * @format rich-text @@ -69,6 +93,31 @@ export interface BlogPost { id?: string; } +/** + * 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 + * 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 + * consider ready, so it must not leak into a listing. + */ +export type PostStatus = "draft" | "published" | "archived" | "generating" | "awaiting_review"; + +/** + * A post is live when it has no status at all or is explicitly `published`. + * + * The absent case is load-bearing: `status` was added long after the first + * posts were written, so every existing record is missing it. Requiring an + * explicit `published` would empty every blog in production the moment a site + * bumps this app. + * + * Takes a plain `string` so it can also be applied to a raw CMS record, where + * the value is only a `PostStatus` by convention. + */ +export const isPublishedStatus = (status?: string): boolean => !status || status === "published"; + export interface ExtraProps { key: string; value: string; @@ -82,6 +131,14 @@ export interface Seo { noIndexing?: boolean; } +/** @titleBy name */ +export interface Publisher { + name: string; + /** @title Logo */ + logo?: ImageWidget; + url?: string; +} + export interface BlogPostPage { "@type": "BlogPostPage"; post: BlogPost; @@ -100,6 +157,10 @@ export interface PageInfo { export interface BlogPostListingPage { posts: BlogPost[]; + /** @title Active category */ + category?: Category | null; + /** @title Categories */ + categories?: Category[] | null; pageInfo: PageInfo; seo: Seo; } diff --git a/scripts/generate-manifests.ts b/scripts/generate-manifests.ts index 156079b..2748ed6 100644 --- a/scripts/generate-manifests.ts +++ b/scripts/generate-manifests.ts @@ -22,6 +22,7 @@ const APPS: AppConfig[] = [ { name: "shopify", dir: "shopify" }, { name: "resend", dir: "resend" }, { name: "website", dir: "website" }, + { name: "blog", dir: "blog" }, ]; const CATEGORIES = ["loaders", "actions", "sections"] as const; From 1efa2b1bdcba2da341b94c47fd40107ab875c610 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 20 Aug 2026 16:27:27 -0300 Subject: [PATCH 2/2] fix(blog): point ./blog/commerceLoaders export at loaderMap.ts The file was renamed to loaderMap.ts, but package.json kept exporting the old path, so the subpath resolved to a file that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 827194c..a87c794 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,8 @@ "./blog": "./blog/index.ts", "./blog/mod": "./blog/mod.ts", "./blog/types": "./blog/types.ts", - "./blog/commerceLoaders": "./blog/commerceLoaders.ts", + "./blog/loaderMap": "./blog/loaderMap.ts", + "./blog/commerceLoaders": "./blog/loaderMap.ts", "./blog/loaders/*": "./blog/loaders/*.ts", "./blog/core/*": "./blog/core/*.ts" },