본문으로 건너뛰기

Markdown 렌더링

이 가이드에서는 TanStack Start 애플리케이션에서 Markdown 콘텐츠를 가져와 렌더링하는 두 가지 방법을 다룹니다:

  1. 빌드 시점 로딩(예: 블로그 게시물)을 위해 content-collections을 사용하는 정적 Markdown
  2. 런타임에 GitHub 또는 임의의 원격 소스에서 가져오는 동적 Markdown

두 방법 모두 unified 생태계를 사용하는 공통 렌더링 파이프라인을 공유합니다.

Markdown 프로세서 설정

두 접근 방식 모두 동일한 Markdown-HTML 처리 파이프라인을 사용합니다. 먼저 필요한 의존성을 설치합니다:

npm install unified remark-parse remark-gfm remark-rehype rehype-raw rehype-slug rehype-autolink-headings rehype-stringify shiki html-react-parser gray-matter

Markdown 프로세서 유틸리티를 생성합니다:

// src/utils/markdown.ts
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'

export type MarkdownHeading = {
id: string
text: string
level: number
}

export type MarkdownResult = {
markup: string
headings: Array<MarkdownHeading>
}

export async function renderMarkdown(content: string): Promise<MarkdownResult> {
const headings: Array<MarkdownHeading> = []

const result = await unified()
.use(remarkParse) // Parse markdown
.use(remarkGfm) // Support GitHub Flavored Markdown
.use(remarkRehype, { allowDangerousHtml: true }) // Convert to HTML AST
.use(rehypeRaw) // Process raw HTML in markdown
.use(rehypeSlug) // Add IDs to headings
.use(rehypeAutolinkHeadings, {
behavior: 'wrap',
properties: { className: ['anchor'] },
})
.use(() => (tree) => {
// Extract headings for table of contents
const { visit } = require('unist-util-visit')
const { toString } = require('hast-util-to-string')

visit(tree, 'element', (node: any) => {
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName)) {
headings.push({
id: node.properties?.id || '',
text: toString(node),
level: parseInt(node.tagName.charAt(1), 10),
})
}
})
})
.use(rehypeStringify) // Serialize to HTML string
.process(content)

return {
markup: String(result),
headings,
}
}

Markdown 컴포넌트 생성

사용자 지정 요소 처리와 함께 처리된 HTML을 렌더링하는 React 컴포넌트를 생성합니다:

// src/components/Markdown.tsx
import parse, { type HTMLReactParserOptions, Element } from 'html-react-parser'
import { renderMarkdown, type MarkdownResult } from '~/utils/markdown'

type MarkdownProps = {
content: string
className?: string
}

export function Markdown({ content, className }: MarkdownProps) {
const [result, setResult] = useState<MarkdownResult | null>(null)

useEffect(() => {
renderMarkdown(content).then(setResult)
}, [content])

if (!result) {
return <div className={className}>Loading...</div>
}

const options: HTMLReactParserOptions = {
replace: (domNode) => {
if (domNode instanceof Element) {
// Customize rendering of specific elements
if (domNode.name === 'a') {
// Handle links
const href = domNode.attribs.href
if (href?.startsWith('/')) {
// Internal link - use your router's Link component
return (
<Link to={href}>{domToReact(domNode.children, options)}</Link>
)
}
}

if (domNode.name === 'img') {
// Add lazy loading to images
return (
<img
{...domNode.attribs}
loading="lazy"
className="rounded-lg shadow-md"
/>
)
}
}
},
}

return <div className={className}>{parse(result.markup, options)}</div>
}

방법 1: content-collections를 사용한 정적 Markdown

content-collections 패키지는 저장소에 포함되는 블로그 게시물과 같은 정적 콘텐츠에 적합합니다. 빌드 시점에 Markdown 파일을 처리하고 콘텐츠에 타입 안전하게 접근할 수 있도록 합니다.

설치

npm install @content-collections/core @content-collections/vite

구성

프로젝트 루트에 content-collections.ts 파일을 생성합니다:

// content-collections.ts
import { defineCollection, defineConfig } from '@content-collections/core'
import matter from 'gray-matter'

function extractFrontMatter(content: string) {
const { data, content: body, excerpt } = matter(content, { excerpt: true })
return { data, body, excerpt: excerpt || '' }
}

const posts = defineCollection({
name: 'posts',
directory: './src/blog', // Directory containing your .md files
include: '*.md',
schema: (z) => ({
title: z.string(),
published: z.string().date(),
description: z.string().optional(),
authors: z.string().array(),
}),
transform: ({ content, ...post }) => {
const frontMatter = extractFrontMatter(content)

// Extract header image (first image in the document)
const headerImageMatch = content.match(/!\[([^\]]*)\]\(([^)]+)\)/)
const headerImage = headerImageMatch ? headerImageMatch[2] : undefined

return {
...post,
slug: post._meta.path,
excerpt: frontMatter.excerpt,
description: frontMatter.data.description,
headerImage,
content: frontMatter.body,
}
},
})

export default defineConfig({
collections: [posts],
})

Vite 통합

Vite 구성에 content-collections 플러그인을 추가합니다:

// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
import contentCollections from '@content-collections/vite'

export default defineConfig({
vite: {
plugins: [contentCollections()],
},
})

블로그 게시물 생성

지정한 디렉터리에 Markdown 파일을 생성합니다:

## <!-- src/blog/hello-world.md -->

title: Hello World
published: 2024-01-15
authors:

- Jane Doe
description: My first blog post

---

![Hero Image](/images/hero.jpg)

Welcome to my blog! This is my first post.

## Getting Started

Here's some content with **bold** and _italic_ text.

```javascript
console.log('Hello, world!')
```

컬렉션 사용

생성된 컬렉션을 통해 게시물에 접근합니다:

// src/routes/blog.index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { allPosts } from 'content-collections'

export const Route = createFileRoute('/blog/')({
component: BlogIndex,
})

function BlogIndex() {
// Posts are sorted by published date
const sortedPosts = allPosts.sort(
(a, b) => new Date(b.published).getTime() - new Date(a.published).getTime(),
)

return (
<div>
<h1>Blog</h1>
<ul>
{sortedPosts.map((post) => (
<li key={post.slug}>
<Link to="/blog/$slug" params={{ slug: post.slug }}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<span>{post.published}</span>
</Link>
</li>
))}
</ul>
</div>
)
}

단일 게시물 렌더링

// src/routes/blog.$slug.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { allPosts } from 'content-collections'
import { Markdown } from '~/components/Markdown'

export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => {
const post = allPosts.find((p) => p.slug === params.slug)
if (!post) {
throw notFound()
}
return post
},
component: BlogPost,
})

function BlogPost() {
const post = Route.useLoaderData()

return (
<article>
<header>
<h1>{post.title}</h1>
<p>
By {post.authors.join(', ')} on {post.published}
</p>
</header>
<Markdown content={post.content} className="prose" />
</article>
)
}

방법 2: 원격 소스의 동적 Markdown

GitHub 저장소처럼 외부에 저장된 콘텐츠는 서버 함수를 사용하여 Markdown을 동적으로 가져와 렌더링할 수 있습니다.

가져오기 유틸리티 생성

// src/utils/docs.server.ts
import { createServerFn } from '@tanstack/react-start'
import matter from 'gray-matter'

type FetchDocsParams = {
repo: string // e.g., 'tanstack/router'
branch: string // e.g., 'main'
filePath: string // e.g., 'docs/guide/getting-started.md'
}

export const fetchDocs = createServerFn({ method: 'GET' })
.validator((params: FetchDocsParams) => params)
.handler(async ({ data: { repo, branch, filePath } }) => {
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`

const response = await fetch(url, {
headers: {
// Add GitHub token for private repos or higher rate limits
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
})

if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}

const rawContent = await response.text()
const { data: frontmatter, content } = matter(rawContent)

return {
frontmatter,
content,
filePath,
}
})

캐시 헤더 추가

프로덕션 환경에서는 적절한 캐시 헤더를 추가합니다:

export const fetchDocs = createServerFn({ method: 'GET' })
.validator((params: FetchDocsParams) => params)
.handler(async ({ data: { repo, branch, filePath }, context }) => {
// Set cache headers for CDN caching
context.response.headers.set(
'Cache-Control',
'public, max-age=0, must-revalidate',
)
context.response.headers.set(
'CDN-Cache-Control',
'max-age=300, stale-while-revalidate=300',
)

// ... fetch logic
})

라우트에서 동적 Markdown 사용

// src/routes/docs.$path.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fetchDocs } from '~/utils/docs.server'
import { Markdown } from '~/components/Markdown'

export const Route = createFileRoute('/docs/$path')({
loader: async ({ params }) => {
return fetchDocs({
data: {
repo: 'your-org/your-repo',
branch: 'main',
filePath: `docs/${params.path}.md`,
},
})
},
component: DocsPage,
})

function DocsPage() {
const { frontmatter, content } = Route.useLoaderData()

return (
<article>
<h1>{frontmatter.title}</h1>
<Markdown content={content} className="prose" />
</article>
)
}

디렉터리 콘텐츠 가져오기

GitHub 디렉터리에서 내비게이션을 구성하려면 다음과 같이 합니다:

// src/utils/docs.server.ts
type GitHubContent = {
name: string
path: string
type: 'file' | 'dir'
}

export const fetchRepoContents = createServerFn({ method: 'GET' })
.validator((params: { repo: string; branch: string; path: string }) => params)
.handler(async ({ data: { repo, branch, path } }) => {
const url = `https://api.github.com/repos/${repo}/contents/${path}?ref=${branch}`

const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
})

if (!response.ok) {
throw new Error(`Failed to fetch contents: ${response.status}`)
}

const contents: Array<GitHubContent> = await response.json()

return contents
.filter((item) => item.type === 'file' && item.name.endsWith('.md'))
.map((item) => ({
name: item.name.replace('.md', ''),
path: item.path,
}))
})

Shiki로 구문 강조 추가

코드 블록에 구문 강조를 적용하려면 Shiki를 Markdown 프로세서에 통합합니다:

// src/utils/markdown.ts
import { codeToHtml } from 'shiki'

// Process code blocks after parsing
export async function highlightCode(
code: string,
language: string,
): Promise<string> {
return codeToHtml(code, {
lang: language,
themes: {
light: 'github-light',
dark: 'tokyo-night',
},
})
}

그런 다음 Markdown 컴포넌트에서 코드 블록을 처리합니다:

// In your Markdown component's replace function
if (domNode.name === 'pre') {
const codeElement = domNode.children.find(
(child) => child instanceof Element && child.name === 'code',
)
if (codeElement) {
const className = codeElement.attribs.class || ''
const language = className.replace('language-', '') || 'text'
const code = getText(codeElement)

return <CodeBlock code={code} language={language} />
}
}

요약

접근 방식가장 적합한 용도장점단점
content-collections앱에 번들되는 블로그 게시물, 정적 문서타입 안전성, 빌드 시 처리, 빠른 런타임콘텐츠 업데이트 시 다시 빌드해야 함
동적 가져오기외부 문서, 자주 업데이트되는 콘텐츠항상 최신 상태, 다시 빌드할 필요 없음런타임 오버헤드, 오류 처리 필요

콘텐츠 업데이트 빈도와 배포 워크플로에 가장 적합한 접근 방식을 선택합니다. 하이브리드 시나리오에서는 동일한 애플리케이션에서 두 방법을 모두 사용할 수 있습니다.