diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index 3ddfba901..cff98a176 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -1,22 +1,16 @@ import { Link } from '@tanstack/react-router' +import { ArrowSquareOutIcon } from '@phosphor-icons/react' import { Card } from '~/components/Card' import { CoverFallback } from '~/components/CoverFallback' import { formatAuthors, formatPublishedDate, getBlogLibraries, + type BlogCardPost, } from '~/utils/blog-format' import { getOptimizedImageUrl } from '~/utils/optimizedImage' -export type BlogCardPost = { - slug: string - title: string - published: string - excerpt: string - headerImage: string | undefined - authors: string[] - library: string | undefined -} +export type { BlogCardPost } from '~/utils/blog-format' type BlogCardProps = { post: BlogCardPost @@ -24,17 +18,23 @@ type BlogCardProps = { } export function BlogCard({ post, showLibraryBadges = true }: BlogCardProps) { - const { slug, title, published, excerpt, headerImage, authors, library } = - post + const { + slug, + title, + published, + excerpt, + headerImage, + authors, + library, + externalUrl, + source, + } = post const blogLibraries = showLibraryBadges ? getBlogLibraries(library) : [] + const cardClassName = + 'relative flex flex-col justify-between overflow-hidden transition-all hover:shadow-sm hover:border-blue-500' - return ( - + const content = ( + <> {blogLibraries.length ? (
{blogLibraries.map((blogLibrary) => ( @@ -88,11 +88,43 @@ export function BlogCard({ post, showLibraryBadges = true }: BlogCardProps) { ) : null}
-
- Read More -
+ {externalUrl ? ( +
+ Read on {source ?? 'Source'} +
+ ) : ( +
+ Read More +
+ )}
+ + ) + + if (externalUrl) { + return ( + + {content} + + ) + } + + return ( + + {content} ) } diff --git a/src/components/BlogSearchFilter.tsx b/src/components/BlogSearchFilter.tsx new file mode 100644 index 000000000..450f9d106 --- /dev/null +++ b/src/components/BlogSearchFilter.tsx @@ -0,0 +1,37 @@ +import { MagnifyingGlassIcon } from '@phosphor-icons/react' +import { twMerge } from 'tailwind-merge' + +type BlogSearchFilterProps = { + id: string + value: string + onChange: (value: string) => void + className?: string +} + +export function BlogSearchFilter({ + id, + value, + onChange, + className, +}: BlogSearchFilterProps) { + return ( +
+
+ ) +} diff --git a/src/components/RecentPostsWidget.tsx b/src/components/RecentPostsWidget.tsx index 189b47312..9380fb323 100644 --- a/src/components/RecentPostsWidget.tsx +++ b/src/components/RecentPostsWidget.tsx @@ -21,22 +21,45 @@ function RecentPostsList({ posts }: { posts: ReadonlyArray }) {
- {posts.map((post) => ( - - - {post.title} - - - {formatPublishedDate(post.published)} - - - ))} + {posts.map((post) => { + const content = ( + <> + + {post.title} + + + {formatPublishedDate(post.published)} + + + ) + const className = `flex flex-col gap-0.5 px-3 py-2.5 + hover:bg-gray-500/5 transition-colors duration-150` + + if (post.externalUrl) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) + })}
) diff --git a/src/components/ds/ui/BlogPostCard.tsx b/src/components/ds/ui/BlogPostCard.tsx index e897c5922..20b7e1e33 100644 --- a/src/components/ds/ui/BlogPostCard.tsx +++ b/src/components/ds/ui/BlogPostCard.tsx @@ -14,18 +14,12 @@ export function BlogPostCard({ onNavigate?: () => void post: RecentPost }) { - return ( - + const cardClassName = twMerge( + 'group/post flex flex-col gap-3 rounded-xl corner-squircle p-3 transition-colors hover:bg-surface-state-hover focus-visible:bg-surface-state-hover focus-visible:outline-none', + className, + ) + const content = ( + <> {post.headerImage ? (
+ + ) + + if (post.externalUrl) { + return ( + + {content} + + ) + } + + return ( + + {content} ) } diff --git a/src/routes/_library/$libraryId/$version.docs.blog.tsx b/src/routes/_library/$libraryId/$version.docs.blog.tsx index 0d5e687d1..2d5aa6640 100644 --- a/src/routes/_library/$libraryId/$version.docs.blog.tsx +++ b/src/routes/_library/$libraryId/$version.docs.blog.tsx @@ -1,51 +1,54 @@ +import { ArrowLeftIcon } from '@phosphor-icons/react' import { Link, createFileRoute } from '@tanstack/react-router' import * as v from 'valibot' -import { ArrowLeftIcon } from '@phosphor-icons/react' -import { twMerge } from 'tailwind-merge' +import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' +import { BlogCard } from '~/components/BlogCard' +import { BlogSearchFilter } from '~/components/BlogSearchFilter' import { DocContainer } from '~/components/DocContainer' import { DocTitle } from '~/components/DocTitle' -import { BlogCard } from '~/components/BlogCard' -import { BlogAuthorFilter } from '~/components/BlogAuthorFilter' import { getLibrary, type LibraryId } from '~/libraries' -import { fetchPostsForLibrary } from '~/utils/blog.functions' -import { getDistinctAuthors } from '~/utils/blog-format' +import { + getDistinctAuthors, + normalizeBlogAuthor, + searchBlogCardPosts, +} from '~/utils/blog-format' +import { fetchBlogPostsForLibrary } from '~/utils/blog.functions' const searchSchema = v.object({ author: v.fallback(v.optional(v.string()), undefined), + q: 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 }), + loader: ({ params }) => + fetchBlogPostsForLibrary({ data: params.libraryId }), component: RouteComponent, }, ) function RouteComponent() { const { libraryId } = Route.useParams() - const { author } = Route.useSearch() + const { author, q } = Route.useSearch() const navigate = Route.useNavigate() const library = getLibrary(libraryId as LibraryId) + const selectedAuthor = author ? normalizeBlogAuthor(author) : undefined + const searchQuery = q ?? '' const posts = Route.useLoaderData() const authors = getDistinctAuthors(posts) - const filteredPosts = author - ? posts.filter((post) => post.authors.includes(author)) + const authorFilteredPosts = selectedAuthor + ? posts.filter((post) => post.authors.includes(selectedAuthor)) : posts + const filteredPosts = searchBlogCardPosts(authorFilteredPosts, searchQuery) return ( -
-
+
+
- {authors.length > 0 ? ( -
+
+
-
- - navigate({ - search: () => ({ author: nextAuthor }), - replace: true, - }) - } - /> -
+ + navigate({ + search: (prev) => ({ + ...prev, + q: nextQuery || undefined, + }), + replace: true, + }) + } + className="w-72 max-w-full" + />
- ) : null} + {authors.length > 0 ? ( +
+ +
+ + navigate({ + search: (prev) => ({ + ...prev, + author: nextAuthor, + }), + replace: true, + }) + } + /> +
+
+ ) : null} +
{filteredPosts.map((post) => ( - + ))}
@@ -104,7 +122,9 @@ function RouteComponent() {
{posts.length === 0 ? `No blog posts yet for ${library.name}.` - : `No posts found${author ? ` by ${author}` : ''}.`} + : `No posts found${ + searchQuery ? ` matching ${searchQuery}` : '' + }${selectedAuthor ? ` by ${selectedAuthor}` : ''}.`}
) : null} diff --git a/src/routes/blog.index.tsx b/src/routes/blog.index.tsx index 2232399ec..28006d33b 100644 --- a/src/routes/blog.index.tsx +++ b/src/routes/blog.index.tsx @@ -1,54 +1,33 @@ +import { RssIcon } from '@phosphor-icons/react' 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 { getVisiblePosts } from '~/utils/blog' -import { getDistinctAuthors } from '~/utils/blog-format' - +import { BlogCard, type BlogCardPost } from '~/components/BlogCard' +import { BlogSearchFilter } from '~/components/BlogSearchFilter' +import { Card } from '~/components/Card' import { Footer } from '~/components/Footer' -import { PostNotFound } from './blog' -import { createServerFn } from '@tanstack/react-start' -import { setResponseHeaders } from '@tanstack/react-start/server' -import { RssIcon } from '@phosphor-icons/react' -import { libraries, type LibrarySlim } from '~/libraries' import { LibrariesWidget } from '~/components/LibrariesWidget' -import { Card } from '~/components/Card' -import { partners } from '~/utils/partners' -import { PartnersRail, RightRail } from '~/components/RightRail' import { RecentPostsWidget } from '~/components/RecentPostsWidget' +import { PartnersRail, RightRail } from '~/components/RightRail' +import { libraries, type LibrarySlim } from '~/libraries' +import { + getDistinctAuthors, + normalizeBlogAuthor, + searchBlogCardPosts, +} from '~/utils/blog-format' +import { fetchBlogIndexPosts } from '~/utils/blog.functions' +import { partners } from '~/utils/partners' +import { PostNotFound } from './blog' const searchSchema = v.object({ author: v.fallback(v.optional(v.string()), undefined), + q: v.fallback(v.optional(v.string()), undefined), }) -const fetchFrontMatters = createServerFn({ method: 'GET' }).handler( - async () => { - setResponseHeaders( - new Headers({ - 'Cache-Control': 'public, max-age=0, must-revalidate', - 'Cloudflare-CDN-Cache-Control': - 'public, max-age=300, stale-while-revalidate=300', - }), - ) - - return getVisiblePosts().map((post) => { - return { - slug: post.slug, - title: post.title, - published: post.published, - excerpt: post.excerpt, - headerImage: post.headerImage, - authors: post.authors, - library: post.library, - } - }) - }, -) - export const Route = createFileRoute('/blog/')({ staleTime: Infinity, validateSearch: searchSchema, - loader: () => fetchFrontMatters(), + loader: () => fetchBlogIndexPosts(), notFoundComponent: () => , component: BlogIndex, head: () => ({ @@ -72,24 +51,29 @@ function getLibrariesWithPosts(posts: BlogCardPost[]): LibrarySlim[] { } function BlogIndex() { - const frontMatters = Route.useLoaderData() as BlogCardPost[] - const { author } = Route.useSearch() + const frontMatters = Route.useLoaderData() + const { author, q } = Route.useSearch() const navigate = Route.useNavigate() - const activePartners = partners.filter((d) => d.status === 'active') + const activePartners = partners.filter( + (partner) => partner.status === 'active', + ) + const selectedAuthor = author ? normalizeBlogAuthor(author) : undefined + const searchQuery = q ?? '' const authors = getDistinctAuthors(frontMatters) const librariesWithPosts = getLibrariesWithPosts(frontMatters) - const filteredPosts = author - ? frontMatters.filter((post) => post.authors.includes(author)) + const authorFilteredPosts = selectedAuthor + ? frontMatters.filter((post) => post.authors.includes(selectedAuthor)) : frontMatters + const filteredPosts = searchBlogCardPosts(authorFilteredPosts, searchQuery) return (
-
+

Blog

- -
- +
+ + navigate({ - search: () => ({ author: nextAuthor }), + search: (prev) => ({ + ...prev, + q: nextQuery || undefined, + }), replace: true, }) } + className="w-72 max-w-full" />
+
+ +
+ + navigate({ + search: (prev) => ({ + ...prev, + author: nextAuthor, + }), + replace: true, + }) + } + /> +
+
{librariesWithPosts.length ? ( @@ -163,10 +174,16 @@ function BlogIndex() { {filteredPosts.length === 0 ? (
No posts found - {author ? ( + {searchQuery ? ( + <> + {' '} + matching {searchQuery} + + ) : null} + {selectedAuthor ? ( <> {' '} - by {author} + by {selectedAuthor} ) : null} . diff --git a/src/utils/blog-format.ts b/src/utils/blog-format.ts index 72987c098..0ac0c8246 100644 --- a/src/utils/blog-format.ts +++ b/src/utils/blog-format.ts @@ -1,3 +1,4 @@ +import { matchSorter } from 'match-sorter' import { findLibrary, type LibrarySlim } from '~/libraries' const listJoiner = new Intl.ListFormat('en-US', { @@ -5,12 +6,50 @@ const listJoiner = new Intl.ListFormat('en-US', { type: 'conjunction', }) +const authorAliases = new Map([ + ['TkDodo', 'Dominik Dorfmeister'], +]) + +export type BlogCardPost = { + slug: string + title: string + published: string + excerpt: string + headerImage: string | undefined + authors: Array + library: string | undefined + externalUrl?: string + source?: string +} + +export function normalizeBlogAuthor(author: string) { + return authorAliases.get(author) ?? author +} + +export function normalizeBlogAuthors(authors: Array) { + const normalizedAuthors: Array = [] + const seen = new Set() + + for (const author of authors) { + const normalizedAuthor = normalizeBlogAuthor(author) + + if (!seen.has(normalizedAuthor)) { + seen.add(normalizedAuthor) + normalizedAuthors.push(normalizedAuthor) + } + } + + return normalizedAuthors +} + export function formatAuthors(authors: Array) { - if (!authors.length) { + const normalizedAuthors = normalizeBlogAuthors(authors) + + if (!normalizedAuthors.length) { return 'TanStack' } - return listJoiner.format(authors) + return listJoiner.format(normalizedAuthors) } function getUtcDateString(date = new Date()) { @@ -63,8 +102,32 @@ export function getDistinctAuthors( const authors = new Set() for (const post of posts) { for (const author of post.authors) { - authors.add(author) + authors.add(normalizeBlogAuthor(author)) } } return [...authors].sort((a, b) => a.localeCompare(b)) } + +export function searchBlogCardPosts( + posts: Array, + query: string | undefined, +) { + const trimmedQuery = query?.trim() + + if (!trimmedQuery) { + return posts + } + + return matchSorter(posts, trimmedQuery, { + keys: [ + 'title', + 'excerpt', + (post) => post.authors.join(' '), + (post) => + getBlogLibraries(post.library) + .map((library) => `${library.id} ${library.name}`) + .join(' '), + (post) => post.library ?? '', + ], + }) +} diff --git a/src/utils/blog.functions.ts b/src/utils/blog.functions.ts index cc15b2a99..7524c870d 100644 --- a/src/utils/blog.functions.ts +++ b/src/utils/blog.functions.ts @@ -1,25 +1,36 @@ +import { notFound, redirect } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' 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, getVisiblePosts } from '~/utils/blog' +import { findLibrary, type LibraryId } from '~/libraries' import { + getPostsForLibrary, + getVisiblePosts, + postToBlogCardPost, + sortBlogCardPosts, +} from '~/utils/blog' +import { + type BlogCardPost, formatAuthors, formatPublishedDate, + getBlogLibraries, isPublishedDateReleased, } from '~/utils/blog-format' +import { getExternalBlogPosts } from '~/utils/external-blog-posts.server' import { buildRedirectManifest } from './redirects' -export type RecentPost = { - slug: string - title: string - published: string - excerpt: string - headerImage: string | undefined - authors: Array -} +export type RecentPost = Pick< + BlogCardPost, + | 'slug' + | 'title' + | 'published' + | 'excerpt' + | 'headerImage' + | 'authors' + | 'externalUrl' + | 'source' +> const blogRedirectManifest = buildRedirectManifest( allPosts.flatMap((post) => @@ -63,6 +74,25 @@ function handleRedirects(blogPath: string) { } } +function setExistingBlogListResponseHeaders() { + setResponseHeaders( + new Headers({ + 'Cache-Control': 'public, max-age=0, must-revalidate', + 'Cloudflare-CDN-Cache-Control': + 'public, max-age=300, stale-while-revalidate=300', + }), + ) +} + +async function getBlogCardPosts() { + const externalPosts = await getExternalBlogPosts() + + return sortBlogCardPosts([ + ...getVisiblePosts().map(postToBlogCardPost), + ...externalPosts, + ]) +} + export const fetchBlogPost = createServerFn({ method: 'GET' }) .validator(v.optional(v.string())) .handler(async ({ data }: { data: string | undefined }) => { @@ -107,26 +137,43 @@ ${post.content}` } }) +export const fetchBlogIndexPosts = createServerFn({ method: 'GET' }).handler( + async (): Promise> => { + setExistingBlogListResponseHeaders() + return getBlogCardPosts() + }, +) + +export const fetchBlogPostsForLibrary = createServerFn({ method: 'GET' }) + .validator(v.string()) + .handler(async ({ data }): Promise> => { + const library = findLibrary(data) + + if (!library) { + return [] + } + + return (await getBlogCardPosts()).filter((post) => + getBlogLibraries(post.library).some( + (postLibrary) => postLibrary.id === library.id, + ), + ) + }) + export const fetchRecentPosts = createServerFn({ method: 'GET' }).handler( async (): Promise> => { - setResponseHeaders( - new Headers({ - 'Cache-Control': 'public, max-age=0, must-revalidate', - 'Cloudflare-CDN-Cache-Control': - 'public, max-age=300, stale-while-revalidate=300', - }), - ) + setExistingBlogListResponseHeaders() - return getVisiblePosts() - .slice(0, 3) - .map((post) => ({ - slug: post.slug, - title: post.title, - published: post.published, - excerpt: post.excerpt, - headerImage: post.headerImage, - authors: post.authors, - })) + return (await getBlogCardPosts()).slice(0, 3).map((post) => ({ + slug: post.slug, + title: post.title, + published: post.published, + excerpt: post.excerpt, + headerImage: post.headerImage, + authors: post.authors, + externalUrl: post.externalUrl, + source: post.source, + })) }, ) @@ -162,32 +209,3 @@ 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, - })) - }) diff --git a/src/utils/blog.ts b/src/utils/blog.ts index 27a7a919a..b80b37a92 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -1,6 +1,34 @@ import { allPosts, type Post } from 'content-collections' import type { LibraryId } from '~/libraries' -import { getBlogLibraries, isPublishedDateReleased } from './blog-format' +import { + getBlogLibraries, + isPublishedDateReleased, + normalizeBlogAuthors, + type BlogCardPost, +} from './blog-format' + +export type { BlogCardPost } from './blog-format' + +export function postToBlogCardPost(post: Post): BlogCardPost { + return { + slug: post.slug, + title: post.title, + published: post.published, + excerpt: post.excerpt, + headerImage: post.headerImage, + authors: normalizeBlogAuthors(post.authors), + library: post.library, + } +} + +export function sortBlogCardPosts(posts: Array) { + return [...posts].sort( + (a, b) => + b.published.localeCompare(a.published) || + a.title.localeCompare(b.title) || + a.slug.localeCompare(b.slug), + ) +} /** * Returns published blog posts (not drafts, not future-dated), diff --git a/src/utils/external-blog-posts.server.ts b/src/utils/external-blog-posts.server.ts new file mode 100644 index 000000000..1bec0d3d5 --- /dev/null +++ b/src/utils/external-blog-posts.server.ts @@ -0,0 +1,375 @@ +import type { LibraryId } from '~/libraries' +import { normalizeBlogAuthors, type BlogCardPost } from '~/utils/blog-format' +import { fetchCached } from '~/utils/cache.server' + +const DEFAULT_STANDARD_SITE_TIMEOUT_MS = 5000 // 5 seconds +const DEFAULT_STANDARD_SITE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour +const STANDARD_SITE_DOCUMENT_COLLECTION = 'site.standard.document' +const STANDARD_SITE_PAGE_LIMIT = 100 + +type ExternalLibraryId = Extract + +type ExternalBlogItem = { + title: string + link: string + excerpt: string + published: string | undefined + categories: Array +} + +type StandardSiteBlob = { + ref?: { + $link?: string + } + mimeType?: string + size?: number +} + +type StandardSiteDocument = { + $type?: string + canonicalUrl?: string + coverImage?: StandardSiteBlob + description?: string + path?: string + publishedAt?: string + title?: string +} + +type StandardSiteRecord = { + uri: string + value: StandardSiteDocument +} + +type StandardSiteListRecordsResponse = { + cursor?: string + records?: Array +} + +type StandardSiteExternalBlogSource = { + type: 'standard-site' + id: string + name: string + siteUrl: string + pdsUrl: string + repo: string + collection?: string + slugPrefix: string + authors: Array + externalUrlSearchParams?: Record + cacheTtlMs?: number + timeoutMs?: number + maxPages?: number + inferLibraries?: (item: ExternalBlogItem) => Array +} + +type ExternalBlogSource = StandardSiteExternalBlogSource + +const externalBlogSources = [ + { + type: 'standard-site', + id: 'tkdodo', + name: "TkDodo's Blog", + siteUrl: 'https://tkdodo.eu', + pdsUrl: 'https://eurosky.social', + repo: 'did:plc:3nqrhu5mthmias3zc4a2ovzj', + slugPrefix: 'tkdodo', + authors: ['Dominik Dorfmeister'], + externalUrlSearchParams: { + utm_source: 'tanstack.com', + utm_medium: 'referral', + utm_campaign: 'tanstack_blog', + }, + inferLibraries: inferTanStackQueryAndRouterLibraries, + }, +] satisfies Array + +function normalizeSearchValue(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function includesPhrase(value: string, phrase: string) { + return value.includes(normalizeSearchValue(phrase)) +} + +function hasWord(value: string, word: string) { + return new RegExp(`\\b${word}\\b`).test(value) +} + +function inferTanStackQueryAndRouterLibraries( + item: ExternalBlogItem, +): Array { + const signal = normalizeSearchValue( + `${item.title} ${item.link} ${item.categories.join(' ')}`, + ) + const libraries: Array = [] + + if ( + includesPhrase(signal, 'react query') || + includesPhrase(signal, 'tanstack query') || + includesPhrase(signal, 'tan stack query') || + hasWord(signal, 'query') || + hasWord(signal, 'queries') + ) { + libraries.push('query') + } + + if ( + includesPhrase(signal, 'tanstack router') || + includesPhrase(signal, 'tan stack router') + ) { + libraries.push('router') + } + + return libraries +} + +export function inferExternalPostLibraries( + title: string, + link: string, +): Array { + return inferTanStackQueryAndRouterLibraries({ + title, + link, + excerpt: '', + published: undefined, + categories: [], + }) as Array +} + +function parseStandardSitePublishedDate(publishedAt: string | undefined) { + if (!publishedAt) { + return undefined + } + + const date = new Date(publishedAt) + + if (Number.isNaN(date.getTime())) { + return undefined + } + + return date.toISOString().slice(0, 10) +} + +function slugify(value: string) { + return normalizeSearchValue(value).replace(/\s+/g, '-') +} + +function getExternalPostSlug( + source: ExternalBlogSource, + item: ExternalBlogItem, +) { + try { + const pathnameSlug = new URL(item.link).pathname + .split('/') + .filter(Boolean) + .pop() + + if (pathnameSlug) { + return `${source.slugPrefix}-${pathnameSlug}` + } + } catch { + // Fall through to the title slug. + } + + return `${source.slugPrefix}-${slugify(item.title)}` +} + +function addSearchParams( + url: string, + searchParams: Record | undefined, +) { + if (!searchParams) { + return url + } + + try { + const nextUrl = new URL(url) + + for (const [key, value] of Object.entries(searchParams)) { + nextUrl.searchParams.set(key, value) + } + + return nextUrl.toString() + } catch { + return url + } +} + +function buildStandardSiteCanonicalUrl( + source: StandardSiteExternalBlogSource, + document: StandardSiteDocument, +) { + if (document.canonicalUrl) { + return document.canonicalUrl + } + + if (!document.path) { + return undefined + } + + return new URL(document.path, source.siteUrl).toString() +} + +function buildStandardSiteBlobUrl( + source: StandardSiteExternalBlogSource, + blob: StandardSiteBlob | undefined, +) { + const cid = blob?.ref?.$link + + if (!cid) { + return undefined + } + + const url = new URL('/xrpc/com.atproto.sync.getBlob', source.pdsUrl) + url.searchParams.set('did', source.repo) + url.searchParams.set('cid', cid) + + return url.toString() +} + +async function fetchStandardSitePage( + source: StandardSiteExternalBlogSource, + cursor?: string, +): Promise { + const controller = new AbortController() + const timeout = setTimeout( + () => controller.abort(), + source.timeoutMs ?? DEFAULT_STANDARD_SITE_TIMEOUT_MS, + ) + + try { + const url = new URL('/xrpc/com.atproto.repo.listRecords', source.pdsUrl) + url.searchParams.set('repo', source.repo) + url.searchParams.set( + 'collection', + source.collection ?? STANDARD_SITE_DOCUMENT_COLLECTION, + ) + url.searchParams.set('limit', String(STANDARD_SITE_PAGE_LIMIT)) + + if (cursor) { + url.searchParams.set('cursor', cursor) + } + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'Cache-Control': 'max-age=3600', + }, + signal: controller.signal, + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch ${url.toString()}: ${response.status} ${ + response.statusText + }`, + ) + } + + return response.json() as Promise + } finally { + clearTimeout(timeout) + } +} + +async function fetchStandardSiteRecords( + source: StandardSiteExternalBlogSource, +) { + const records: Array = [] + const maxPages = source.maxPages ?? 10 + let cursor: string | undefined + + for (let page = 0; page < maxPages; page++) { + const response = await fetchStandardSitePage(source, cursor) + const pageRecords = Array.isArray(response.records) ? response.records : [] + + records.push(...pageRecords) + + if (!response.cursor || pageRecords.length < STANDARD_SITE_PAGE_LIMIT) { + break + } + + cursor = response.cursor + } + + return records +} + +function standardSiteRecordToBlogCardPost( + source: StandardSiteExternalBlogSource, + record: StandardSiteRecord, +): BlogCardPost | undefined { + const document = record.value + const title = document.title?.trim() + const link = buildStandardSiteCanonicalUrl(source, document) + const published = parseStandardSitePublishedDate(document.publishedAt) + + if (!title || !link || !published) { + return undefined + } + + const item: ExternalBlogItem = { + title, + link, + excerpt: document.description?.trim() ?? '', + published, + categories: [], + } + const libraries = + source.inferLibraries?.(item) ?? inferTanStackQueryAndRouterLibraries(item) + + if (!libraries.length) { + return undefined + } + + return { + slug: getExternalPostSlug(source, item), + title, + published, + excerpt: item.excerpt, + headerImage: buildStandardSiteBlobUrl(source, document.coverImage), + authors: normalizeBlogAuthors(source.authors), + library: libraries.join(','), + externalUrl: addSearchParams(link, source.externalUrlSearchParams), + source: source.name, + } +} + +async function fetchStandardSiteBlogPosts( + source: StandardSiteExternalBlogSource, +) { + const records = await fetchStandardSiteRecords(source) + + return records.flatMap((record) => { + const post = standardSiteRecordToBlogCardPost(source, record) + + return post ? [post] : [] + }) +} + +async function fetchExternalBlogPostsForSource(source: ExternalBlogSource) { + return fetchCached({ + key: `external-blog-posts:${source.id}`, + ttl: source.cacheTtlMs ?? DEFAULT_STANDARD_SITE_CACHE_TTL_MS, + fn: async () => fetchStandardSiteBlogPosts(source), + }).catch((error) => { + console.warn( + `Unable to load external blog posts from ${source.name}`, + error, + ) + return [] + }) +} + +export async function getExternalBlogPosts() { + const postsBySource = await Promise.all( + externalBlogSources.map((source) => + fetchExternalBlogPostsForSource(source), + ), + ) + + return postsBySource.flat() +}