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')