Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/components/common/MarkdownRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import prismThemes from '../../lib/styles/prismThemes';
import breaks from 'remark-breaks';
import Typography from './Typography';
import embedPlugin from '../../lib/remark/embedPlugin';
import mermaidPlugin from '../../lib/remark/mermaidPlugin';
import { loadScript, ssrEnabled } from '../../lib/utils';
import media from '../../lib/styles/media';
import parse from 'html-react-parser';
Expand Down Expand Up @@ -125,6 +126,23 @@ const MarkdownRenderBlock = styled.div`
.katex-mathml {
display: none;
}

.mermaid-block {
display: flex;
justify-content: center;
margin: 1.5rem 0;

pre.mermaid {
background: transparent;
padding: 0;
text-align: center;
}

svg {
max-width: 100%;
height: auto;
}
}
`;

function filter(html: string) {
Expand Down Expand Up @@ -218,6 +236,7 @@ const MarkdownRender: React.FC<MarkdownRenderProps> = ({
.use(breaks)
.use(remarkParse)
.use(slug)
.use(mermaidPlugin)
.use(prismPlugin)
.use(embedPlugin)
.use(remark2rehype, { allowDangerousHTML: true })
Expand All @@ -242,6 +261,7 @@ const MarkdownRender: React.FC<MarkdownRenderProps> = ({
.use(breaks)
.use(remarkParse)
.use(slug)
.use(mermaidPlugin)
.use(prismPlugin)
.use(embedPlugin)
.use(remark2rehype, { allowDangerousHTML: true })
Expand Down Expand Up @@ -291,6 +311,33 @@ const MarkdownRender: React.FC<MarkdownRenderProps> = ({
throttledUpdate(markdown);
}, [markdown, throttledUpdate]);

useEffect(() => {
if (!html || html.indexOf('class="mermaid"') === -1) return;

const renderMermaid = () => {
const mermaid = (window as any).mermaid;
if (!mermaid) return;
mermaid.initialize({
startOnLoad: false,
theme:
codeTheme === 'atom-one' ||
codeTheme === 'monokai' ||
codeTheme === 'dracula'
? 'dark'
: 'default',
});
mermaid.run({ querySelector: '.mermaid' });
};

if ((window as any).mermaid) {
renderMermaid();
} else {
loadScript(
'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js',
).then(renderMermaid);
}
}, [html, codeTheme]);

useEffect(() => {
if (!shouldShowAds || !html) {
setHtmlWithAds('');
Expand Down
76 changes: 76 additions & 0 deletions src/lib/remark/__tests__/mermaidPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import remark from 'remark';
import remarkParse from 'remark-parse';
import remark2rehype from 'remark-rehype';
import raw from 'rehype-raw';
import stringify from 'rehype-stringify';
import mermaidPlugin from '../mermaidPlugin';

function processMarkdown(md: string): string {
return remark()
.use(remarkParse)
.use(mermaidPlugin)
.use(remark2rehype, { allowDangerousHTML: true })
.use(raw)
.use(stringify)
.processSync(md)
.toString();
}

describe('mermaidPlugin', () => {
it('mermaid 코드 블록을 mermaid-block div로 변환한다', () => {
const md = '```mermaid\ngraph TD;\n A-->B;\n```';
const html = processMarkdown(md);
expect(html).toContain('class="mermaid-block"');
expect(html).toContain('class="mermaid"');
expect(html).toContain('graph TD;');
expect(html).toContain('A-->B;');
});

it('mermaid가 아닌 코드 블록은 변환하지 않는다', () => {
const md = '```javascript\nconsole.log("hello");\n```';
const html = processMarkdown(md);
expect(html).not.toContain('mermaid-block');
expect(html).toContain('console.log');
});

it('언어 지정 없는 코드 블록은 변환하지 않는다', () => {
const md = '```\nplain code\n```';
const html = processMarkdown(md);
expect(html).not.toContain('mermaid-block');
expect(html).toContain('plain code');
});

it('다양한 mermaid 다이어그램 타입을 지원한다', () => {
const diagrams = [
'sequenceDiagram\n Alice->>Bob: Hello',
'classDiagram\n Animal <|-- Duck',
'pie title Pets\n "Dogs" : 386\n "Cats" : 85',
'flowchart LR\n A --> B',
'erDiagram\n CUSTOMER ||--o{ ORDER : places',
'gantt\n title A Gantt\n section Section\n A task :a1, 2024-01-01, 30d',
'stateDiagram-v2\n [*] --> Active',
'gitGraph\n commit\n branch develop',
];
diagrams.forEach(diagram => {
const md = `\`\`\`mermaid\n${diagram}\n\`\`\``;
const html = processMarkdown(md);
expect(html).toContain('class="mermaid"');
});
});

it('mermaid 블록과 일반 코드 블록이 함께 있을 때 각각 올바르게 처리된다', () => {
const md = [
'```mermaid',
'graph TD;',
' A-->B;',
'```',
'',
'```javascript',
'const x = 1;',
'```',
].join('\n');
const html = processMarkdown(md);
expect(html).toContain('class="mermaid"');
expect(html).toContain('const x = 1;');
});
});
40 changes: 40 additions & 0 deletions src/lib/remark/__tests__/prismPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import remark from 'remark';
import remarkParse from 'remark-parse';
import remark2rehype from 'remark-rehype';
import raw from 'rehype-raw';
import stringify from 'rehype-stringify';
import prismPlugin from '../prismPlugin';

function processMarkdown(md: string): string {
return remark()
.use(remarkParse)
.use(prismPlugin)
.use(remark2rehype, { allowDangerousHTML: true })
.use(raw)
.use(stringify)
.processSync(md)
.toString();
}

describe('prismPlugin', () => {
it('mermaid 코드 블록을 Prism으로 처리하지 않는다', () => {
const md = '```mermaid\ngraph TD;\n A-->B;\n```';
const html = processMarkdown(md);
// Prism이 처리했다면 <span class="token ..."> 토큰이 생성됨
expect(html).not.toContain('class="token');
// 원본 텍스트가 그대로 유지되어야 함
expect(html).toContain('graph TD;');
});

it('javascript 코드 블록은 정상적으로 하이라이팅한다', () => {
const md = '```javascript\nconst x = 1;\n```';
const html = processMarkdown(md);
expect(html).toContain('language-javascript');
});

it('언어 지정 없는 코드 블록도 처리한다', () => {
const md = '```\nplain text\n```';
const html = processMarkdown(md);
expect(html).toContain('<pre>');
});
});
13 changes: 13 additions & 0 deletions src/lib/remark/mermaidPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import visit from 'unist-util-visit';

export default function mermaidPlugin() {
function visitor(node: any) {
const { lang, value } = node;
if (lang !== 'mermaid') return;

node.type = 'html';
node.value = `<div class="mermaid-block"><pre class="mermaid">${value}</pre></div>`;
}

return (ast: any) => visit(ast, 'code', visitor);
}
2 changes: 2 additions & 0 deletions src/lib/remark/prismPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default function attacher({ include, exclude } = {}) {
function visitor(node) {
let { lang, data } = node;

if (lang === 'mermaid') return;

// if (
// !lang ||
// (include && include.indexOf(lang) === -1) ||
Expand Down
Loading