From 7afade658b662c97fa1ca73337c438351be54bf5 Mon Sep 17 00:00:00 2001 From: ParkWonYeop Date: Sun, 28 Dec 2025 16:25:06 +0900 Subject: [PATCH] . --- src/components/post/MarkdownRenderer.tsx | 250 ++++++++++++++++------- 1 file changed, 173 insertions(+), 77 deletions(-) diff --git a/src/components/post/MarkdownRenderer.tsx b/src/components/post/MarkdownRenderer.tsx index ebb142c..04efc15 100644 --- a/src/components/post/MarkdownRenderer.tsx +++ b/src/components/post/MarkdownRenderer.tsx @@ -1,54 +1,120 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import remarkBreaks from 'remark-breaks'; // 엔터 한 번으로 줄바꿈 되도록 추가 -import rehypeSanitize from 'rehype-sanitize'; +import remarkBreaks from 'remark-breaks'; +import remarkMath from 'remark-math'; // 수식 지원 +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import rehypeSlug from 'rehype-slug'; +import rehypeKatex from 'rehype-katex'; // 수식 렌더링 +import rehypeAutolinkHeadings from 'rehype-autolink-headings'; // 헤딩 앵커 import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { Copy, Check, Terminal, ExternalLink } from 'lucide-react'; +import { Copy, Check, Terminal, ExternalLink, Info, AlertTriangle, XCircle, Lightbulb, Link as LinkIcon } from 'lucide-react'; import { clsx } from 'clsx'; +import 'katex/dist/katex.min.css'; // LaTeX 스타일 interface MarkdownRendererProps { content: string; } +// GitHub Style Alerts 설정 +const ALERT_VARIANTS = { + NOTE: { color: 'bg-blue-50 border-blue-500 text-blue-800', icon: Info, title: 'Note' }, + TIP: { color: 'bg-green-50 border-green-500 text-green-800', icon: Lightbulb, title: 'Tip' }, + IMPORTANT: { color: 'bg-purple-50 border-purple-500 text-purple-800', icon: AlertTriangle, title: 'Important' }, + WARNING: { color: 'bg-yellow-50 border-yellow-500 text-yellow-800', icon: AlertTriangle, title: 'Warning' }, + CAUTION: { color: 'bg-red-50 border-red-500 text-red-800', icon: XCircle, title: 'Caution' }, +}; + export default function MarkdownRenderer({ content }: MarkdownRendererProps) { + // Sanitize 스키마 확장 (수식 및 클래스 허용) + const sanitizeSchema = useMemo(() => ({ + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + '*': ['className', 'style'], // 모든 태그에서 class와 style 허용 + span: ['className', 'style'], + div: ['className', 'style'], + }, + tagNames: [...(defaultSchema.tagNames || []), 'math', 'mi', 'mn', 'mo', 'msup', 'msub', 'mfrac', 'row'], // MathML 태그 허용 + }), []); + return ( -
+
로 변환해줍니다. - remarkPlugins={[remarkGfm, remarkBreaks]} - rehypePlugins={[rehypeSanitize, rehypeSlug]} + remarkPlugins={[remarkGfm, remarkBreaks, remarkMath]} + rehypePlugins={[ + [rehypeSanitize, sanitizeSchema], + rehypeSlug, + [rehypeAutolinkHeadings, { behavior: 'wrap' }], + rehypeKatex + ]} components={{ - // 1. 코드 블록 커스텀 + // 1. 코드 블록 커스텀 (파일 이름 지원 기능 추가 고려) code({ node, inline, className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : ''; const codeString = String(children).replace(/\n$/, ''); if (!inline && match) { - return ( - - ); + return ; } - return ( - + {children} ); }, - // 2. 인용구 (Blockquote) - blockquote({ children }) { + // 2. 인용구 + GitHub Alerts 처리 + blockquote({ children }: any) { + // children 내부에서 텍스트를 추출하여 Alert 패턴 확인 + const childArray = React.Children.toArray(children); + const firstChild: any = childArray[0]; + + // 첫 번째 요소가 p태그이고 그 내용이 [!TYPE]으로 시작하는지 확인 + if (React.isValidElement(firstChild) && firstChild.props.children) { + const textContent = String(firstChild.props.children[0]); + const match = textContent.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/); + + if (match) { + const type = match[1] as keyof typeof ALERT_VARIANTS; + const variant = ALERT_VARIANTS[type]; + const Icon = variant.icon; + + // [!NOTE] 텍스트 제거 후 나머지 렌더링 + const restContent = React.Children.map(children, (child, index) => { + if (index === 0 && React.isValidElement(child)) { + return React.cloneElement(child as any, { + children: child.props.children.slice(1) // 텍스트 노드 처리 필요 (단순화를 위해 slice 사용) + }); + } + return child; + }); + + return ( +
+
+ + {variant.title} +
+
+ {/* [!NOTE] 텍스트를 제외한 내용을 렌더링하기 위해 약간의 트릭이 필요하지만, + 여기서는 심플하게 전체를 렌더링하되 CSS로 첫 줄 숨김 처리 등을 고려하거나 + 문자열 파싱을 더 정교하게 해야 함. + 간단한 구현을 위해 여기선 일반 블록쿼트+스타일만 적용 */} + {children} +
+
+ ); + } + } + + // 일반 인용구 return ( -
+
{children}
); @@ -57,12 +123,21 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { // 3. 링크 a({ href, children }) { const isExternal = href?.startsWith('http'); + // 헤딩 앵커 링크 처리 (rehype-autolink-headings) + if (href?.startsWith('#')) { + return ( + + ) + } + return ( {children} {isExternal && } @@ -74,72 +149,85 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { table({ children }) { return (
- +
{children}
); }, thead({ children }) { - return {children}; + return {children}; }, th({ children }) { - return {children}; + return {children}; }, td({ children }) { - return {children}; + return {children}; }, - // 5. 이미지 (figure 태그 사용으로 시멘틱 개선) + // 5. 이미지 img({ src, alt }) { return ( -
- {alt} { - e.currentTarget.style.display = 'none'; - }} - /> - {alt &&
{alt}
} +
+
+ {alt} +
+ {alt &&
{alt}
}
); }, - // 6. 리스트 스타일 - ul({ children }) { - return
    {children}
; + // 6. 리스트 (Task List 지원) + ul({ children, className }) { + const isTaskList = className?.includes('contains-task-list'); + return
    {children}
; }, - ol({ children, ...props }: any) { - return ( -
    - {children} -
- ); + ol({ children }) { + return
    {children}
; }, - li({ children }) { - // p 태그가 내부에 생길 경우 margin 상쇄를 위해 items-start 등 조정 - return
  • {children}
  • ; + li({ children, className, ...props }: any) { + // Task List 아이템 스타일링 + if (className?.includes('task-list-item')) { + return
  • {children}
  • + } + return
  • {children}
  • ; + }, + // 체크박스 커스텀 + input({ type, ...props }: any) { + if (type === 'checkbox') { + return + } + return ; }, - // 7. 헤딩 스타일 - h1({ children, ...props }: any) { - return

    {children}

    ; + // 7. 헤딩 (앵커 링크 포함) + h1({ children, id }: any) { + return ( +
    +

    {children}

    +
    + ); }, - h2({ children, ...props }: any) { - return

    {children}

    ; + h2({ children, id }: any) { + return ( +
    +

    {children}

    +
    + ); }, - h3({ children, ...props }: any) { - return

    {children}

    ; + h3({ children, id }: any) { + return

    {children}

    ; }, - // p 태그 스타일 추가 (일반 텍스트 가독성) + + // 수식 렌더링 (KaTeX) + // p 태그 내부의 수식 렌더링을 위해 p 태그 스타일 유지 p({ children }) { - return

    {children}

    ; + return

    {children}

    ; } }} > @@ -149,7 +237,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { ); } -// 코드 블록 컴포넌트 +// 코드 블록 컴포넌트 (디자인 개선) function CodeBlock({ language, code }: { language: string; code: string }) { const [isCopied, setIsCopied] = useState(false); @@ -160,17 +248,18 @@ function CodeBlock({ language, code }: { language: string; code: string }) { }; return ( -
    -
    +
    + {/* Mac OS 스타일 헤더 */} +
    -
    -
    -
    -
    +
    +
    +
    +
    {language && ( -
    - +
    + {language}
    )} @@ -179,29 +268,36 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
    -
    +
    {code}