From 0218d7e888d9058b3d8ca40687c16ab3ee425169 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 22:49:47 -0700 Subject: [PATCH 1/5] Refactor blog utilities: move formatting functions to blog-format module --- src/components/BlogCard.tsx | 2 +- src/components/RecentPostsWidget.tsx | 2 +- .../home/HomeSocialProofSection.tsx | 2 +- src/components/stack/CategoryArticle.tsx | 3 +- .../$libraryId/$version.docs.blog.tsx | 3 +- src/routes/blog.$.tsx | 2 +- src/routes/blog.index.tsx | 3 +- src/routes/rss[.]xml.ts | 4 +- src/utils/blog-format.ts | 70 ++++++++++++++++++ src/utils/blog.functions.ts | 4 +- src/utils/blog.ts | 72 +------------------ 11 files changed, 86 insertions(+), 81 deletions(-) create mode 100644 src/utils/blog-format.ts diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index c1e98148b..3ddfba901 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -5,7 +5,7 @@ import { formatAuthors, formatPublishedDate, getBlogLibraries, -} from '~/utils/blog' +} from '~/utils/blog-format' import { getOptimizedImageUrl } from '~/utils/optimizedImage' export type BlogCardPost = { diff --git a/src/components/RecentPostsWidget.tsx b/src/components/RecentPostsWidget.tsx index 858df3b7a..8e96c91d3 100644 --- a/src/components/RecentPostsWidget.tsx +++ b/src/components/RecentPostsWidget.tsx @@ -1,7 +1,7 @@ import { Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { fetchRecentPosts, type RecentPost } from '~/utils/blog.functions' -import { formatPublishedDate } from '~/utils/blog' +import { formatPublishedDate } from '~/utils/blog-format' type RecentPostsWidgetProps = { posts?: ReadonlyArray diff --git a/src/components/home/HomeSocialProofSection.tsx b/src/components/home/HomeSocialProofSection.tsx index 7bd68c389..3f3210ec4 100644 --- a/src/components/home/HomeSocialProofSection.tsx +++ b/src/components/home/HomeSocialProofSection.tsx @@ -5,7 +5,7 @@ import { ArrowRight } from 'lucide-react' import { Card } from '~/components/Card' import { PartnersGrid } from '~/components/PartnersGrid' import { Button } from '~/ui' -import { formatAuthors, formatPublishedDate } from '~/utils/blog' +import { formatAuthors, formatPublishedDate } from '~/utils/blog-format' import type { RecentPost } from '~/utils/blog.functions' type HomeSocialProofSectionProps = { diff --git a/src/components/stack/CategoryArticle.tsx b/src/components/stack/CategoryArticle.tsx index 2c8f93fc1..0faece4e1 100644 --- a/src/components/stack/CategoryArticle.tsx +++ b/src/components/stack/CategoryArticle.tsx @@ -23,7 +23,8 @@ import { import { LibraryWordmark } from '~/components/LibraryWordmark' import type { LibrarySlim } from '~/libraries' -import { formatPublishedDate, getPostsForLibrary } from '~/utils/blog' +import { getPostsForLibrary } from '~/utils/blog' +import { formatPublishedDate } from '~/utils/blog-format' import { categoryMeta, getCategoryLibraries, diff --git a/src/routes/_library/$libraryId/$version.docs.blog.tsx b/src/routes/_library/$libraryId/$version.docs.blog.tsx index 5f4f9cbf7..00567cb5d 100644 --- a/src/routes/_library/$libraryId/$version.docs.blog.tsx +++ b/src/routes/_library/$libraryId/$version.docs.blog.tsx @@ -7,7 +7,8 @@ import { DocTitle } from '~/components/DocTitle' import { BlogCard } from '~/components/BlogCard' import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' import { getLibrary, type LibraryId } from '~/libraries' -import { getDistinctAuthors, getPostsForLibrary } from '~/utils/blog' +import { getPostsForLibrary } from '~/utils/blog' +import { getDistinctAuthors } from '~/utils/blog-format' const searchSchema = v.object({ author: v.fallback(v.optional(v.string()), undefined), diff --git a/src/routes/blog.$.tsx b/src/routes/blog.$.tsx index 48232a5d8..34babf670 100644 --- a/src/routes/blog.$.tsx +++ b/src/routes/blog.$.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { seo } from '~/utils/seo' import { PostNotFound } from './blog' -import { formatAuthors } from '~/utils/blog' +import { formatAuthors } from '~/utils/blog-format' import * as React from 'react' import { MarkdownContent } from '~/components/markdown' import { Card } from '~/components/Card' diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index ae5adc625..3922beff1 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -2,7 +2,8 @@ import { Link, createFileRoute } from '@tanstack/react-router' import * as v from 'valibot' import { BlogCard, type BlogCardPost } from '~/components/BlogCard' import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' -import { getDistinctAuthors, getPublishedPosts } from '~/utils/blog' +import { getPublishedPosts } from '~/utils/blog' +import { getDistinctAuthors } from '~/utils/blog-format' import { Footer } from '~/components/Footer' import { PostNotFound } from './blog' diff --git a/src/routes/rss[.]xml.ts b/src/routes/rss[.]xml.ts index 550a7c855..c8100f0ef 100644 --- a/src/routes/rss[.]xml.ts +++ b/src/routes/rss[.]xml.ts @@ -1,10 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' import { setResponseHeader } from '@tanstack/react-start/server' +import { getPublishedPosts } from '~/utils/blog' import { - getPublishedPosts, formatAuthors, publishedDateToUTCString, -} from '~/utils/blog' +} from '~/utils/blog-format' function escapeXml(unsafe: string): string { return unsafe diff --git a/src/utils/blog-format.ts b/src/utils/blog-format.ts new file mode 100644 index 000000000..72987c098 --- /dev/null +++ b/src/utils/blog-format.ts @@ -0,0 +1,70 @@ +import { findLibrary, type LibrarySlim } from '~/libraries' + +const listJoiner = new Intl.ListFormat('en-US', { + style: 'long', + type: 'conjunction', +}) + +export function formatAuthors(authors: Array) { + if (!authors.length) { + return 'TanStack' + } + + return listJoiner.format(authors) +} + +function getUtcDateString(date = new Date()) { + return date.toISOString().slice(0, 10) +} + +function parsePublishedDate(published: string) { + const [year, month, day] = published.split('-').map(Number) + + return new Date(Date.UTC(year, month - 1, day, 12)) +} + +export function formatPublishedDate(published: string) { + return parsePublishedDate(published).toLocaleDateString('en-US', { + timeZone: 'UTC', + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function isPublishedDateReleased(published: string, now = new Date()) { + return published <= getUtcDateString(now) +} + +export function publishedDateToUTCString(published: string) { + return parsePublishedDate(published).toUTCString() +} + +function isLibrarySlim( + library: LibrarySlim | undefined, +): library is LibrarySlim { + return library !== undefined +} + +export function getBlogLibraries(library: string | undefined): LibrarySlim[] { + if (!library) { + return [] + } + + return library + .split(',') + .map((libraryId) => findLibrary(libraryId.trim())) + .filter(isLibrarySlim) +} + +export function getDistinctAuthors( + posts: ReadonlyArray<{ authors: string[] }>, +): string[] { + const authors = new Set() + for (const post of posts) { + for (const author of post.authors) { + authors.add(author) + } + } + return [...authors].sort((a, b) => a.localeCompare(b)) +} diff --git a/src/utils/blog.functions.ts b/src/utils/blog.functions.ts index c55319742..37778b400 100644 --- a/src/utils/blog.functions.ts +++ b/src/utils/blog.functions.ts @@ -3,12 +3,12 @@ import { setResponseHeaders } from '@tanstack/react-start/server' import { notFound, redirect } from '@tanstack/react-router' import { allPosts } from 'content-collections' import * as v from 'valibot' +import { getPublishedPosts } from '~/utils/blog' import { formatAuthors, formatPublishedDate, - getPublishedPosts, isPublishedDateReleased, -} from '~/utils/blog' +} from '~/utils/blog-format' import { buildRedirectManifest } from './redirects' export type RecentPost = { diff --git a/src/utils/blog.ts b/src/utils/blog.ts index a5d215486..6ed7b92d8 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -1,45 +1,6 @@ import { allPosts, type Post } from 'content-collections' -import { findLibrary, type LibraryId, type LibrarySlim } from '~/libraries' - -const listJoiner = new Intl.ListFormat('en-US', { - style: 'long', - type: 'conjunction', -}) - -export function formatAuthors(authors: Array) { - if (!authors.length) { - return 'TanStack' - } - - return listJoiner.format(authors) -} - -function getUtcDateString(date = new Date()) { - return date.toISOString().slice(0, 10) -} - -function parsePublishedDate(published: string) { - const [year, month, day] = published.split('-').map(Number) - - return new Date(Date.UTC(year, month - 1, day, 12)) -} - -export function formatPublishedDate(published: string) { - return parsePublishedDate(published).toLocaleDateString('en-US', { - timeZone: 'UTC', - year: 'numeric', - month: 'short', - day: 'numeric', - }) -} - -export function isPublishedDateReleased(published: string, now = new Date()) { - return published <= getUtcDateString(now) -} - -export function publishedDateToUTCString(published: string) { - return parsePublishedDate(published).toUTCString() -} +import type { LibraryId } from '~/libraries' +import { getBlogLibraries, isPublishedDateReleased } from './blog-format' /** * Returns published blog posts (not drafts, not future-dated), @@ -51,37 +12,8 @@ export function getPublishedPosts(): Post[] { .sort((a, b) => b.published.localeCompare(a.published)) } -function isLibrarySlim( - library: LibrarySlim | undefined, -): library is LibrarySlim { - return library !== undefined -} - -export function getBlogLibraries(library: string | undefined): LibrarySlim[] { - if (!library) { - return [] - } - - return library - .split(',') - .map((libraryId) => findLibrary(libraryId.trim())) - .filter(isLibrarySlim) -} - export function getPostsForLibrary(libraryId: LibraryId): Post[] { return getPublishedPosts().filter((post) => getBlogLibraries(post.library).some((lib) => lib.id === libraryId), ) } - -export function getDistinctAuthors( - posts: ReadonlyArray<{ authors: string[] }>, -): string[] { - const authors = new Set() - for (const post of posts) { - for (const author of post.authors) { - authors.add(author) - } - } - return [...authors].sort((a, b) => a.localeCompare(b)) -} From 37eb0cf239a1d16f91c343e1b466893aef83bfa3 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 22:50:28 -0700 Subject: [PATCH 2/5] format --- src/routes/rss[.]xml.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/routes/rss[.]xml.ts b/src/routes/rss[.]xml.ts index c8100f0ef..732b7474c 100644 --- a/src/routes/rss[.]xml.ts +++ b/src/routes/rss[.]xml.ts @@ -1,10 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { setResponseHeader } from '@tanstack/react-start/server' import { getPublishedPosts } from '~/utils/blog' -import { - formatAuthors, - publishedDateToUTCString, -} from '~/utils/blog-format' +import { formatAuthors, publishedDateToUTCString } from '~/utils/blog-format' function escapeXml(unsafe: string): string { return unsafe From c0714f8a6ce44cb63507b4c903fa741d504b9d71 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 22:51:51 -0700 Subject: [PATCH 3/5] Enhance CategoryArticle to support related posts and refactor fetching logic --- src/components/stack/CategoryArticle.tsx | 35 +++++++++++++++++------ src/routes/stack.$category.tsx | 21 +++++++++++--- src/utils/blog.functions.ts | 36 +++++++++++++++++++++++- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/components/stack/CategoryArticle.tsx b/src/components/stack/CategoryArticle.tsx index 0faece4e1..5397d772b 100644 --- a/src/components/stack/CategoryArticle.tsx +++ b/src/components/stack/CategoryArticle.tsx @@ -22,9 +22,9 @@ import { } from 'lucide-react' import { LibraryWordmark } from '~/components/LibraryWordmark' -import type { LibrarySlim } from '~/libraries' -import { getPostsForLibrary } from '~/utils/blog' +import type { LibraryId, LibrarySlim } from '~/libraries' import { formatPublishedDate } from '~/utils/blog-format' +import type { RelatedPost as RelatedPostData } from '~/utils/blog.functions' import { categoryMeta, getCategoryLibraries, @@ -45,10 +45,16 @@ const libraryLinkClassName = const staticPanelClassName = 'rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950' -export function CategoryArticle({ slug }: { slug: CategorySlug }) { +export function CategoryArticle({ + slug, + relatedPosts: relatedPostsData, +}: { + slug: CategorySlug + relatedPosts: Array +}) { const meta = categoryMeta[slug] const libraries = getCategoryLibraries(slug) - const relatedPosts = getRelatedPosts(libraries) + const relatedPosts = reconstructRelatedPosts(libraries, relatedPostsData) return (
@@ -76,10 +82,23 @@ export function CategoryArticle({ slug }: { slug: CategorySlug }) { ) } -function getRelatedPosts(libraries: Array) { - return libraries - .flatMap((lib) => getPostsForLibrary(lib.id).map((post) => ({ post, lib }))) - .slice(0, 4) +/** + * Reconstructs {post, lib} pairs from the server-provided, already-ordered + * and already-sliced related-posts data, using the in-memory `libraries` + * array (pure, client-safe) rather than sending non-serializable LibrarySlim + * objects (e.g. `handleRedirects`) over the server-fn RPC boundary. + */ +function reconstructRelatedPosts( + libraries: Array, + data: Array, +): Array { + const libraryById = new Map( + libraries.map((lib) => [lib.id, lib]), + ) + return data.flatMap(({ libraryId, post }) => { + const lib = libraryById.get(libraryId) + return lib ? [{ post, lib }] : [] + }) } function Breadcrumb({ categoryName }: { categoryName: string }) { diff --git a/src/routes/stack.$category.tsx b/src/routes/stack.$category.tsx index a4a5a0e9d..7c089ed8c 100644 --- a/src/routes/stack.$category.tsx +++ b/src/routes/stack.$category.tsx @@ -4,8 +4,10 @@ import { CategoryArticle } from '~/components/stack/CategoryArticle' import { categoryMeta, categorySlugs, + getCategoryLibraries, type CategorySlug, } from '~/components/stack/stack-categories' +import { fetchRelatedPostsForLibraries } from '~/utils/blog.functions' import { seo } from '~/utils/seo' function isCategorySlug(value: string): value is CategorySlug { @@ -13,11 +15,22 @@ function isCategorySlug(value: string): value is CategorySlug { } export const Route = createFileRoute('/stack/$category')({ - loader: ({ params }) => { + staleTime: Infinity, + loader: async ({ params }) => { if (!isCategorySlug(params.category)) { throw notFound() } - return { category: params.category, meta: categoryMeta[params.category] } + + const libraries = getCategoryLibraries(params.category) + const relatedPosts = await fetchRelatedPostsForLibraries({ + data: libraries.map((lib) => lib.id), + }) + + return { + category: params.category, + meta: categoryMeta[params.category], + relatedPosts, + } }, head: ({ loaderData }) => ({ meta: seo({ @@ -31,6 +44,6 @@ export const Route = createFileRoute('/stack/$category')({ }) function StackCategoryPage() { - const { category } = Route.useLoaderData() - return + const { category, relatedPosts } = Route.useLoaderData() + return } diff --git a/src/utils/blog.functions.ts b/src/utils/blog.functions.ts index 37778b400..97f303a8b 100644 --- a/src/utils/blog.functions.ts +++ b/src/utils/blog.functions.ts @@ -3,7 +3,8 @@ import { setResponseHeaders } from '@tanstack/react-start/server' import { notFound, redirect } from '@tanstack/react-router' import { allPosts } from 'content-collections' import * as v from 'valibot' -import { getPublishedPosts } from '~/utils/blog' +import type { LibraryId } from '~/libraries' +import { getPostsForLibrary, getPublishedPosts } from '~/utils/blog' import { formatAuthors, formatPublishedDate, @@ -128,3 +129,36 @@ export const fetchRecentPosts = createServerFn({ method: 'GET' }).handler( })) }, ) + +export type RelatedPost = { + libraryId: LibraryId + post: { + slug: string + title: string + published: string + excerpt: string + } +} + +/** + * Mirrors CategoryArticle's original client-side + * `libraries.flatMap((lib) => getPostsForLibrary(lib.id)...).slice(0, 4)` + * so the display order/cutoff of related posts is unchanged. + */ +export const fetchRelatedPostsForLibraries = createServerFn({ method: 'GET' }) + .validator(v.array(v.string())) + .handler(({ data }): Array => { + return (data as Array) + .flatMap((libraryId) => + getPostsForLibrary(libraryId).map((post) => ({ + libraryId, + post: { + slug: post.slug, + title: post.title, + published: post.published, + excerpt: post.excerpt, + }, + })), + ) + .slice(0, 4) + }) From 4df165ea8f6c9c651c23f1d4a8ef94ef8c9448b1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 22:57:18 -0700 Subject: [PATCH 4/5] Refactor blog post fetching: replace getPostsForLibrary with fetchPostsForLibrary and update RouteComponent to use loader data --- .../$libraryId/$version.docs.blog.tsx | 7 +++-- src/utils/blog.functions.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/routes/_library/$libraryId/$version.docs.blog.tsx b/src/routes/_library/$libraryId/$version.docs.blog.tsx index 00567cb5d..b99544aa4 100644 --- a/src/routes/_library/$libraryId/$version.docs.blog.tsx +++ b/src/routes/_library/$libraryId/$version.docs.blog.tsx @@ -7,7 +7,7 @@ import { DocTitle } from '~/components/DocTitle' import { BlogCard } from '~/components/BlogCard' import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' import { getLibrary, type LibraryId } from '~/libraries' -import { getPostsForLibrary } from '~/utils/blog' +import { fetchPostsForLibrary } from '~/utils/blog.functions' import { getDistinctAuthors } from '~/utils/blog-format' const searchSchema = v.object({ @@ -16,7 +16,10 @@ const searchSchema = v.object({ export const Route = createFileRoute('/_library/$libraryId/$version/docs/blog')( { + staleTime: Infinity, validateSearch: searchSchema, + loader: ({ params }) => + fetchPostsForLibrary({ data: params.libraryId }), component: RouteComponent, }, ) @@ -27,7 +30,7 @@ function RouteComponent() { const navigate = Route.useNavigate() const library = getLibrary(libraryId as LibraryId) - const posts = getPostsForLibrary(libraryId as LibraryId) + const posts = Route.useLoaderData() const authors = getDistinctAuthors(posts) const filteredPosts = author diff --git a/src/utils/blog.functions.ts b/src/utils/blog.functions.ts index 97f303a8b..3f36165a6 100644 --- a/src/utils/blog.functions.ts +++ b/src/utils/blog.functions.ts @@ -162,3 +162,32 @@ export const fetchRelatedPostsForLibraries = createServerFn({ method: 'GET' }) ) .slice(0, 4) }) + +export type LibraryBlogPost = { + slug: string + title: string + published: string + excerpt: string + headerImage: string | undefined + authors: Array + library: string | undefined +} + +/** + * Wider 7-field shape (matches blog.index.tsx's fetchFrontMatters) since + * /docs/blog needs authors (author filter), headerImage (cover), and + * library (badge suppression) in addition to slug/title/published/excerpt. + */ +export const fetchPostsForLibrary = createServerFn({ method: 'GET' }) + .validator(v.string()) + .handler(({ data }): Array => { + return getPostsForLibrary(data as LibraryId).map((post) => ({ + slug: post.slug, + title: post.title, + published: post.published, + excerpt: post.excerpt, + headerImage: post.headerImage, + authors: post.authors, + library: post.library, + })) + }) From 6eff051fa0bf74cce02bbc92255e28a794f1dd91 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 1 Jul 2026 22:57:46 -0700 Subject: [PATCH 5/5] format --- src/routes/_library/$libraryId/$version.docs.blog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/_library/$libraryId/$version.docs.blog.tsx b/src/routes/_library/$libraryId/$version.docs.blog.tsx index b99544aa4..36a1c0fa9 100644 --- a/src/routes/_library/$libraryId/$version.docs.blog.tsx +++ b/src/routes/_library/$libraryId/$version.docs.blog.tsx @@ -18,8 +18,7 @@ export const Route = createFileRoute('/_library/$libraryId/$version/docs/blog')( { staleTime: Infinity, validateSearch: searchSchema, - loader: ({ params }) => - fetchPostsForLibrary({ data: params.libraryId }), + loader: ({ params }) => fetchPostsForLibrary({ data: params.libraryId }), component: RouteComponent, }, )