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..5397d772b 100644 --- a/src/components/stack/CategoryArticle.tsx +++ b/src/components/stack/CategoryArticle.tsx @@ -22,8 +22,9 @@ import { } from 'lucide-react' import { LibraryWordmark } from '~/components/LibraryWordmark' -import type { LibrarySlim } from '~/libraries' -import { formatPublishedDate, 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, @@ -44,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 (
@@ -75,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/_library/$libraryId/$version.docs.blog.tsx b/src/routes/_library/$libraryId/$version.docs.blog.tsx index 5f4f9cbf7..36a1c0fa9 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 { fetchPostsForLibrary } from '~/utils/blog.functions' +import { getDistinctAuthors } from '~/utils/blog-format' const searchSchema = v.object({ author: v.fallback(v.optional(v.string()), undefined), @@ -15,7 +16,9 @@ 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, }, ) @@ -26,7 +29,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/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..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, - formatAuthors, - publishedDateToUTCString, -} from '~/utils/blog' +import { getPublishedPosts } from '~/utils/blog' +import { formatAuthors, publishedDateToUTCString } from '~/utils/blog-format' function escapeXml(unsafe: string): string { return unsafe 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-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..3f36165a6 100644 --- a/src/utils/blog.functions.ts +++ b/src/utils/blog.functions.ts @@ -3,12 +3,13 @@ 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 type { LibraryId } from '~/libraries' +import { getPostsForLibrary, getPublishedPosts } from '~/utils/blog' import { formatAuthors, formatPublishedDate, - getPublishedPosts, isPublishedDateReleased, -} from '~/utils/blog' +} from '~/utils/blog-format' import { buildRedirectManifest } from './redirects' export type RecentPost = { @@ -128,3 +129,65 @@ 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) + }) + +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, + })) + }) 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)) -}