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
29 changes: 25 additions & 4 deletions src/components/markdown/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -19,27 +23,35 @@ 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 ?? ''),
[content, document],
)

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 = {
Expand Down Expand Up @@ -160,7 +172,9 @@ function TableElement({
)
}

function createMarkdownComponents(options: MarkdownRenderOptions = {}) {
function createMarkdownComponents(
options: MarkdownRenderOptions & { firstImageSrc?: string } = {},
) {
function MdCommentComponentWithOptions(
props: React.ComponentProps<typeof MdCommentComponent>,
) {
Expand All @@ -172,6 +186,13 @@ function createMarkdownComponents(options: MarkdownRenderOptions = {}) {
)
}

function ImgElement(props: React.ComponentProps<typeof MarkdownImg>) {
const isFirstImage =
options.firstImageSrc !== undefined && props.src === options.firstImageSrc

return <MarkdownImg {...props} priority={isFirstImage} />
}

return {
a: LinkElement,
code: CodeElement,
Expand All @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/components/markdown/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -76,6 +78,7 @@ export function MarkdownContent({
libraryVersion,
pagePath,
currentFramework,
eagerFirstImage,
}: MarkdownContentProps) {
const [canLoadCopyControls, setCanLoadCopyControls] = React.useState(false)

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

const renderedMarkdown = (
<Markdown document={markdown} preserveTabPanels={preserveTabPanels} />
<Markdown
document={markdown}
preserveTabPanels={preserveTabPanels}
eagerFirstImage={eagerFirstImage}
/>
)

const contentNode =
Expand Down
1 change: 1 addition & 0 deletions src/routes/blog.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ function BlogPost() {
branch={branch}
filePath={filePath}
containerRef={markdownContainerRef}
eagerFirstImage
/>
</div>
{isTocVisible && (
Expand Down
16 changes: 13 additions & 3 deletions src/ui/MarkdownImg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ import type { HTMLProps } from 'react'
import { getOptimizedImageUrl } from '~/utils/optimizedImage'
import { getPublicImageDimensions } from '~/utils/publicImageDimensions'

const DEFAULT_TRANSFORM_WIDTH = 1200

export type MarkdownImgProps = HTMLProps<HTMLImageElement> & {
priority?: boolean
}

export const MarkdownImg = React.memo(function MarkdownImg({
alt,
src,
className,
width,
height,
style,
priority,
children: _,
...props
}: HTMLProps<HTMLImageElement>) {
}: MarkdownImgProps) {
const sourceDimensions = src ? getPublicImageDimensions(src) : undefined
const providedWidth = parseDimension(width)
const providedHeight = parseDimension(height)
Expand All @@ -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 =
Expand Down Expand Up @@ -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"
/>
)
Expand Down
30 changes: 30 additions & 0 deletions src/utils/markdown/findFirstImageSrc.ts
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions src/utils/markdown/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { findFirstImageSrc } from './findFirstImageSrc'
export {
parseSiteMarkdown,
type MarkdownDocument,
Expand Down
47 changes: 47 additions & 0 deletions tests/blog-hero-image.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Loading