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
74 changes: 53 additions & 21 deletions src/components/BlogCard.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
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
showLibraryBadges?: boolean
}

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 (
<Card
as={Link}
to="/blog/$"
params={{ _splat: slug } as never}
className="relative flex flex-col justify-between overflow-hidden transition-all hover:shadow-sm hover:border-blue-500"
>
const content = (
<>
{blogLibraries.length ? (
<div className="absolute right-3 top-3 z-10 flex flex-wrap justify-end gap-1">
{blogLibraries.map((blogLibrary) => (
Expand Down Expand Up @@ -88,11 +88,43 @@ export function BlogCard({ post, showLibraryBadges = true }: BlogCardProps) {
) : null}
</div>
<div>
<div className="text-blue-500 uppercase font-black text-sm">
Read More
</div>
{externalUrl ? (
<div className="inline-flex items-center gap-1 text-blue-500 uppercase font-black text-sm">
Read on {source ?? 'Source'}
<ArrowSquareOutIcon className="h-3.5 w-3.5" aria-hidden="true" />
</div>
) : (
<div className="text-blue-500 uppercase font-black text-sm">
Read More
</div>
)}
</div>
</div>
</>
)

if (externalUrl) {
return (
<Card
as="a"
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
className={cardClassName}
>
{content}
</Card>
)
}

return (
<Card
as={Link}
to="/blog/$"
params={{ _splat: slug } as never}
className={cardClassName}
>
{content}
</Card>
)
}
37 changes: 37 additions & 0 deletions src/components/BlogSearchFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={twMerge('relative w-full', className)}>
<MagnifyingGlassIcon
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500 dark:text-gray-400"
aria-hidden="true"
/>
<input
id={id}
type="search"
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
placeholder="Search posts..."
className={twMerge(
'w-full rounded-md border border-gray-500/20 bg-transparent py-1.5 pl-8 pr-2 text-sm transition-colors',
'placeholder:text-gray-500 dark:placeholder:text-gray-400',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
)}
/>
</div>
)
}
55 changes: 39 additions & 16 deletions src/components/RecentPostsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,45 @@ function RecentPostsList({ posts }: { posts: ReadonlyArray<RecentPost> }) {
</Link>
</div>
<div className="flex flex-col divide-y divide-gray-500/10">
{posts.map((post) => (
<Link
key={post.slug}
to="/blog/$"
params={{ _splat: post.slug } as never}
className="flex flex-col gap-0.5 px-3 py-2.5
hover:bg-gray-500/5 transition-colors duration-150"
>
<span className="text-xs font-medium leading-snug line-clamp-2">
{post.title}
</span>
<span className="text-[10px] text-gray-500 dark:text-gray-500">
{formatPublishedDate(post.published)}
</span>
</Link>
))}
{posts.map((post) => {
const content = (
<>
<span className="text-xs font-medium leading-snug line-clamp-2">
{post.title}
</span>
<span className="text-[10px] text-gray-500 dark:text-gray-500">
{formatPublishedDate(post.published)}
</span>
</>
)
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 (
<a
key={post.slug}
href={post.externalUrl}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{content}
</a>
)
}

return (
<Link
key={post.slug}
to="/blog/$"
params={{ _splat: post.slug } as never}
className={className}
>
{content}
</Link>
)
})}
</div>
</div>
)
Expand Down
45 changes: 33 additions & 12 deletions src/components/ds/ui/BlogPostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,12 @@ export function BlogPostCard({
onNavigate?: () => void
post: RecentPost
}) {
return (
<Link
to="/blog/$"
params={{ _splat: post.slug } as never}
onClick={onNavigate}
preload="intent"
activeProps={{ className: 'bg-surface-state-pressed' }}
className={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 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 ? (
<div className="aspect-video w-full overflow-hidden rounded-lg corner-squircle border border-border-subtle">
<img
Expand Down Expand Up @@ -58,6 +52,33 @@ export function BlogPostCard({
{formatAuthors(post.authors)} · {formatPublishedDate(post.published)}
</div>
</div>
</>
)

if (post.externalUrl) {
return (
<a
href={post.externalUrl}
target="_blank"
rel="noopener noreferrer"
Comment on lines +58 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/utils/external-blog-posts.server.ts"
test -f "$file"

rg -n -C 10 \
  'canonicalUrl|buildStandardSiteCanonicalUrl|externalUrl|addSearchParams|protocol' \
  "$file"

Repository: TanStack/tanstack.com

Length of output: 4101


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- BlogPostCard outline ---\n'
ast-grep outline src/components/ds/ui/BlogPostCard.tsx || true

printf '\n--- BlogPostCard relevant lines ---\n'
sed -n '1,110p' src/components/ds/ui/BlogPostCard.tsx | cat -n

printf '\n--- external-blog-posts relevant lines ---\n'
sed -n '180,340p' src/utils/external-blog-posts.server.ts | cat -n

printf '\n--- BlogCardPost type usages/declaration ---\n'
rg -n -C 4 'type BlogCardPost|interface BlogCardPost|externalUrl:' src

Repository: TanStack/tanstack.com

Length of output: 250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- BlogPostCard outline ---'
ast-grep outline src/components/ds/ui/BlogPostCard.tsx || true

printf '%s\n' ''
printf '%s\n' '--- BlogPostCard relevant lines ---'
sed -n '1,110p' src/components/ds/ui/BlogPostCard.tsx | cat -n

printf '%s\n' ''
printf '%s\n' '--- external-blog-posts relevant lines ---'
sed -n '180,340p' src/utils/external-blog-posts.server.ts | cat -n

printf '%s\n' ''
printf '%s\n' '--- BlogCardPost type usages/declaration ---'
rg -n -C 4 'type BlogCardPost|interface BlogCardPost|externalUrl:' src

Repository: TanStack/tanstack.com

Length of output: 12635


Validate the URL scheme before rendering external links.

buildStandardSiteCanonicalUrl() accepts document.canonicalUrl without limiting it to http: or https:, and standardSiteRecordToBlogCardPost() stores that value as externalUrl. BlogPostCard then assigns it directly to href. Reject non-HTTP(S) canonical URLs in the producer so the unsafe scheme cannot reach React.

🤖 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/components/ds/ui/BlogPostCard.tsx` around lines 58 - 63, Update
standardSiteRecordToBlogCardPost and the canonical URL production flow around
buildStandardSiteCanonicalUrl to accept external URLs only when their scheme is
http or https; reject or omit non-HTTP(S) canonical URLs before assigning
externalUrl, so BlogPostCard never receives an unsafe href while valid links
remain unchanged.

Source: MCP tools

onClick={onNavigate}
className={cardClassName}
>
{content}
</a>
)
}

return (
<Link
to="/blog/$"
params={{ _splat: post.slug } as never}
onClick={onNavigate}
preload="intent"
activeProps={{ className: 'bg-surface-state-pressed' }}
className={cardClassName}
>
{content}
</Link>
)
}
Loading
Loading