블로그에 마크다운 적용하기 (Marked.js, Prism, KaTeX)


글을 작성하다 보니 코드에 구문 강조나 수식을 입력하고 싶은 경우에는 Quill.js로는 한계를 느껴 마크다운을 사용하기로 했습니다. 이 글에서는 마크다운과 구문 강조(Syntax Highlighting), 그리고 수식을 서버에서 렌더링하는 것입니다.

마크다운 to HTML 파서

Node.js환경에서 마크다운을 HTML로 변환하는 패키지는 찾아본 결과 Markdon-itMarked가 있었습니다. 그 중에서 Marked.js가 인기가 더 많은 것 같아 Marked를 사용하기로 했습니다. 우선 아래 명령어를 통해 Marked를 설치해줍니다.

npm install --save marked

이제 모듈을 불러온 후 마크다운을 매개변수로 넘겨주면 HTML로 변환되는 것을 볼 수 있습니다.

const marked = require('marked')

console.log(marked('## Title\n**strong**'))

// 출력: 
// <h2 id="title">Title</h2>
// <p><strong>strong</strong></p>

구문 강조 (Syntax Highlighting)

Marked는 자체로 구문 강조를 지원하지 않습니다. 구문 강조를 위해서는 별도의 모듈을 사용해야 합니다. 찾아본 결과 구문 강조를 위해 가장 많이 사용되는 모듈에는 highlightjsPrism.js 두가지가 있습니다. highlightjs과 Prism.js 모두 사용해 본 결과 Prism.js의 구문 강조가 조금 더 마음에 들어 Prism.js를 채택했습니다. Marked와 마찬가지로 npm으로 Prism.js를 설치해 줍니다.

npm install --save prismjs

Marked에서 코드를 파싱할 때 코드 블럭과 인라인 코드는 Prism이 렌더링하도록 해야합니다. 그러기 위해서 아래와 같이 Marked의 renderer를 오버라이드 해줍니다.

const marked = require('marked');
const Prism = require('prismjs');
const loadLanguages = require('prismjs/components/');

// 글에서 사용할 계획이 있는 언어들을 불러옵니다. 
// 개인적으로 미리 불러오지 않고 필요할 때 불러오는 것을 선호하지만 지원하지 않으므로 어쩔 수 없습니다.
// 지원하는 모든 언어는 https://prismjs.com/#supported-languages에서 확인할 수 있습니다.
loadLanguages([
    'bash',
    'sql',
]);

const renderer = {
    // 코드 블럭을 오버라이드 합니다.
    code(code, infostring) {
        try {
            return `<pre class = "language-${infostring}"><code class = "language-${infostring}">${Prism.highlight(
                code,
                Prism.languages[infostring],
                infostring
            )}</code></pre>`;
        } catch (err) {
            return false;
        }
    },
};

marked.use({ renderer });

이후 marked를 함수로 호출하면 `로 둘러쌓인 코드가 Prism에서 사용하는 class들로 감싸진 것을 확인 할 수 있습니다. 생성된 html과 Prism에서 제공하는 CSS파일을 합치면 현재 블로그에서 볼 수 있는 구문 강조가 완성됩니다.

수식 입력

수식 입력은 TeX를 이용합니다. 웹에서 수식을 처리하기 위한 MathML이란 표준도 있지만 지원하는 브라우저도 거의 없고 복잡해 고려하지 않았습니다. TeX를 HTML로 바꿔주기 위해서는 또 두 가지 선택지가 있습니다. MathJax와 KaTeX인데 MathJax는 지나치게 복잡해 KaTeX를 사용하기로 했습니다. npm으로 KaTeX를 설치해 줍니다.

npm install --save katex

TeX는 로 TeX블럭을, $로 인라인 TeX를 감싸기 때문에 Marked의 renderer 뿐 아니라 tokenizer를 오버라이드 해줍니다.

const marked = require('marked');
const Prism = require('prismjs');
const katex = require('katex');
const loadLanguages = require('prismjs/components/');

// Languages I have plan to write in my posts
loadLanguages([
    'bash',
    'sql',
]);

const renderer = {
    // Override code block
    code(code, infostring) {
        // infostring이 Math일 때만 KaTex로 별도로 렌더링해 줍니다.
        if (infostring === 'Math') {
            // TeX 문법에 오류가 있어도 에러를 일으키지 않습니다.
            return katex.renderToString(code, { throwOnError: false });
        } else {
            try {
                return `<pre class = "language-${infostring}"><code class = "language-${infostring}">${Prism.highlight(
                    code,
                    Prism.languages[infostring],
                    infostring
                )}</code></pre>`;
            } catch (err) {
                return false;
            }
        }
    },
    // Override inline code
    codespan(code) {
        // 첫 캐릭터가 '$'일 때 KaTeX로 렌더링한다.
        if (code[0] === '$') {
            return katex.renderToString(code.substring(1), {
                throwOnError: false,
            });
        } else {
            // or just use original code
            return `<code class = "inline-code">${code}</code>`;
        }
    },
};

const tokenizer = {
    // $ ... $로 감싸진 블럭을 codespan으로 처리합니다.
    codespan(src) {
        const match = src.match(/^([`$])(?=[^\s\d$`])([^`$]*?)\1(?![`$])/);
        if (match) {
            return {
                type: 'codespan',
                raw: match[0],
                // codespan은 infostring이 없으니까 TeX인 경우 문자열의 제일 앞에 $를 써줍니다.
                text:
                    match[1] === '$' ? `$${match[2].trim()}` : match[2].trim(),
            };
        }
        return false;
    },
    // 인라인 텍스트가 $ ... $를 만나도 멈추도록 오버라이드 합니다.
    inlineText(src, inRawBlock, smartypants) {
        const cap = src.match(
            /^([`$]+|[^`$])(?:[\s\S]*?(?:(?=[\\<!\[`$*]|\b_|$)|[^ ](?= {2,}\n))|(?= {2,}\n))/
        );
        if (cap) {
            var text;
            if (inRawBlock) {
                text = this.options.sanitize
                    ? this.options.sanitizer
                        ? this.options.sanitizer(cap[0])
                        : cap[0]
                    : cap[0];
            } else {
                text = this.options.smartypants ? smartypants(cap[0]) : cap[0];
            }
            return {
                type: 'text',
                raw: cap[0],
                text: text,
            };
        }
    },
    // $$ ... $$ 블럭을 코드블럭으로 처리하도록 오버라이드 해 줍니다.
    fences(src) {
        const cap = src.match(
            /^ {0,3}(`{3,}|\${2,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`\$]* *(?:\n+|$)|$)/
        );
        if (cap) {
            return {
                type: 'code',
                raw: cap[0],
                codeBlockStyle: 'indented',
                // $$ ... $$ 블럭일 때 lang을 Math로 설정합니다.
                lang: cap[1] === '$$' ? 'Math' : cap[2].trim(),
                text: cap[3],
            };
        }
    },
};

marked.use({ renderer, tokenizer });

마지막으로 marked로 파싱한 html을 katex CSS와 함께 사용하면 아래와 같이 잘 포맷된 수식을 작성할 수 있습니다.