Skip to content

Commit 2a10dee

Browse files
committed
markdown perf
1 parent c246f0d commit 2a10dee

File tree

5 files changed

+291
-41
lines changed

5 files changed

+291
-41
lines changed

src/components/Markdown.tsx

Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import * as React from 'react'
22
import { MarkdownLink } from '~/components/MarkdownLink'
33
import type { HTMLProps } from 'react'
4-
import { createHighlighter as shikiGetHighlighter } from 'shiki/bundle-web.mjs'
4+
import { createHighlighter, type HighlighterGeneric } from 'shiki/bundle/web'
55
import { transformerNotationDiff } from '@shikijs/transformers'
66
import parse, {
77
attributesToProps,
88
domToReact,
99
Element,
1010
HTMLReactParserOptions,
1111
} from 'html-react-parser'
12-
import mermaid from 'mermaid'
12+
import type { Mermaid } from 'mermaid'
1313
import { useToast } from '~/components/ToastProvider'
1414
import { twMerge } from 'tailwind-merge'
1515
import { useMarkdownHeadings } from '~/components/MarkdownHeadingContext'
@@ -92,17 +92,29 @@ const markdownComponents: Record<string, React.FC> = {
9292
<iframe {...props} className="w-full" title="Embedded Content" />
9393
),
9494
// eslint-disable-next-line @typescript-eslint/no-unused-vars
95-
img: ({ children, alt, ...props }: HTMLProps<HTMLImageElement>) => (
96-
<img
97-
{...props}
98-
alt={alt ?? ''}
99-
className={`max-w-full h-auto rounded-lg shadow-md ${
100-
props.className ?? ''
101-
}`}
102-
loading="lazy"
103-
decoding="async"
104-
/>
105-
),
95+
img: ({ children, alt, src, ...props }: HTMLProps<HTMLImageElement>) => {
96+
// Use Netlify Image CDN for local images
97+
const optimizedSrc =
98+
src &&
99+
!src.startsWith('http') &&
100+
!src.startsWith('data:') &&
101+
!src.endsWith('.svg')
102+
? `/.netlify/images?url=${encodeURIComponent(src)}&w=800&q=80`
103+
: src
104+
105+
return (
106+
<img
107+
{...props}
108+
src={optimizedSrc}
109+
alt={alt ?? ''}
110+
className={`max-w-full h-auto rounded-lg shadow-md ${
111+
props.className ?? ''
112+
}`}
113+
loading="lazy"
114+
decoding="async"
115+
/>
116+
)
117+
},
106118
}
107119

108120
export function extractPreAttributes(html: string): {
@@ -127,7 +139,16 @@ export function extractPreAttributes(html: string): {
127139

128140
const genSvgMap = new Map<string, string>()
129141

130-
mermaid.initialize({ startOnLoad: true, securityLevel: 'loose' })
142+
// Lazy load mermaid only when needed
143+
let mermaidInstance: Mermaid | null = null
144+
async function getMermaid(): Promise<Mermaid> {
145+
if (!mermaidInstance) {
146+
const { default: mermaid } = await import('mermaid')
147+
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' })
148+
mermaidInstance = mermaid
149+
}
150+
return mermaidInstance
151+
}
131152

132153
export function CodeBlock({
133154
isEmbedded,
@@ -174,12 +195,17 @@ export function CodeBlock({
174195
;(async () => {
175196
const themes = ['github-light', 'tokyo-night']
176197

198+
// Normalize language name
199+
const normalizedLang = LANG_ALIASES[lang] || lang
200+
const effectiveLang =
201+
normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang
202+
177203
const highlighter = await getHighlighter(lang, themes)
178204

179205
const htmls = await Promise.all(
180206
themes.map(async (theme) => {
181207
const output = highlighter.codeToHtml(code, {
182-
lang: lang === 'mermaid' ? 'plaintext' : lang,
208+
lang: effectiveLang,
183209
theme,
184210
transformers: [transformerNotationDiff()],
185211
})
@@ -188,6 +214,7 @@ export function CodeBlock({
188214
const preAttributes = extractPreAttributes(output)
189215
let svgHtml = genSvgMap.get(code || '')
190216
if (!svgHtml) {
217+
const mermaid = await getMermaid()
191218
const { svg } = await mermaid.render('foo', code || '')
192219
genSvgMap.set(code || '', svg)
193220
svgHtml = svg
@@ -278,31 +305,66 @@ const cache = <T extends (...args: any[]) => any>(fn: T) => {
278305
}
279306
}
280307

281-
const highlighterPromise = shikiGetHighlighter({} as any)
308+
// Core languages to bundle (most commonly used in TanStack docs)
309+
const CORE_LANGS = [
310+
'typescript',
311+
'javascript',
312+
'tsx',
313+
'jsx',
314+
'bash',
315+
'json',
316+
'html',
317+
'css',
318+
'markdown',
319+
'plaintext',
320+
] as const
321+
322+
// Language aliases mapping
323+
const LANG_ALIASES: Record<string, string> = {
324+
ts: 'typescript',
325+
js: 'javascript',
326+
sh: 'bash',
327+
shell: 'bash',
328+
console: 'bash',
329+
zsh: 'bash',
330+
md: 'markdown',
331+
txt: 'plaintext',
332+
text: 'plaintext',
333+
}
282334

283-
const getHighlighter = cache(async (language: string, themes: string[]) => {
284-
const highlighter = await highlighterPromise
335+
// Lazy highlighter initialization
336+
let highlighterPromise: Promise<HighlighterGeneric<any, any>> | null = null
285337

286-
const loadedLanguages = highlighter.getLoadedLanguages()
287-
const loadedThemes = highlighter.getLoadedThemes()
288-
289-
const promises = []
290-
if (!loadedLanguages.includes(language as any)) {
291-
promises.push(
292-
highlighter.loadLanguage(
293-
language === 'mermaid' ? 'plaintext' : (language as any),
294-
),
295-
)
338+
async function getShikiHighlighter() {
339+
if (!highlighterPromise) {
340+
highlighterPromise = createHighlighter({
341+
themes: ['github-light', 'tokyo-night'],
342+
langs: CORE_LANGS as unknown as string[],
343+
})
296344
}
345+
return highlighterPromise
346+
}
297347

298-
for (const theme of themes) {
299-
if (!loadedThemes.includes(theme as any)) {
300-
promises.push(highlighter.loadTheme(theme as any))
348+
const getHighlighter = cache(async (language: string, _themes: string[]) => {
349+
const highlighter = await getShikiHighlighter()
350+
351+
// Normalize language name
352+
const normalizedLang = LANG_ALIASES[language] || language
353+
const langToLoad = normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang
354+
355+
const loadedLanguages = highlighter.getLoadedLanguages()
356+
357+
// Only load language if not already loaded
358+
if (!loadedLanguages.includes(langToLoad as any)) {
359+
try {
360+
// Load language using shiki's built-in loader (works with bundle/web)
361+
await highlighter.loadLanguage(langToLoad as any)
362+
} catch {
363+
// Fallback to plaintext if language not found
364+
console.warn(`Shiki: Language "${langToLoad}" not found, using plaintext`)
301365
}
302366
}
303367

304-
await Promise.all(promises)
305-
306368
return highlighter
307369
})
308370

src/components/NetlifyImage.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as React from 'react'
2+
3+
type NetlifyImageProps = Omit<
4+
React.ImgHTMLAttributes<HTMLImageElement>,
5+
'src'
6+
> & {
7+
src: string
8+
width?: number
9+
height?: number
10+
quality?: number
11+
format?: 'avif' | 'webp' | 'jpg' | 'png' | 'gif'
12+
fit?: 'contain' | 'cover' | 'fill'
13+
position?: 'top' | 'bottom' | 'left' | 'right' | 'center'
14+
}
15+
16+
/**
17+
* Optimized image component using Netlify Image CDN.
18+
* Automatically transforms images on-demand with:
19+
* - Format conversion (auto webp/avif based on browser support)
20+
* - Resizing to specified dimensions
21+
* - Quality optimization
22+
* - Edge caching
23+
*
24+
* @see https://docs.netlify.com/build/image-cdn/overview/
25+
*/
26+
export function NetlifyImage({
27+
src,
28+
width,
29+
height,
30+
quality = 75,
31+
format,
32+
fit,
33+
position,
34+
alt = '',
35+
loading = 'lazy',
36+
decoding = 'async',
37+
...props
38+
}: NetlifyImageProps) {
39+
const optimizedSrc = React.useMemo(() => {
40+
// Skip optimization for external URLs, data URIs, or SVGs
41+
if (
42+
src.startsWith('http') ||
43+
src.startsWith('data:') ||
44+
src.endsWith('.svg')
45+
) {
46+
return src
47+
}
48+
49+
const params = new URLSearchParams()
50+
params.set('url', src)
51+
52+
if (width) params.set('w', String(width))
53+
if (height) params.set('h', String(height))
54+
if (quality !== 75) params.set('q', String(quality))
55+
if (format) params.set('fm', format)
56+
if (fit) params.set('fit', fit)
57+
if (position) params.set('position', position)
58+
59+
return `/.netlify/images?${params.toString()}`
60+
}, [src, width, height, quality, format, fit, position])
61+
62+
return (
63+
<img
64+
src={optimizedSrc}
65+
width={width}
66+
height={height}
67+
alt={alt}
68+
loading={loading}
69+
decoding={decoding}
70+
{...props}
71+
/>
72+
)
73+
}

src/components/SimpleMarkdown.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as React from 'react'
2+
import { MarkdownLink } from '~/components/MarkdownLink'
3+
import type { HTMLProps } from 'react'
4+
import parse, {
5+
attributesToProps,
6+
domToReact,
7+
Element,
8+
HTMLReactParserOptions,
9+
} from 'html-react-parser'
10+
import { renderMarkdown } from '~/utils/markdown'
11+
12+
/**
13+
* Lightweight markdown renderer for simple content like excerpts.
14+
* Does NOT include syntax highlighting (shiki) or diagram rendering (mermaid).
15+
* Use the full <Markdown> component for documentation with code blocks.
16+
*/
17+
18+
const markdownComponents: Record<string, React.FC<any>> = {
19+
a: MarkdownLink,
20+
code: function Code({ className, ...rest }: HTMLProps<HTMLElement>) {
21+
return (
22+
<span
23+
className={`border border-gray-500/20 bg-gray-500/10 rounded px-1 py-0.5${
24+
className ? ` ${className}` : ''
25+
}`}
26+
{...rest}
27+
/>
28+
)
29+
},
30+
pre: function Pre({ children, ...rest }: HTMLProps<HTMLPreElement>) {
31+
return (
32+
<pre
33+
className="bg-gray-100 dark:bg-gray-800 rounded p-2 overflow-x-auto text-sm"
34+
{...rest}
35+
>
36+
{children}
37+
</pre>
38+
)
39+
},
40+
img: ({ children, alt, src, ...props }: HTMLProps<HTMLImageElement>) => {
41+
// Use Netlify Image CDN for local images
42+
const optimizedSrc =
43+
src &&
44+
!src.startsWith('http') &&
45+
!src.startsWith('data:') &&
46+
!src.endsWith('.svg')
47+
? `/.netlify/images?url=${encodeURIComponent(src)}&w=800&q=80`
48+
: src
49+
50+
return (
51+
<img
52+
{...props}
53+
src={optimizedSrc}
54+
alt={alt ?? ''}
55+
className={`max-w-full h-auto rounded-lg shadow-md ${props.className ?? ''}`}
56+
loading="lazy"
57+
decoding="async"
58+
/>
59+
)
60+
},
61+
}
62+
63+
const options: HTMLReactParserOptions = {
64+
replace: (domNode) => {
65+
if (domNode instanceof Element && domNode.attribs) {
66+
const replacer = markdownComponents[domNode.name]
67+
if (replacer) {
68+
return React.createElement(
69+
replacer,
70+
attributesToProps(domNode.attribs),
71+
domToReact(domNode.children as any, options),
72+
)
73+
}
74+
}
75+
return
76+
},
77+
}
78+
79+
type SimpleMarkdownProps = {
80+
rawContent?: string
81+
htmlMarkup?: string
82+
}
83+
84+
export function SimpleMarkdown({ rawContent, htmlMarkup }: SimpleMarkdownProps) {
85+
const rendered = React.useMemo(() => {
86+
if (rawContent) {
87+
return renderMarkdown(rawContent)
88+
}
89+
90+
if (htmlMarkup) {
91+
return { markup: htmlMarkup, headings: [] }
92+
}
93+
94+
return { markup: '', headings: [] }
95+
}, [rawContent, htmlMarkup])
96+
97+
return React.useMemo(() => {
98+
if (!rendered.markup) {
99+
return null
100+
}
101+
102+
return parse(rendered.markup, options)
103+
}, [rendered.markup])
104+
}

src/routes/_libraries/blog.index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Link, createFileRoute } from '@tanstack/react-router'
22

33
import { formatAuthors, getPublishedPosts } from '~/utils/blog'
4-
import { Markdown } from '~/components/Markdown'
4+
import { SimpleMarkdown } from '~/components/SimpleMarkdown'
55
import { format } from 'date-fns'
66
import { Footer } from '~/components/Footer'
77
import { PostNotFound } from './blog'
@@ -93,7 +93,7 @@ function BlogIndex() {
9393
<div
9494
className={`text-sm mt-4 text-black dark:text-white leading-7`}
9595
>
96-
<Markdown rawContent={excerpt || ''} />
96+
<SimpleMarkdown rawContent={excerpt || ''} />
9797
</div>
9898
</div>
9999
<div>

0 commit comments

Comments
 (0)