Skip to content

Commit bb0f1c9

Browse files
committed
Implement eager image loading optimization in Markdown component and add tests
1 parent df78d80 commit bb0f1c9

4 files changed

Lines changed: 96 additions & 15 deletions

File tree

src/components/markdown/Markdown.tsx

Lines changed: 13 additions & 15 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'
@@ -13,7 +17,6 @@ import {
1317

1418
type MarkdownRenderOptions = {
1519
preserveTabPanels?: boolean
16-
eagerFirstImage?: boolean
1720
}
1821

1922
export type MarkdownProps = {
@@ -34,16 +37,17 @@ export function Markdown({
3437
() => document ?? parseSiteMarkdown(content ?? ''),
3538
[content, document],
3639
)
37-
const heroConsumedRef = React.useRef(false)
38-
heroConsumedRef.current = false
3940

4041
return React.useMemo(() => {
42+
const firstImageSrc = eagerFirstImage
43+
? findFirstImageSrc(parsed)
44+
: undefined
45+
4146
return renderMarkdownReact(parsed, {
4247
allowHtml: true,
4348
components: createMarkdownComponents({
4449
preserveTabPanels,
45-
eagerFirstImage,
46-
heroConsumedRef,
50+
firstImageSrc,
4751
}),
4852
headingAnchors,
4953
})
@@ -169,9 +173,7 @@ function TableElement({
169173
}
170174

171175
function createMarkdownComponents(
172-
options: MarkdownRenderOptions & {
173-
heroConsumedRef?: React.RefObject<boolean>
174-
} = {},
176+
options: MarkdownRenderOptions & { firstImageSrc?: string } = {},
175177
) {
176178
function MdCommentComponentWithOptions(
177179
props: React.ComponentProps<typeof MdCommentComponent>,
@@ -185,13 +187,9 @@ function createMarkdownComponents(
185187
}
186188

187189
function ImgElement(props: React.ComponentProps<typeof MarkdownImg>) {
188-
const heroConsumedRef = options.heroConsumedRef
189190
const isFirstImage =
190-
options.eagerFirstImage && heroConsumedRef && !heroConsumedRef.current
191-
192-
if (isFirstImage) {
193-
heroConsumedRef.current = true
194-
}
191+
options.firstImageSrc !== undefined &&
192+
props.src === options.firstImageSrc
195193

196194
return <MarkdownImg {...props} priority={isFirstImage} />
197195
}
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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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
24+
.readdirSync(blogDir)
25+
.filter((file) => file.endsWith('.md'))
26+
27+
assert.ok(
28+
postFiles.length > 0,
29+
'expected to find blog posts in src/blog',
30+
)
31+
32+
for (const file of postFiles) {
33+
const raw = fs.readFileSync(path.join(blogDir, file), 'utf-8')
34+
const body = stripFrontmatter(raw)
35+
const hasBodyImage = /!\[[^\]]*\]\([^)]+\)/.test(body)
36+
37+
if (!hasBodyImage) continue
38+
39+
const document = parseSiteMarkdown(body)
40+
const firstImageSrc = findFirstImageSrc(document)
41+
42+
assert.notEqual(
43+
firstImageSrc,
44+
undefined,
45+
`${file} contains a body image but findFirstImageSrc() returned ` +
46+
`undefined - its first image is no longer reachable through plain ` +
47+
`paragraphs/headings, so the blog hero eager-load optimization is ` +
48+
`silently disabled for this post`,
49+
)
50+
}
51+
52+
console.log('blog-hero-image tests passed')

0 commit comments

Comments
 (0)