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
2 changes: 1 addition & 1 deletion src/components/BlogCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
formatAuthors,
formatPublishedDate,
getBlogLibraries,
} from '~/utils/blog'
} from '~/utils/blog-format'
import { getOptimizedImageUrl } from '~/utils/optimizedImage'

export type BlogCardPost = {
Expand Down
2 changes: 1 addition & 1 deletion src/components/RecentPostsWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<RecentPost>
Expand Down
2 changes: 1 addition & 1 deletion src/components/home/HomeSocialProofSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
36 changes: 28 additions & 8 deletions src/components/stack/CategoryArticle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<RelatedPostData>
}) {
const meta = categoryMeta[slug]
const libraries = getCategoryLibraries(slug)
const relatedPosts = getRelatedPosts(libraries)
const relatedPosts = reconstructRelatedPosts(libraries, relatedPostsData)

return (
<div className="bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
Expand Down Expand Up @@ -75,10 +82,23 @@ export function CategoryArticle({ slug }: { slug: CategorySlug }) {
)
}

function getRelatedPosts(libraries: Array<LibrarySlim>) {
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<LibrarySlim>,
data: Array<RelatedPostData>,
): Array<RelatedPost> {
const libraryById = new Map<LibraryId, LibrarySlim>(
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 }) {
Expand Down
7 changes: 5 additions & 2 deletions src/routes/_library/$libraryId/$version.docs.blog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ 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),
})

export const Route = createFileRoute('/_library/$libraryId/$version/docs/blog')(
{
staleTime: Infinity,
validateSearch: searchSchema,
loader: ({ params }) => fetchPostsForLibrary({ data: params.libraryId }),
component: RouteComponent,
},
)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/routes/blog.$.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
3 changes: 2 additions & 1 deletion src/routes/blog.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 2 additions & 5 deletions src/routes/rss[.]xml.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 17 additions & 4 deletions src/routes/stack.$category.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,33 @@ 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 {
return (categorySlugs as readonly string[]).includes(value)
}

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({
Expand All @@ -31,6 +44,6 @@ export const Route = createFileRoute('/stack/$category')({
})

function StackCategoryPage() {
const { category } = Route.useLoaderData()
return <CategoryArticle slug={category} />
const { category, relatedPosts } = Route.useLoaderData()
return <CategoryArticle slug={category} relatedPosts={relatedPosts} />
}
70 changes: 70 additions & 0 deletions src/utils/blog-format.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
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<string>()
for (const post of posts) {
for (const author of post.authors) {
authors.add(author)
}
}
return [...authors].sort((a, b) => a.localeCompare(b))
}
Comment on lines +60 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pin locale for deterministic sort order.

localeCompare without an explicit locale depends on the runtime's default locale, which can differ between server (SSR) and client (hydration), causing non-deterministic author ordering and potential hydration mismatches. formatAuthors in this same file already pins locale ('en-US') for this reason — apply the same here.

🌐 Proposed fix
-  return [...authors].sort((a, b) => a.localeCompare(b))
+  return [...authors].sort((a, b) => a.localeCompare(b, 'en-US'))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getDistinctAuthors(
posts: ReadonlyArray<{ authors: string[] }>,
): string[] {
const authors = new Set<string>()
for (const post of posts) {
for (const author of post.authors) {
authors.add(author)
}
}
return [...authors].sort((a, b) => a.localeCompare(b))
}
export function getDistinctAuthors(
posts: ReadonlyArray<{ authors: string[] }>,
): string[] {
const authors = new Set<string>()
for (const post of posts) {
for (const author of post.authors) {
authors.add(author)
}
}
return [...authors].sort((a, b) => a.localeCompare(b, 'en-US'))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/blog-format.ts` around lines 60 - 70, The author sorting in
getDistinctAuthors uses localeCompare without a fixed locale, so the order can
vary between SSR and hydration. Update the sort callback in getDistinctAuthors
to use the same pinned locale approach as formatAuthors by passing a fixed
locale like en-US to localeCompare, keeping author ordering deterministic across
runtimes.

67 changes: 65 additions & 2 deletions src/utils/blog.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<RelatedPost> => {
return (data as Array<LibraryId>)
.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<string>
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<LibraryBlogPost> => {
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,
}))
})
Loading
Loading