From 8e8af153512cea6b1c4560af98c8e6cf095d302f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 2 Jul 2026 08:16:02 -0700 Subject: [PATCH 01/22] perf: skip RecentPostsWidget fetch when its rail is hidden on mobile (#1024) --- src/components/LibraryLayout.tsx | 24 +++--------------------- src/components/RecentPostsWidget.tsx | 9 +++++++-- src/routes/blog.$.tsx | 4 +++- src/utils/useMediaQuery.ts | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 src/utils/useMediaQuery.ts diff --git a/src/components/LibraryLayout.tsx b/src/components/LibraryLayout.tsx index 71c2790fa..6cf10e3df 100644 --- a/src/components/LibraryLayout.tsx +++ b/src/components/LibraryLayout.tsx @@ -4,6 +4,7 @@ import { GithubIcon } from '~/components/icons/GithubIcon' import { DiscordIcon } from '~/components/icons/DiscordIcon' import { Link, useMatches, useParams } from '@tanstack/react-router' import { useLocalStorage } from '~/utils/useLocalStorage' +import { useMediaQuery } from '~/utils/useMediaQuery' import { useClickOutside } from '~/hooks/useClickOutside' import { last } from '~/utils/utils' import type { ConfigSchema, MenuItem } from '~/utils/config' @@ -424,26 +425,6 @@ function clampProgress(value: number) { return Math.min(Math.max(value, 0), 1) } -function useMediaQuery(query: string) { - const [matches, setMatches] = React.useState(false) - - React.useEffect(() => { - const mediaQueryList = window.matchMedia(query) - const updateMatches = () => { - setMatches(mediaQueryList.matches) - } - - updateMatches() - mediaQueryList.addEventListener('change', updateMatches) - - return () => { - mediaQueryList.removeEventListener('change', updateMatches) - } - }, [query]) - - return matches -} - function areDocsPartnerSlotsEqual( left: DocsPartnerScrollSlot | undefined, right: DocsPartnerScrollSlot | undefined, @@ -903,6 +884,7 @@ export function LibraryLayout({ surface: 'docs_rail', }) const shouldShowDocsPartnerSlot = useMediaQuery('(max-width: 767.98px)') + const isDesktopViewport = useMediaQuery('(min-width: 768px)') const groupInitialOpenState = React.useMemo(() => { return visibleMenuConfig.reduce>( @@ -1431,7 +1413,7 @@ export function LibraryLayout({ partners={activePartners} />
- +
)} diff --git a/src/components/RecentPostsWidget.tsx b/src/components/RecentPostsWidget.tsx index 858df3b7a..3dad2208c 100644 --- a/src/components/RecentPostsWidget.tsx +++ b/src/components/RecentPostsWidget.tsx @@ -5,6 +5,8 @@ import { formatPublishedDate } from '~/utils/blog' type RecentPostsWidgetProps = { posts?: ReadonlyArray + /** Set to false to skip the client fetch when the widget is rendered but not visible (e.g. hidden below a CSS breakpoint). Ignored when `posts` is provided. */ + enabled?: boolean } function RecentPostsList({ posts }: { posts: ReadonlyArray }) { @@ -63,11 +65,14 @@ function RecentPostsSkeleton() { ) } -export function RecentPostsWidget({ posts }: RecentPostsWidgetProps) { +export function RecentPostsWidget({ + posts, + enabled = true, +}: RecentPostsWidgetProps) { const recentPostsQuery = useQuery({ queryKey: ['recentPosts'], queryFn: () => fetchRecentPosts(), - enabled: posts === undefined, + enabled: posts === undefined && enabled, staleTime: 1000 * 60 * 5, }) diff --git a/src/routes/blog.$.tsx b/src/routes/blog.$.tsx index 48232a5d8..dc41a6b1e 100644 --- a/src/routes/blog.$.tsx +++ b/src/routes/blog.$.tsx @@ -9,6 +9,7 @@ import { LibrariesWidget } from '~/components/LibrariesWidget' import { partners } from '~/utils/partners' import { PartnersRail, RightRail } from '~/components/RightRail' import { RecentPostsWidget } from '~/components/RecentPostsWidget' +import { useMediaQuery } from '~/utils/useMediaQuery' import { Toc } from '~/components/Toc' import { Breadcrumbs } from '~/components/Breadcrumbs' @@ -76,6 +77,7 @@ function BlogPost() { const headings = markdown.headings const isTocVisible = headings.length > 1 + const isDesktopViewport = useMediaQuery('(min-width: 768px)') const markdownContainerRef = React.useRef(null) const [activeHeadings, setActiveHeadings] = React.useState>([]) @@ -203,7 +205,7 @@ function BlogPost() { partners={activePartners} />
- +
diff --git a/src/utils/useMediaQuery.ts b/src/utils/useMediaQuery.ts new file mode 100644 index 000000000..2112184d0 --- /dev/null +++ b/src/utils/useMediaQuery.ts @@ -0,0 +1,21 @@ +import * as React from 'react' + +export function useMediaQuery(query: string) { + const [matches, setMatches] = React.useState(false) + + React.useEffect(() => { + const mediaQueryList = window.matchMedia(query) + const updateMatches = () => { + setMatches(mediaQueryList.matches) + } + + updateMatches() + mediaQueryList.addEventListener('change', updateMatches) + + return () => { + mediaQueryList.removeEventListener('change', updateMatches) + } + }, [query]) + + return matches +} From d17bbc7fe62e90b73f32d0ec0901381e83e52357 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 2 Jul 2026 08:16:15 -0700 Subject: [PATCH 02/22] perf: stop shipping full blog post content to every client bundle (#1025) --- src/components/BlogCard.tsx | 2 +- src/components/RecentPostsWidget.tsx | 2 +- .../home/HomeSocialProofSection.tsx | 2 +- src/components/stack/CategoryArticle.tsx | 36 +++++++--- .../$libraryId/$version.docs.blog.tsx | 7 +- src/routes/blog.$.tsx | 2 +- src/routes/blog.index.tsx | 3 +- src/routes/rss[.]xml.ts | 7 +- src/routes/stack.$category.tsx | 21 ++++-- src/utils/blog-format.ts | 70 ++++++++++++++++++ src/utils/blog.functions.ts | 67 ++++++++++++++++- src/utils/blog.ts | 72 +------------------ 12 files changed, 195 insertions(+), 96 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 3dad2208c..189b47312 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 dc41a6b1e..12a8bbeca 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)) -} From d8b348196ca10e2faafefd475bdee048dc93a959 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 2 Jul 2026 11:36:52 -0700 Subject: [PATCH 03/22] perf: cap markdown image transform width + eager-load blog hero images (#1023) * Fix default width handling in MarkdownImg component * Enhance Markdown components to support eager loading for the first image * Implement eager image loading optimization in Markdown component and add tests * linting --- src/components/markdown/Markdown.tsx | 29 +++++++++++-- src/components/markdown/MarkdownContent.tsx | 9 +++- src/routes/blog.$.tsx | 1 + src/ui/MarkdownImg.tsx | 16 +++++-- src/utils/markdown/findFirstImageSrc.ts | 30 +++++++++++++ src/utils/markdown/index.ts | 1 + tests/blog-hero-image.test.ts | 47 +++++++++++++++++++++ 7 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 src/utils/markdown/findFirstImageSrc.ts create mode 100644 tests/blog-hero-image.test.ts diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx index 6166d2eb9..5753d29d3 100644 --- a/src/components/markdown/Markdown.tsx +++ b/src/components/markdown/Markdown.tsx @@ -1,7 +1,11 @@ import { renderMarkdownReact } from '@tanstack/markdown/react' import * as React from 'react' import { InlineCode, MarkdownImg } from '~/ui' -import { parseSiteMarkdown, type MarkdownDocument } from '~/utils/markdown' +import { + findFirstImageSrc, + parseSiteMarkdown, + type MarkdownDocument, +} from '~/utils/markdown' import { isSafeHttpUrl } from '~/utils/url-boundary' import { CodeBlock } from './CodeBlock' import { MarkdownLink } from './MarkdownLink' @@ -19,12 +23,15 @@ export type MarkdownProps = { content?: string document?: MarkdownDocument preserveTabPanels?: boolean + /** Render the first image in the document as high-priority/eager (e.g. blog post hero images) */ + eagerFirstImage?: boolean } export function Markdown({ content, document, preserveTabPanels, + eagerFirstImage, }: MarkdownProps) { const parsed = React.useMemo( () => document ?? parseSiteMarkdown(content ?? ''), @@ -32,14 +39,19 @@ export function Markdown({ ) return React.useMemo(() => { + const firstImageSrc = eagerFirstImage + ? findFirstImageSrc(parsed) + : undefined + return renderMarkdownReact(parsed, { allowHtml: true, components: createMarkdownComponents({ preserveTabPanels, + firstImageSrc, }), headingAnchors, }) - }, [parsed, preserveTabPanels]) + }, [parsed, preserveTabPanels, eagerFirstImage]) } const headingAnchors = { @@ -160,7 +172,9 @@ function TableElement({ ) } -function createMarkdownComponents(options: MarkdownRenderOptions = {}) { +function createMarkdownComponents( + options: MarkdownRenderOptions & { firstImageSrc?: string } = {}, +) { function MdCommentComponentWithOptions( props: React.ComponentProps, ) { @@ -172,6 +186,13 @@ function createMarkdownComponents(options: MarkdownRenderOptions = {}) { ) } + function ImgElement(props: React.ComponentProps) { + const isFirstImage = + options.firstImageSrc !== undefined && props.src === options.firstImageSrc + + return + } + return { a: LinkElement, code: CodeElement, @@ -184,7 +205,7 @@ function createMarkdownComponents(options: MarkdownRenderOptions = {}) { h5: createHeadingComponent('h5'), h6: createHeadingComponent('h6'), iframe: MarkdownIframe, - img: MarkdownImg, + img: ImgElement, 'md-comment-component': MdCommentComponentWithOptions, 'md-framework-panel': MdFrameworkPanel, 'md-tab-panel': MdTabPanel, diff --git a/src/components/markdown/MarkdownContent.tsx b/src/components/markdown/MarkdownContent.tsx index 309a6288f..1d22a521f 100644 --- a/src/components/markdown/MarkdownContent.tsx +++ b/src/components/markdown/MarkdownContent.tsx @@ -33,6 +33,8 @@ type MarkdownContentProps = { pagePath?: string /** Current framework for filtering markdown content */ currentFramework?: string + /** Render the first image in the document as high-priority/eager (e.g. blog post hero images) */ + eagerFirstImage?: boolean } function CopyPageDropdownFallback() { @@ -76,6 +78,7 @@ export function MarkdownContent({ libraryVersion, pagePath, currentFramework, + eagerFirstImage, }: MarkdownContentProps) { const [canLoadCopyControls, setCanLoadCopyControls] = React.useState(false) @@ -84,7 +87,11 @@ export function MarkdownContent({ }, []) const renderedMarkdown = ( - + ) const contentNode = diff --git a/src/routes/blog.$.tsx b/src/routes/blog.$.tsx index 12a8bbeca..a969d214e 100644 --- a/src/routes/blog.$.tsx +++ b/src/routes/blog.$.tsx @@ -184,6 +184,7 @@ function BlogPost() { branch={branch} filePath={filePath} containerRef={markdownContainerRef} + eagerFirstImage />
{isTocVisible && ( diff --git a/src/ui/MarkdownImg.tsx b/src/ui/MarkdownImg.tsx index 8b77ade0b..3ea4c93d2 100644 --- a/src/ui/MarkdownImg.tsx +++ b/src/ui/MarkdownImg.tsx @@ -3,6 +3,12 @@ import type { HTMLProps } from 'react' import { getOptimizedImageUrl } from '~/utils/optimizedImage' import { getPublicImageDimensions } from '~/utils/publicImageDimensions' +const DEFAULT_TRANSFORM_WIDTH = 1200 + +export type MarkdownImgProps = HTMLProps & { + priority?: boolean +} + export const MarkdownImg = React.memo(function MarkdownImg({ alt, src, @@ -10,9 +16,10 @@ export const MarkdownImg = React.memo(function MarkdownImg({ width, height, style, + priority, children: _, ...props -}: HTMLProps) { +}: MarkdownImgProps) { const sourceDimensions = src ? getPublicImageDimensions(src) : undefined const providedWidth = parseDimension(width) const providedHeight = parseDimension(height) @@ -29,7 +36,9 @@ export const MarkdownImg = React.memo(function MarkdownImg({ const resolvedWidth = width ?? inferredWidth const resolvedHeight = height ?? inferredHeight const numericWidth = - typeof resolvedWidth === 'number' ? resolvedWidth : parseDimension(width) + typeof resolvedWidth === 'number' + ? resolvedWidth + : (parseDimension(width) ?? DEFAULT_TRANSFORM_WIDTH) const numericHeight = typeof resolvedHeight === 'number' ? resolvedHeight : parseDimension(height) const aspectRatio = @@ -60,7 +69,8 @@ export const MarkdownImg = React.memo(function MarkdownImg({ ...style, }} className={`block max-w-full h-auto rounded-lg shadow-md ${className ?? ''}`} - loading="lazy" + loading={priority ? 'eager' : 'lazy'} + fetchPriority={priority ? 'high' : undefined} decoding="async" /> ) diff --git a/src/utils/markdown/findFirstImageSrc.ts b/src/utils/markdown/findFirstImageSrc.ts new file mode 100644 index 000000000..f25ae1e64 --- /dev/null +++ b/src/utils/markdown/findFirstImageSrc.ts @@ -0,0 +1,30 @@ +import type { InlineNode } from '@tanstack/markdown' +import type { MarkdownDocument } from './processor' + +export function findFirstImageSrc( + document: MarkdownDocument, +): string | undefined { + for (const block of document.children) { + if (block.type === 'paragraph' || block.type === 'heading') { + const found = findFirstImageSrcInInline(block.children) + if (found) return found + } else if (block.type !== 'thematicBreak' && block.type !== 'html') { + // Every current blog post's hero image is a bare top-level paragraph; + // stop rather than reach into lists/tables/blockquotes/tab panels, + // where a "first" image may not even be visible on initial paint. + return undefined + } + } + return undefined +} + +function findFirstImageSrcInInline(nodes: InlineNode[]): string | undefined { + for (const node of nodes) { + if (node.type === 'image') return node.src + if ('children' in node) { + const found = findFirstImageSrcInInline(node.children) + if (found) return found + } + } + return undefined +} diff --git a/src/utils/markdown/index.ts b/src/utils/markdown/index.ts index 75b7d7248..92103fafd 100644 --- a/src/utils/markdown/index.ts +++ b/src/utils/markdown/index.ts @@ -1,3 +1,4 @@ +export { findFirstImageSrc } from './findFirstImageSrc' export { parseSiteMarkdown, type MarkdownDocument, diff --git a/tests/blog-hero-image.test.ts b/tests/blog-hero-image.test.ts new file mode 100644 index 000000000..a188e3991 --- /dev/null +++ b/tests/blog-hero-image.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { findFirstImageSrc, parseSiteMarkdown } from '../src/utils/markdown' + +// Guards the eagerFirstImage optimization (blog.$.tsx -> Markdown -> +// findFirstImageSrc): if a future post's structure stops the walker before +// its first image (e.g. a list/table/blockquote appears earlier in the +// document), the post silently falls back to lazy-loading its hero image +// instead of failing loudly. This test makes that regression visible. + +const blogDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../src/blog', +) + +function stripFrontmatter(content: string) { + const match = content.match(/^---\n[\s\S]*?\n---\n?/) + return match ? content.slice(match[0].length) : content +} + +const postFiles = fs.readdirSync(blogDir).filter((file) => file.endsWith('.md')) + +assert.ok(postFiles.length > 0, 'expected to find blog posts in src/blog') + +for (const file of postFiles) { + const raw = fs.readFileSync(path.join(blogDir, file), 'utf-8') + const body = stripFrontmatter(raw) + const hasBodyImage = /!\[[^\]]*\]\([^)]+\)/.test(body) + + if (!hasBodyImage) continue + + const document = parseSiteMarkdown(body) + const firstImageSrc = findFirstImageSrc(document) + + assert.notEqual( + firstImageSrc, + undefined, + `${file} contains a body image but findFirstImageSrc() returned ` + + `undefined - its first image is no longer reachable through plain ` + + `paragraphs/headings, so the blog hero eager-load optimization is ` + + `silently disabled for this post`, + ) +} + +console.log('blog-hero-image tests passed') From 09622c952a79df1cc527263033bd6b0c025a2276 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 2 Jul 2026 15:20:11 -0600 Subject: [PATCH 04/22] Add embeddable npm stats chart --- src/components/npm-stats/NPMStatsChart.tsx | 251 ++++++++++++++- src/routeTree.gen.ts | 21 ++ src/routes/stats/npm/$packages.tsx | 1 + src/routes/stats/npm/embed.tsx | 341 +++++++++++++++++++++ src/routes/stats/npm/index.tsx | 86 +++++- src/server.ts | 4 + 6 files changed, 700 insertions(+), 4 deletions(-) create mode 100644 src/routes/stats/npm/embed.tsx diff --git a/src/components/npm-stats/NPMStatsChart.tsx b/src/components/npm-stats/NPMStatsChart.tsx index 5dd973ad6..04d2f8e04 100644 --- a/src/components/npm-stats/NPMStatsChart.tsx +++ b/src/components/npm-stats/NPMStatsChart.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import * as Plot from '@observablehq/plot' import * as d3 from 'd3' import { GIFEncoder, applyPalette, quantize } from 'gifenc' -import { Download, List } from 'lucide-react' +import { Check, Code2, Copy, Download, List } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -1267,6 +1267,16 @@ type LatestBucketOffsetBounds = { maxOffset: number minOffset: number } +export type NpmStatsChartEmbedOptions = { + includeTimelineRange: boolean + lockWidth: boolean + showLegend: boolean +} +export type NpmStatsChartEmbedConfig = { + buildUrl: (options: NpmStatsChartEmbedOptions) => string + hasTimelineRange: boolean + hasWidth: boolean +} const chartExportOptions = [ { value: 'svg', label: 'SVG' }, @@ -1287,6 +1297,8 @@ const chartActionDropdownContentStyles = 'z-50 min-w-[120px] rounded-md bg-white p-1.5 shadow-lg dark:bg-gray-800' const chartActionDropdownItemStyles = 'flex w-full cursor-pointer items-center rounded px-1.5 py-1 text-left text-xs outline-none hover:bg-gray-500/20 data-highlighted:bg-gray-500/20 data-highlighted:text-blue-500' +const chartEmbedInputStyles = + 'w-full rounded border border-gray-500/20 bg-gray-50 px-2 py-1.5 font-mono text-[11px] leading-snug outline-none focus:border-blue-500 dark:bg-gray-900' const svgNamespace = 'http://www.w3.org/2000/svg' function getExportFileName(format: ChartExportFormat) { @@ -2416,6 +2428,7 @@ function renderPlotLegend({ function ChartActions({ canExportAnimation, disabled, + embedConfig, exportingFormat, onExport, onToggleLegend, @@ -2423,6 +2436,7 @@ function ChartActions({ }: { canExportAnimation: boolean disabled: boolean + embedConfig?: NpmStatsChartEmbedConfig exportingFormat: ChartExportFormat | null onExport: (format: ChartExportFormat) => void onToggleLegend: () => void @@ -2504,10 +2518,222 @@ function ChartActions({ + {embedConfig ? : null} ) } +function getAbsoluteEmbedUrl(url: string) { + const base = + typeof window === 'undefined' + ? 'https://tanstack.com' + : window.location.origin + + return new URL(url, base).toString() +} + +function CopyButton({ + copied, + label, + onCopy, +}: { + copied: boolean + label: string + onCopy: () => void +}) { + return ( + + ) +} + +function EmbedOption({ + checked, + children, + disabled, + onCheckedChange, +}: { + checked: boolean + children: React.ReactNode + disabled?: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( + + ) +} + +function EmbedChartAction({ + embedConfig, +}: { + embedConfig: NpmStatsChartEmbedConfig +}) { + const [showLegend, setShowLegend] = React.useState(true) + const [includeTimelineRange, setIncludeTimelineRange] = React.useState( + embedConfig.hasTimelineRange, + ) + const [lockWidth, setLockWidth] = React.useState(false) + const [iframeHeight, setIframeHeight] = React.useState(420) + const [copiedTarget, setCopiedTarget] = React.useState< + 'iframe' | 'url' | null + >(null) + + React.useEffect(() => { + if (!embedConfig.hasTimelineRange) { + setIncludeTimelineRange(false) + } + }, [embedConfig.hasTimelineRange]) + + React.useEffect(() => { + if (!embedConfig.hasWidth) { + setLockWidth(false) + } + }, [embedConfig.hasWidth]) + + const embedUrl = getAbsoluteEmbedUrl( + embedConfig.buildUrl({ + includeTimelineRange, + lockWidth, + showLegend, + }), + ) + const iframeCode = `` + + const copyText = React.useCallback( + async (target: 'iframe' | 'url', text: string) => { + await navigator.clipboard.writeText(text) + setCopiedTarget(target) + window.setTimeout(() => { + setCopiedTarget((currentTarget) => + currentTarget === target ? null : currentTarget, + ) + }, 1500) + }, + [], + ) + + return ( + + + + + + + +
+
+
Embed chart
+ +
+ +
+ + Show legend by default + + + Include current timeline zoom + + + Lock current chart width + +
+ +
+
+ URL + { + void copyText('url', embedUrl) + }} + /> +
+ +
+ +
+
+ + Iframe + + { + void copyText('iframe', iframeCode) + }} + /> +
+