From c01aa68dcfe622304cc6e28a616a6238a074ac14 Mon Sep 17 00:00:00 2001 From: ParkWonYeop Date: Sun, 28 Dec 2025 16:32:40 +0900 Subject: [PATCH] . --- src/components/post/MarkdownRenderer.tsx | 228 +++++++++++++---------- 1 file changed, 129 insertions(+), 99 deletions(-) diff --git a/src/components/post/MarkdownRenderer.tsx b/src/components/post/MarkdownRenderer.tsx index 04efc15..82a7ba8 100644 --- a/src/components/post/MarkdownRenderer.tsx +++ b/src/components/post/MarkdownRenderer.tsx @@ -4,16 +4,16 @@ import React, { useState, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; -import remarkMath from 'remark-math'; // 수식 지원 +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 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, Info, AlertTriangle, XCircle, Lightbulb, Link as LinkIcon } from 'lucide-react'; import { clsx } from 'clsx'; -import 'katex/dist/katex.min.css'; // LaTeX 스타일 +import 'katex/dist/katex.min.css'; interface MarkdownRendererProps { content: string; @@ -29,16 +29,17 @@ const ALERT_VARIANTS = { }; export default function MarkdownRenderer({ content }: MarkdownRendererProps) { - // Sanitize 스키마 확장 (수식 및 클래스 허용) + // Sanitize 스키마 확장 + // 주의: 모든 태그에 style과 className을 허용하는 것은 보안상(XSS) 주의가 필요합니다. const sanitizeSchema = useMemo(() => ({ ...defaultSchema, attributes: { ...defaultSchema.attributes, - '*': ['className', 'style'], // 모든 태그에서 class와 style 허용 + '*': ['className', 'style'], span: ['className', 'style'], div: ['className', 'style'], }, - tagNames: [...(defaultSchema.tagNames || []), 'math', 'mi', 'mn', 'mo', 'msup', 'msub', 'mfrac', 'row'], // MathML 태그 허용 + tagNames: [...(defaultSchema.tagNames || []), 'math', 'mi', 'mn', 'mo', 'msup', 'msub', 'mfrac', 'row'], }), []); return ( @@ -52,15 +53,17 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { 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 ; + // [수정] inline이 아니면 언어가 없어도 CodeBlock으로 처리 (기본값: text) + if (!inline) { + return ; } + return ( {children} @@ -68,50 +71,78 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { ); }, - // 2. 인용구 + GitHub Alerts 처리 + // 2. 인용구 + GitHub Alerts 처리 (TypeScript 오류 수정 및 로직 개선) 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)\]/); + const firstChild = childArray[0]; + let alertType: keyof typeof ALERT_VARIANTS | null = null; - 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 사용) - }); + // [수정] 타입스크립트 안전성 확보 및 파싱 로직 강화 + // React.isValidElement로 체크 후, ReactElement로 단언(assertion)하여 props 접근 + if (React.isValidElement(firstChild)) { + const element = firstChild as React.ReactElement; + + // 보통 blockquote > p 구조임 + if (element.type === 'p' && element.props.children) { + // p 태그의 첫 번째 자식이 텍스트인지 확인 + const content = element.props.children; + const firstText = Array.isArray(content) ? content[0] : content; + + if (typeof firstText === 'string') { + const match = firstText.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/); + if (match) { + alertType = match[1] as keyof typeof ALERT_VARIANTS; } - return child; - }); - - return ( -
-
- - {variant.title} -
-
- {/* [!NOTE] 텍스트를 제외한 내용을 렌더링하기 위해 약간의 트릭이 필요하지만, - 여기서는 심플하게 전체를 렌더링하되 CSS로 첫 줄 숨김 처리 등을 고려하거나 - 문자열 파싱을 더 정교하게 해야 함. - 간단한 구현을 위해 여기선 일반 블록쿼트+스타일만 적용 */} - {children} -
-
- ); + } } } - + + // Alert 타입이 감지되면 스타일링된 컴포넌트 반환 + if (alertType) { + const variant = ALERT_VARIANTS[alertType]; + const Icon = variant.icon; + + // [수정] [!NOTE] 텍스트를 안전하게 제거 + const restContent = React.Children.map(children, (child, index) => { + if (index === 0 && React.isValidElement(child)) { + const element = child as React.ReactElement; + const content = element.props.children; + + // 내용이 배열일 수도 있고 문자열일 수도 있음 + if (Array.isArray(content)) { + // 첫 번째 텍스트 노드에서 [!NOTE] 부분만 제거 + const [first, ...rest] = content; + if (typeof first === 'string') { + const newText = first.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s?/, ''); + return React.cloneElement(element, { + ...element.props, + children: [newText, ...rest] + }); + } + } else if (typeof content === 'string') { + const newText = content.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s?/, ''); + return React.cloneElement(element, { + ...element.props, + children: newText + }); + } + } + return child; + }); + + return ( +
+
+ + {variant.title} +
+
+ {restContent} +
+
+ ); + } + // 일반 인용구 return (
@@ -123,18 +154,17 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { // 3. 링크 a({ href, children }) { const isExternal = href?.startsWith('http'); - // 헤딩 앵커 링크 처리 (rehype-autolink-headings) if (href?.startsWith('#')) { - return ( - - ) + return ( + + ) } return ( -
- {alt} + />
{alt &&
{alt}
} @@ -191,43 +221,46 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { return
    {children}
; }, li({ children, className, ...props }: any) { - // Task List 아이템 스타일링 if (className?.includes('task-list-item')) { - return
  • {children}
  • + return
  • {children}
  • } return
  • {children}
  • ; }, - // 체크박스 커스텀 + // [수정] 체크박스 ReadOnly 속성 추가 input({ type, ...props }: any) { - if (type === 'checkbox') { - return - } - return ; + if (type === 'checkbox') { + return ( + + ) + } + return ; }, - // 7. 헤딩 (앵커 링크 포함) + // 7. 헤딩 h1({ children, id }: any) { return ( -
    -

    {children}

    -
    +
    +

    {children}

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

    {children}

    -
    +
    +

    {children}

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

    {children}

    ; + return

    {children}

    ; }, - - // 수식 렌더링 (KaTeX) - // p 태그 내부의 수식 렌더링을 위해 p 태그 스타일 유지 p({ children }) { - return

    {children}

    ; + return

    {children}

    ; } }} > @@ -237,7 +270,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) { ); } -// 코드 블록 컴포넌트 (디자인 개선) +// 코드 블록 컴포넌트 function CodeBlock({ language, code }: { language: string; code: string }) { const [isCopied, setIsCopied] = useState(false); @@ -249,7 +282,6 @@ function CodeBlock({ language, code }: { language: string; code: string }) { return (
    - {/* Mac OS 스타일 헤더 */}
    @@ -257,20 +289,18 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
    - {language && ( -
    - - {language} -
    - )} +
    + + {language} +
    - +
    - +