.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 42s

This commit is contained in:
ParkWonYeop
2025-12-28 16:32:40 +09:00
parent 7afade658b
commit c01aa68dcf

View File

@@ -4,16 +4,16 @@ import React, { useState, useMemo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks'; import remarkBreaks from 'remark-breaks';
import remarkMath from 'remark-math'; // 수식 지원 import remarkMath from 'remark-math';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeSlug from 'rehype-slug'; import rehypeSlug from 'rehype-slug';
import rehypeKatex from 'rehype-katex'; // 수식 렌더링 import rehypeKatex from 'rehype-katex';
import rehypeAutolinkHeadings from 'rehype-autolink-headings'; // 헤딩 앵커 import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; 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 { Copy, Check, Terminal, ExternalLink, Info, AlertTriangle, XCircle, Lightbulb, Link as LinkIcon } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import 'katex/dist/katex.min.css'; // LaTeX 스타일 import 'katex/dist/katex.min.css';
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; content: string;
@@ -29,16 +29,17 @@ const ALERT_VARIANTS = {
}; };
export default function MarkdownRenderer({ content }: MarkdownRendererProps) { export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
// Sanitize 스키마 확장 (수식 및 클래스 허용) // Sanitize 스키마 확장
// 주의: 모든 태그에 style과 className을 허용하는 것은 보안상(XSS) 주의가 필요합니다.
const sanitizeSchema = useMemo(() => ({ const sanitizeSchema = useMemo(() => ({
...defaultSchema, ...defaultSchema,
attributes: { attributes: {
...defaultSchema.attributes, ...defaultSchema.attributes,
'*': ['className', 'style'], // 모든 태그에서 class와 style 허용 '*': ['className', 'style'],
span: ['className', 'style'], span: ['className', 'style'],
div: ['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 ( return (
@@ -52,15 +53,17 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
rehypeKatex rehypeKatex
]} ]}
components={{ components={{
// 1. 코드 블록 커스텀 (파일 이름 지원 기능 추가 고려) // 1. 코드 블록 커스텀
code({ node, inline, className, children, ...props }: any) { code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : ''; const language = match ? match[1] : '';
const codeString = String(children).replace(/\n$/, ''); const codeString = String(children).replace(/\n$/, '');
if (!inline && match) { // [수정] inline이 아니면 언어가 없어도 CodeBlock으로 처리 (기본값: text)
return <CodeBlock language={language} code={codeString} />; if (!inline) {
return <CodeBlock language={language || 'text'} code={codeString} />;
} }
return ( return (
<code className="bg-gray-100 text-pink-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words border border-gray-200" {...props}> <code className="bg-gray-100 text-pink-500 px-1.5 py-0.5 rounded-md text-[0.9em] font-mono font-medium mx-1 break-words border border-gray-200" {...props}>
{children} {children}
@@ -68,50 +71,78 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
); );
}, },
// 2. 인용구 + GitHub Alerts 처리 // 2. 인용구 + GitHub Alerts 처리 (TypeScript 오류 수정 및 로직 개선)
blockquote({ children }: any) { blockquote({ children }: any) {
// children 내부에서 텍스트를 추출하여 Alert 패턴 확인
const childArray = React.Children.toArray(children); const childArray = React.Children.toArray(children);
const firstChild: any = childArray[0]; const firstChild = childArray[0];
let alertType: keyof typeof ALERT_VARIANTS | null = null;
// 첫 번째 요소가 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; // React.isValidElement로 체크 후, ReactElement로 단언(assertion)하여 props 접근
const variant = ALERT_VARIANTS[type]; if (React.isValidElement(firstChild)) {
const Icon = variant.icon; const element = firstChild as React.ReactElement<any>;
// [!NOTE] 텍스트 제거 후 나머지 렌더링 // 보통 blockquote > p 구조임
const restContent = React.Children.map(children, (child, index) => { if (element.type === 'p' && element.props.children) {
if (index === 0 && React.isValidElement(child)) { // p 태그의 첫 번째 자식이 텍스트인지 확인
return React.cloneElement(child as any, { const content = element.props.children;
children: child.props.children.slice(1) // 텍스트 노드 처리 필요 (단순화를 위해 slice 사용) 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 (
<div className={clsx("my-6 rounded-lg border-l-4 p-4 shadow-sm", variant.color)}>
<div className="flex items-center gap-2 font-bold mb-2">
<Icon size={20} />
<span>{variant.title}</span>
</div>
<div className="text-sm opacity-90 pl-7 [&>p]:mb-0">
{/* [!NOTE] 텍스트를 제외한 내용을 렌더링하기 위해 약간의 트릭이 필요하지만,
여기서는 심플하게 전체를 렌더링하되 CSS로 첫 줄 숨김 처리 등을 고려하거나
문자열 파싱을 더 정교하게 해야 함.
간단한 구현을 위해 여기선 일반 블록쿼트+스타일만 적용 */}
{children}
</div>
</div>
);
} }
} }
// 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<any>;
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 (
<div className={clsx("my-6 rounded-lg border-l-4 p-4 shadow-sm", variant.color)}>
<div className="flex items-center gap-2 font-bold mb-2 select-none">
<Icon size={20} />
<span>{variant.title}</span>
</div>
<div className="text-sm opacity-90 pl-7 [&>p]:mb-0">
{restContent}
</div>
</div>
);
}
// 일반 인용구 // 일반 인용구
return ( return (
<blockquote className="border-l-4 border-gray-300 bg-gray-50 pl-4 py-3 my-6 text-gray-600 rounded-r-lg italic shadow-sm"> <blockquote className="border-l-4 border-gray-300 bg-gray-50 pl-4 py-3 my-6 text-gray-600 rounded-r-lg italic shadow-sm">
@@ -123,18 +154,17 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
// 3. 링크 // 3. 링크
a({ href, children }) { a({ href, children }) {
const isExternal = href?.startsWith('http'); const isExternal = href?.startsWith('http');
// 헤딩 앵커 링크 처리 (rehype-autolink-headings)
if (href?.startsWith('#')) { if (href?.startsWith('#')) {
return ( return (
<a href={href} className="no-underline hover:underline text-gray-500 hover:text-blue-600 opacity-0 hover:opacity-100 transition-opacity ml-2" aria-hidden="true"> <a href={href} className="no-underline hover:underline text-gray-500 hover:text-blue-600 opacity-0 hover:opacity-100 transition-opacity ml-2" aria-hidden="true">
<LinkIcon size={16} className="inline" /> <LinkIcon size={16} className="inline" />
</a> </a>
) )
} }
return ( return (
<a <a
href={href} href={href}
target={isExternal ? '_blank' : undefined} target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined} rel={isExternal ? 'noopener noreferrer' : undefined}
className="text-blue-600 hover:text-blue-800 font-medium underline decoration-blue-300 hover:decoration-blue-800 underline-offset-4 inline-flex items-center gap-0.5 transition-all" className="text-blue-600 hover:text-blue-800 font-medium underline decoration-blue-300 hover:decoration-blue-800 underline-offset-4 inline-flex items-center gap-0.5 transition-all"
@@ -170,12 +200,12 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return ( return (
<figure className="block my-8 flex flex-col items-center justify-center group"> <figure className="block my-8 flex flex-col items-center justify-center group">
<div className="relative overflow-hidden rounded-xl shadow-md border border-gray-100"> <div className="relative overflow-hidden rounded-xl shadow-md border border-gray-100">
<img <img
src={src} src={src}
alt={alt} alt={alt}
className="max-w-full h-auto max-h-[600px] object-contain transition-transform duration-500 group-hover:scale-105" className="max-w-full h-auto max-h-[600px] object-contain transition-transform duration-500 group-hover:scale-105"
loading="lazy" loading="lazy"
/> />
</div> </div>
{alt && <figcaption className="text-center text-sm text-gray-500 mt-3 italic">{alt}</figcaption>} {alt && <figcaption className="text-center text-sm text-gray-500 mt-3 italic">{alt}</figcaption>}
</figure> </figure>
@@ -191,43 +221,46 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
return <ol className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium">{children}</ol>; return <ol className="list-decimal pl-6 space-y-2 my-4 text-gray-700 marker:text-gray-500 font-medium">{children}</ol>;
}, },
li({ children, className, ...props }: any) { li({ children, className, ...props }: any) {
// Task List 아이템 스타일링
if (className?.includes('task-list-item')) { if (className?.includes('task-list-item')) {
return <li className="flex items-start gap-2 -ml-6" {...props}>{children}</li> return <li className="flex items-start gap-2 -ml-6" {...props}>{children}</li>
} }
return <li className="pl-1 leading-relaxed" {...props}>{children}</li>; return <li className="pl-1 leading-relaxed" {...props}>{children}</li>;
}, },
// 체크박스 커스텀 // [수정] 체크박스 ReadOnly 속성 추가
input({ type, ...props }: any) { input({ type, ...props }: any) {
if (type === 'checkbox') { if (type === 'checkbox') {
return <input type="checkbox" className="mt-1.5 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2" {...props} /> return (
} <input
return <input type={type} {...props} />; type="checkbox"
readOnly // [수정] 뷰어 모드이므로 readOnly 추가
className="mt-1.5 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 cursor-default"
{...props}
/>
)
}
return <input type={type} {...props} />;
}, },
// 7. 헤딩 (앵커 링크 포함) // 7. 헤딩
h1({ children, id }: any) { h1({ children, id }: any) {
return ( return (
<div className="group flex items-center gap-2 mt-12 mb-6 border-b border-gray-200 pb-4"> <div className="group flex items-center gap-2 mt-12 mb-6 border-b border-gray-200 pb-4">
<h1 id={id} className="text-3xl font-extrabold text-gray-900 scroll-mt-24">{children}</h1> <h1 id={id} className="text-3xl font-extrabold text-gray-900 scroll-mt-24">{children}</h1>
</div> </div>
); );
}, },
h2({ children, id }: any) { h2({ children, id }: any) {
return ( return (
<div className="group flex items-center gap-2 mt-10 mb-5 pb-2 border-b border-gray-100"> <div className="group flex items-center gap-2 mt-10 mb-5 pb-2 border-b border-gray-100">
<h2 id={id} className="text-2xl font-bold text-gray-800 scroll-mt-24">{children}</h2> <h2 id={id} className="text-2xl font-bold text-gray-800 scroll-mt-24">{children}</h2>
</div> </div>
); );
}, },
h3({ children, id }: any) { h3({ children, id }: any) {
return <h3 id={id} className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1 scroll-mt-24 group">{children}</h3>; return <h3 id={id} className="text-xl font-bold mt-8 mb-4 text-gray-800 flex items-center gap-2 before:content-[''] before:w-1.5 before:h-6 before:bg-blue-500 before:rounded-full before:mr-1 scroll-mt-24 group">{children}</h3>;
}, },
// 수식 렌더링 (KaTeX)
// p 태그 내부의 수식 렌더링을 위해 p 태그 스타일 유지
p({ children }) { p({ children }) {
return <p className="mb-4 leading-7 text-gray-700 overflow-x-auto">{children}</p>; return <p className="mb-4 leading-7 text-gray-700 overflow-x-auto">{children}</p>;
} }
}} }}
> >
@@ -237,7 +270,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
); );
} }
// 코드 블록 컴포넌트 (디자인 개선) // 코드 블록 컴포넌트
function CodeBlock({ language, code }: { language: string; code: string }) { function CodeBlock({ language, code }: { language: string; code: string }) {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
@@ -249,7 +282,6 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
return ( return (
<div className="relative my-8 rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e] group"> <div className="relative my-8 rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl bg-[#1e1e1e] group">
{/* Mac OS 스타일 헤더 */}
<div className="flex items-center justify-between px-4 py-3 bg-[#252526] border-b border-black/30 select-none"> <div className="flex items-center justify-between px-4 py-3 bg-[#252526] border-b border-black/30 select-none">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex gap-1.5 opacity-80 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1.5 opacity-80 group-hover:opacity-100 transition-opacity">
@@ -257,20 +289,18 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
<div className="w-2.5 h-2.5 rounded-full bg-[#FFBD2E] border border-[#DEA123]/50" /> <div className="w-2.5 h-2.5 rounded-full bg-[#FFBD2E] border border-[#DEA123]/50" />
<div className="w-2.5 h-2.5 rounded-full bg-[#27C93F] border border-[#1AAB29]/50" /> <div className="w-2.5 h-2.5 rounded-full bg-[#27C93F] border border-[#1AAB29]/50" />
</div> </div>
{language && ( <div className="ml-4 flex items-center gap-1.5 text-[11px] font-sans font-medium text-gray-400">
<div className="ml-4 flex items-center gap-1.5 text-[11px] font-sans font-medium text-gray-400"> <Terminal size={12} className="text-blue-400" />
<Terminal size={12} className="text-blue-400" /> <span className="uppercase tracking-wider">{language}</span>
<span className="uppercase tracking-wider">{language}</span> </div>
</div>
)}
</div> </div>
<button <button
onClick={handleCopy} onClick={handleCopy}
className={clsx( className={clsx(
"flex items-center gap-1.5 px-2 py-1 rounded-md text-[10px] font-medium transition-all duration-200 border bg-white/5 backdrop-blur-sm", "flex items-center gap-1.5 px-2 py-1 rounded-md text-[10px] font-medium transition-all duration-200 border bg-white/5 backdrop-blur-sm",
isCopied isCopied
? "bg-green-500/10 text-green-400 border-green-500/20" ? "bg-green-500/10 text-green-400 border-green-500/20"
: "border-white/10 text-gray-400 hover:bg-white/10 hover:text-white" : "border-white/10 text-gray-400 hover:bg-white/10 hover:text-white"
)} )}
> >
@@ -278,20 +308,20 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
<span>{isCopied ? 'Copied' : 'Copy'}</span> <span>{isCopied ? 'Copied' : 'Copy'}</span>
</button> </button>
</div> </div>
<div className="relative font-mono text-[14px] leading-relaxed overflow-x-auto"> <div className="relative font-mono text-[14px] leading-relaxed overflow-x-auto">
<SyntaxHighlighter <SyntaxHighlighter
style={vscDarkPlus} style={vscDarkPlus}
language={language || 'text'} language={language}
PreTag="div" PreTag="div"
showLineNumbers={true} showLineNumbers={true}
wrapLines={true} // 줄바꿈 지원 wrapLines={true}
lineNumberStyle={{ lineNumberStyle={{
minWidth: '2.5em', minWidth: '2.5em',
paddingRight: '1em', paddingRight: '1em',
color: '#858585', color: '#858585',
textAlign: 'right', textAlign: 'right',
userSelect: 'none' // 라인 넘버 드래그 방지 userSelect: 'none'
}} }}
customStyle={{ customStyle={{
margin: 0, margin: 0,