Skip to content

Commit d8b3481

Browse files
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
1 parent d17bbc7 commit d8b3481

7 files changed

Lines changed: 125 additions & 8 deletions

File tree

src/components/markdown/Markdown.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { renderMarkdownReact } from '@tanstack/markdown/react'
22
import * as React from 'react'
33
import { InlineCode, MarkdownImg } from '~/ui'
4-
import { parseSiteMarkdown, type MarkdownDocument } from '~/utils/markdown'
4+
import {
5+
findFirstImageSrc,
6+
parseSiteMarkdown,
7+
type MarkdownDocument,
8+
} from '~/utils/markdown'
59
import { isSafeHttpUrl } from '~/utils/url-boundary'
610
import { CodeBlock } from './CodeBlock'
711
import { MarkdownLink } from './MarkdownLink'
@@ -19,27 +23,35 @@ export type MarkdownProps = {
1923
content?: string
2024
document?: MarkdownDocument
2125
preserveTabPanels?: boolean
26+
/** Render the first image in the document as high-priority/eager (e.g. blog post hero images) */
27+
eagerFirstImage?: boolean
2228
}
2329

2430
export function Markdown({
2531
content,
2632
document,
2733
preserveTabPanels,
34+
eagerFirstImage,
2835
}: MarkdownProps) {
2936
const parsed = React.useMemo(
3037
() => document ?? parseSiteMarkdown(content ?? ''),
3138
[content, document],
3239
)
3340

3441
return React.useMemo(() => {
42+
const firstImageSrc = eagerFirstImage
43+
? findFirstImageSrc(parsed)
44+
: undefined
45+
3546
return renderMarkdownReact(parsed, {
3647
allowHtml: true,
3748
components: createMarkdownComponents({
3849
preserveTabPanels,
50+
firstImageSrc,
3951
}),
4052
headingAnchors,
4153
})
42-
}, [parsed, preserveTabPanels])
54+
}, [parsed, preserveTabPanels, eagerFirstImage])
4355
}
4456

4557
const headingAnchors = {
@@ -160,7 +172,9 @@ function TableElement({
160172
)
161173
}
162174

163-
function createMarkdownComponents(options: MarkdownRenderOptions = {}) {
175+
function createMarkdownComponents(
176+
options: MarkdownRenderOptions & { firstImageSrc?: string } = {},
177+
) {
164178
function MdCommentComponentWithOptions(
165179
props: React.ComponentProps<typeof MdCommentComponent>,
166180
) {
@@ -172,6 +186,13 @@ function createMarkdownComponents(options: MarkdownRenderOptions = {}) {
172186
)
173187
}
174188

189+
function ImgElement(props: React.ComponentProps<typeof MarkdownImg>) {
190+
const isFirstImage =
191+
options.firstImageSrc !== undefined && props.src === options.firstImageSrc
192+
193+
return <MarkdownImg {...props} priority={isFirstImage} />
194+
}
195+
175196
return {
176197
a: LinkElement,
177198
code: CodeElement,
@@ -184,7 +205,7 @@ function createMarkdownComponents(options: MarkdownRenderOptions = {}) {
184205
h5: createHeadingComponent('h5'),
185206
h6: createHeadingComponent('h6'),
186207
iframe: MarkdownIframe,
187-
img: MarkdownImg,
208+
img: ImgElement,
188209
'md-comment-component': MdCommentComponentWithOptions,
189210
'md-framework-panel': MdFrameworkPanel,
190211
'md-tab-panel': MdTabPanel,

src/components/markdown/MarkdownContent.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type MarkdownContentProps = {
3333
pagePath?: string
3434
/** Current framework for filtering markdown content */
3535
currentFramework?: string
36+
/** Render the first image in the document as high-priority/eager (e.g. blog post hero images) */
37+
eagerFirstImage?: boolean
3638
}
3739

3840
function CopyPageDropdownFallback() {
@@ -76,6 +78,7 @@ export function MarkdownContent({
7678
libraryVersion,
7779
pagePath,
7880
currentFramework,
81+
eagerFirstImage,
7982
}: MarkdownContentProps) {
8083
const [canLoadCopyControls, setCanLoadCopyControls] = React.useState(false)
8184

@@ -84,7 +87,11 @@ export function MarkdownContent({
8487
}, [])
8588

8689
const renderedMarkdown = (
87-
<Markdown document={markdown} preserveTabPanels={preserveTabPanels} />
90+
<Markdown
91+
document={markdown}
92+
preserveTabPanels={preserveTabPanels}
93+
eagerFirstImage={eagerFirstImage}
94+
/>
8895
)
8996

9097
const contentNode =

src/routes/blog.$.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ function BlogPost() {
184184
branch={branch}
185185
filePath={filePath}
186186
containerRef={markdownContainerRef}
187+
eagerFirstImage
187188
/>
188189
</div>
189190
{isTocVisible && (

src/ui/MarkdownImg.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,23 @@ import type { HTMLProps } from 'react'
33
import { getOptimizedImageUrl } from '~/utils/optimizedImage'
44
import { getPublicImageDimensions } from '~/utils/publicImageDimensions'
55

6+
const DEFAULT_TRANSFORM_WIDTH = 1200
7+
8+
export type MarkdownImgProps = HTMLProps<HTMLImageElement> & {
9+
priority?: boolean
10+
}
11+
612
export const MarkdownImg = React.memo(function MarkdownImg({
713
alt,
814
src,
915
className,
1016
width,
1117
height,
1218
style,
19+
priority,
1320
children: _,
1421
...props
15-
}: HTMLProps<HTMLImageElement>) {
22+
}: MarkdownImgProps) {
1623
const sourceDimensions = src ? getPublicImageDimensions(src) : undefined
1724
const providedWidth = parseDimension(width)
1825
const providedHeight = parseDimension(height)
@@ -29,7 +36,9 @@ export const MarkdownImg = React.memo(function MarkdownImg({
2936
const resolvedWidth = width ?? inferredWidth
3037
const resolvedHeight = height ?? inferredHeight
3138
const numericWidth =
32-
typeof resolvedWidth === 'number' ? resolvedWidth : parseDimension(width)
39+
typeof resolvedWidth === 'number'
40+
? resolvedWidth
41+
: (parseDimension(width) ?? DEFAULT_TRANSFORM_WIDTH)
3342
const numericHeight =
3443
typeof resolvedHeight === 'number' ? resolvedHeight : parseDimension(height)
3544
const aspectRatio =
@@ -60,7 +69,8 @@ export const MarkdownImg = React.memo(function MarkdownImg({
6069
...style,
6170
}}
6271
className={`block max-w-full h-auto rounded-lg shadow-md ${className ?? ''}`}
63-
loading="lazy"
72+
loading={priority ? 'eager' : 'lazy'}
73+
fetchPriority={priority ? 'high' : undefined}
6474
decoding="async"
6575
/>
6676
)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { InlineNode } from '@tanstack/markdown'
2+
import type { MarkdownDocument } from './processor'
3+
4+
export function findFirstImageSrc(
5+
document: MarkdownDocument,
6+
): string | undefined {
7+
for (const block of document.children) {
8+
if (block.type === 'paragraph' || block.type === 'heading') {
9+
const found = findFirstImageSrcInInline(block.children)
10+
if (found) return found
11+
} else if (block.type !== 'thematicBreak' && block.type !== 'html') {
12+
// Every current blog post's hero image is a bare top-level paragraph;
13+
// stop rather than reach into lists/tables/blockquotes/tab panels,
14+
// where a "first" image may not even be visible on initial paint.
15+
return undefined
16+
}
17+
}
18+
return undefined
19+
}
20+
21+
function findFirstImageSrcInInline(nodes: InlineNode[]): string | undefined {
22+
for (const node of nodes) {
23+
if (node.type === 'image') return node.src
24+
if ('children' in node) {
25+
const found = findFirstImageSrcInInline(node.children)
26+
if (found) return found
27+
}
28+
}
29+
return undefined
30+
}

src/utils/markdown/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { findFirstImageSrc } from './findFirstImageSrc'
12
export {
23
parseSiteMarkdown,
34
type MarkdownDocument,

tests/blog-hero-image.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import assert from 'node:assert/strict'
2+
import fs from 'node:fs'
3+
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
import { findFirstImageSrc, parseSiteMarkdown } from '../src/utils/markdown'
6+
7+
// Guards the eagerFirstImage optimization (blog.$.tsx -> Markdown ->
8+
// findFirstImageSrc): if a future post's structure stops the walker before
9+
// its first image (e.g. a list/table/blockquote appears earlier in the
10+
// document), the post silently falls back to lazy-loading its hero image
11+
// instead of failing loudly. This test makes that regression visible.
12+
13+
const blogDir = path.join(
14+
path.dirname(fileURLToPath(import.meta.url)),
15+
'../src/blog',
16+
)
17+
18+
function stripFrontmatter(content: string) {
19+
const match = content.match(/^---\n[\s\S]*?\n---\n?/)
20+
return match ? content.slice(match[0].length) : content
21+
}
22+
23+
const postFiles = fs.readdirSync(blogDir).filter((file) => file.endsWith('.md'))
24+
25+
assert.ok(postFiles.length > 0, 'expected to find blog posts in src/blog')
26+
27+
for (const file of postFiles) {
28+
const raw = fs.readFileSync(path.join(blogDir, file), 'utf-8')
29+
const body = stripFrontmatter(raw)
30+
const hasBodyImage = /!\[[^\]]*\]\([^)]+\)/.test(body)
31+
32+
if (!hasBodyImage) continue
33+
34+
const document = parseSiteMarkdown(body)
35+
const firstImageSrc = findFirstImageSrc(document)
36+
37+
assert.notEqual(
38+
firstImageSrc,
39+
undefined,
40+
`${file} contains a body image but findFirstImageSrc() returned ` +
41+
`undefined - its first image is no longer reachable through plain ` +
42+
`paragraphs/headings, so the blog hero eager-load optimization is ` +
43+
`silently disabled for this post`,
44+
)
45+
}
46+
47+
console.log('blog-hero-image tests passed')

0 commit comments

Comments
 (0)