Ghost 블로그에 코드 하이라이팅, Mermaid, 수식 렌더링 붙이기

Ghost가 제안하는 Code Injection 방식으로 Prism, Mermaid, KaTeX 렌더링을 구현하는 방법입니다.

Share
Ghost 블로그에 코드 하이라이팅, Mermaid, 수식 렌더링 붙이기
Photo by Mohammad Rahmani / Unsplash

Ghost가 제안하는 Code Injection 방식으로 Prism, Mermaid, KaTeX 렌더링을 구현하는 방법입니다.

Ghost로 기술 블로그를 운영하다 보면 렌더링이 아쉬운 순간이 옵니다. 마크다운 코드 블록은 하이라이팅 없는 회색 박스로 나오고, mermaid 다이어그램이나 수식 블록은 소스 텍스트가 그대로 노출됩니다. Ghost의 렌더러는 마크다운을 HTML로 변환하는 역할까지만 하고, 그 위에 얹는 시각적 처리는 의도적으로 코어에 포함하지 않았기 때문입니다.

대신 Ghost는 이런 확장을 위한 공식 통로로 Code Injection을 제공합니다. 공식 튜토리얼에서도 문법 하이라이팅이 필요하면 Code Injection으로 Prism을 로드하라고 안내하고 있습니다. 즉 "지원 안 함"이 아니라 "필요한 만큼 직접 붙이라"는 설계입니다.

이 글에서는 그 공식 접근을 그대로 따라가되, 하이라이팅에서 멈추지 않고 mermaid 다이어그램과 KaTeX 수식 렌더링까지 같은 패턴으로 확장하는 방법을 공유합니다. 실제로 이 블로그에 적용해서 운영 중인 구성이고, 지금 보고 계신 이 글의 예시 블록들이 바로 그 결과물입니다.

설계: 하나의 패턴, 조건부 로딩

스크립트는 Ghost 관리자 페이지의 Settings → Code injection → Site footer에 넣습니다. 전역으로 적용되는 만큼 한 가지 원칙을 세웠습니다. 해당 블록이 있는 페이지에서만 라이브러리를 로드한다는 것입니다. mermaid만 해도 압축 기준 수백 KB급이라, 다이어그램 없는 글에서까지 이걸 받아올 이유가 없습니다.

그래서 세 스크립트 모두 같은 구조로 통일했습니다.

  1. querySelectorAll로 대상 코드 블록이 페이지에 존재하는지 검사
  2. 존재할 때만 동적 import() 또는 스크립트 주입으로 라이브러리 로드
  3. 블록을 찾아 렌더링

검사 자체는 사실상 비용이 없어서 전역에 걸어둬도 부담이 없고, 브라우저가 모듈을 캐싱하므로 같은 세션에서 여러 글을 오가도 매번 새로 받지 않습니다.

블록 종류별 역할은 이렇게 나눴습니다.

코드 블록 처리 결과
```mermaid Mermaid 다이어그램 렌더링
```math KaTeX 수식 렌더링
```latex 포함 그 외 Prism 문법 하이라이팅

latex를 수식 렌더링 대상에서 뺀 건 의도적입니다. LaTeX 소스 자체를 보여주는 글을 쓸 여지를 남겨두기 위해서입니다. 수식을 넣고 싶으면 math, 소스를 보여주고 싶으면 latex로 구분해 쓰면 됩니다.

Prism: 코드 하이라이팅

공식 튜토리얼과 마찬가지로 Prism을 쓰되, 조건부 로딩 구조로 감쌌습니다. Prism은 코어와 언어별 문법 파일이 분리되어 있고, autoloader 플러그인이 페이지에 실제로 등장한 언어의 문법만 골라 받아옵니다. bash와 yaml만 있는 글이면 그 두 개만 로드되는 식이라 이 설계와 잘 맞습니다.

<!-- 1. prism highlighter -->
<script type="module">
  const codeBlocks = document.querySelectorAll(
    'pre code[class*="language-"]:not(.language-mermaid):not(.language-math)'
  );
  if (codeBlocks.length > 0 && !window.Prism) {
    const THEME_CSS = 'https://cdn.jsdelivr.net/npm/[email protected]/themes/prism-vsc-dark-plus.min.css';
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = THEME_CSS;
    document.head.appendChild(link);

    window.Prism = { manual: true };

    const load = (src) => new Promise((res, rej) => {
      const s = document.createElement('script');
      s.src = src;
      s.onload = res;
      s.onerror = rej;
      document.head.appendChild(s);
    });

    await load('https://cdn.jsdelivr.net/npm/[email protected]/components/prism-core.min.js');
    await load('https://cdn.jsdelivr.net/npm/[email protected]/plugins/autoloader/prism-autoloader.min.js');

    codeBlocks.forEach((el) => Prism.highlightElement(el));
  }
</script>

포인트만 짚으면:

  • 선택자에서 language-mermaidlanguage-math를 제외했습니다. 이 블록들은 뒤의 스크립트가 다이어그램과 수식으로 치환할 대상이라, Prism이 먼저 하이라이팅 태그를 감지 않도록 한 것입니다.
  • window.Prism = { manual: true }는 라이브러리 로드 전에 설정해야 합니다. 기본 동작이 로드 즉시 페이지 전체 자동 하이라이팅이라, 이걸 끄지 않으면 제외 선택자가 무의미해집니다.
  • !window.Prism 가드는 테마에 하이라이터가 내장된 경우의 이중 하이라이팅을 막습니다.

테마는 로드하는 CSS 파일이 전부라 THEME_CSS URL만 바꾸면 됩니다. 저는 독자에게 가장 익숙한 VS Code Dark+를 골랐고, Prism 기본 제공 외의 테마는 공식 확장 모음인 prism-themes 패키지에서 고를 수 있습니다.

Mermaid: 다이어그램

<!-- 2. mermaid renderer -->
<script type="module">
  const blocks = document.querySelectorAll('pre code.language-mermaid');
  if (blocks.length > 0) {
    const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/[email protected]/dist/mermaid.esm.min.mjs');
    mermaid.initialize({ startOnLoad: false, theme: 'base' });
    blocks.forEach((el) => {
      const d = document.createElement('div');
      d.className = 'mermaid';
      d.textContent = el.textContent;
      el.parentElement.replaceWith(d);
    });
    mermaid.run();
  }
</script>

pre > code 구조를 mermaid가 요구하는 div.mermaid로 치환한 뒤 mermaid.run()을 호출합니다. textContent로 읽기 때문에 다른 스크립트가 태그를 끼워 넣었더라도 순수 텍스트만 넘어갑니다.

KaTeX: 수식

수식 렌더러는 KaTeX를 골랐습니다. MathJax보다 가볍고 렌더링이 동기적이라 레이아웃이 출렁이지 않습니다. 여기서는 CSS도 조건부로 주입하는 게 핵심입니다. <link>를 헤더에 상시 걸어두면 수식 없는 페이지에서도 폰트와 스타일시트를 받아오기 때문입니다.

<!-- 3. math renderer (katex) -->
<script type="module">
  const blocks = document.querySelectorAll('pre code.language-math');
  if (blocks.length > 0) {
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css';
    document.head.appendChild(link);

    const { default: katex } = await import('https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.mjs');
    blocks.forEach((el) => {
      const d = document.createElement('div');
      d.className = 'math-block';
      katex.render(el.textContent, d, { displayMode: true, throwOnError: false });
      el.parentElement.replaceWith(d);
    });
  }
</script>

throwOnError: false 옵션 덕분에 수식에 문법 오류가 있어도 페이지가 깨지지 않고 해당 수식만 오류 표시됩니다.

세 스크립트를 위 순서대로 Site footer에 넣으면 설정은 끝입니다. 모듈 스크립트는 문서 순서대로 실행되므로 별도의 순서 제어는 필요 없습니다.

렌더링 결과

아래 블록들은 전부 평범한 마크다운 코드 블록으로 작성했고, 위 스크립트가 렌더링한 결과입니다.

코드 하이라이팅(```bash):

#!/usr/bin/env bash
set -euo pipefail

for svc in ghost mysql caddy; do
  docker compose ps "$svc" --format '{{.Status}}'
done

다이어그램(```mermaid):

flowchart LR
    A[마크다운 코드 블록] --> B{어떤 언어?}
    B -->|mermaid| C[다이어그램 렌더링]
    B -->|math| D[KaTeX 수식 렌더링]
    B -->|그 외| E[Prism 하이라이팅]

수식(```math):

f(x) = \int_{-\infty}^{\infty} \hat{f}(\xi)\, e^{2\pi i \xi x}\, d\xi

LaTeX 소스 그대로 보여주기(```latex):

\documentclass{article}
\begin{document}
Hello, Ghost!
\end{document}

mathlatex가 같은 문법의 블록인데도 하나는 수식으로, 하나는 하이라이팅된 코드로 갈라지는 것을 확인할 수 있습니다.

적용 전에 확인할 것들

첫째, 사용 중인 테마에 하이라이터가 이미 내장된 경우가 있습니다. 개발자 도구 콘솔에서 window.Prism이나 window.hljs를 확인해보고, 있다면 테마 쪽 설정을 끄거나 위 스크립트의 가드에 맡기면 됩니다. 둘 다 동작하면 스타일이 꼬입니다.

둘째, 에디터가 코드 블록의 언어명을 language-* 클래스로 뽑아주는지 확인해두는 게 좋습니다. 일반적으로는 문제없지만, 렌더링된 HTML의 클래스명을 직접 한 번 보고 선택자와 맞는지 검증하는 게 확실합니다.

셋째, 이 구성은 jsDelivr에 의존하는 클라이언트 렌더링입니다. CDN 장애 시 그 시간 동안 다이어그램과 수식이 소스 텍스트로 노출됩니다. 신경 쓰인다면 라이브러리 정적 파일을 자체 스토리지에 올려두고 URL만 교체하면 됩니다.

마지막으로, CSS를 스크립트에서 주입하는 구조라 수식이 잠깐 스타일 없이 보였다가 정리되는 순간이 있을 수 있습니다. 거슬린다면 link.onload를 기다린 뒤 렌더링하도록 순서를 맞추면 됩니다.

빌드 파이프라인 없이 스크립트 세 개로 끝난다는 게 이 방식의 장점입니다. Ghost가 렌더링 확장을 코어에 넣지 않고 Code Injection으로 열어둔 설계 덕분에, 필요한 만큼만 골라 붙일 수 있었습니다. 같은 구성을 고민하는 분들께 참고가 되었으면 합니다.