6 minute read

이번엔 제가 사용하고 있는 github blog 테마인 Minimal Mistakes 에서 코드 블록을 복사할 수 있는 기능 구현에 대해 알아보고자 합니다. 특히 ChatGPT 를 쓰면서 GPT 가 보여주는 코드 블록처럼 구현을 진행해 보고자 합니다. 오른쪽 상단에 코드 블럭 복사 버튼을 만들고, 왼쪽 상단에는 코드 블럭에 사용된 언어 이름을 표시하도록 구현하고자 합니다. 그리고 구현 과정에 대해 포스트를 작성하게 된 계기는 코드 블럭 복사 버튼과 언어이름 표시와 관련해서 아무리 찾아봐도 자료가 없었고, 혹시나 해서 ChatGPT 에게 물어보아 GPT 의 도움을 토대로 구현을 하게 되었고, 이 과정을 다른 사람들에게도 공유를 해서 저 처럼 답답한 심정을 느꼈을 사람들에게 조금이나마 도움이 되었으면 해서 작성하게 되었습니다.

구현 과정

코드 블럭 복사 버튼 구현 과정

1. 코드 블럭에 적용할 복사 버튼을 위한 JavaScript 파일 생성 및 적용

포스트의 모든 코드 블럭에 복사 버튼을 자동으로 삽입하고, 클릭 시 해당 코드 내용을 클립보드로 복사하는 JavaScript 파일을 /assets/scripts/ 디렉토리에 추가해 줍니다. 저는 파일 이름을 copy-code-auto.js 로 하였습니다. 코드 내용은 다음과 같습니다.

/* /assets/scripts/copy-code-auto.js */
(function () {
  const blocks = document.querySelectorAll('div.highlighter-rouge, figure.highlight, div.highlight');

  blocks.forEach((block) => {
    // 컨테이너 결정
    const container = block.classList.contains('highlight') ? block : (block.querySelector('.highlight') || block);
    if (!container) return;

    // 버튼 중복 삽입 방지
    if (container.querySelector('.copy-code-button')) return;

    // 코드 엘리먼트
    const codeEl = container.querySelector('pre code') || container.querySelector('code');
    if (!codeEl) return;

    // 헤더(버튼) 생성
    const header = document.createElement('div');
    header.className = 'code-header';
    const btn = document.createElement('button');
    btn.className = 'copy-code-button';
    btn.type = 'button';
    btn.title = 'Copy code to clipboard';
    // 이미지 아이콘 쓰시려면 아래 한 줄로 교체
    // btn.innerHTML = '<img class="copy-code-image" src="/assets/images/copy.png" />';
    btn.textContent = '복사하기';

    header.appendChild(btn);

    // 컨테이너에 삽입 (상단)
    container.style.position = getComputedStyle(container).position === 'static' ? 'relative' : getComputedStyle(container).position;
    container.insertBefore(header, container.firstChild);

    // 클릭 복사
    btn.addEventListener('click', async () => {
      const text = codeEl.innerText;
      try {
        if (navigator.clipboard && window.isSecureContext) {
          await navigator.clipboard.writeText(text);
        } else {
          const ta = document.createElement('textarea');
          ta.value = text;
          ta.style.position = 'fixed';
          ta.style.top = '-9999px';
          document.body.appendChild(ta);
          ta.focus(); ta.select();
          document.execCommand('copy');
          document.body.removeChild(ta);
        }
        btn.textContent = '복사됨!';
        setTimeout(() => (btn.textContent = '복사하기'), 1200);
      } catch {
        btn.textContent = '복사실패';
        setTimeout(() => (btn.textContent = '복사하기'), 1200);
      }
    });
  });
})();

/includes/footer.html 의 마지막 줄에 아래와 같이 추가해 줍니다. footer.html 은 사이트 공통 하단을 담당하는 파일로, 저작권/링크 같은 푸터 디자인뿐 아니라, 자신이 만든 JavaScript 파일으 불러와 기능을 확장하는데 사용됩니다. 그리고 js 파일을 footer.html 에 넣어주는 이유는 HTML 페이지가 위에서부터 아래로 순서대로 렌더링이 되는데, 스크립트를 <head>에 넣으면 DOM 요소들이 생성되기 전에 실행될 수 있어 에러가 납니다. footer.html 은 페이지 하단에 위치하기 때문에 모든 코드 블럭이 생성된 후 실행됩니다. 그러므로 footer.html 은 DOM 조작 스크립트를 넣기 적합하기 때문에 footer.html 에 넣어주는 것입니다.

<script src="/assets/scripts/copy-code-auto.js" defer></script>

3. 버튼 추가를 위한 scss 파일 수정

/sass/minimal-mistakes/_page.scss 파일의 가장 마지막 부분에 아래와 같이 추가해 줍니다.

.code-header {
  position: relative;
  top: 0;  // 자동 주입 시 컨테이너 첫 줄에 오므로 0부터 시작
  z-index: 1;
  display: flex;
  justify-content: flex-end;
}

.copy-code-button {
  cursor: pointer;
  background-color: $code-background-color;
  color: #ffffff;
  padding: 4px 8px;
  border-radius: 6px;
  border: none;
  margin: .4rem;
  font-size: .8rem;
}

.copy-code-button:hover { background-color: #0f1214; }

코드 블럭 왼쪽 상단에 언어 이름 표시 구현 과정

1. highlighter 적용을 위한 _config.yml 파일 수정

highlighter 가 적용되지 않아 최종적으로 언어이름 표시가 되지 않을 수 있습니다. 자신의 github blog 디렉토리에 있는 _config.yml 파일에서 ConversionMarkdown Processing 을 찾고 아래와 같이 추가해 줍니다.

markdown: kramdown
highlighter: rouge

kramdown:
  input: GFM
  syntax_highlighter: rouge
  syntax_highlighter_opts:
    # 라인번호 쓰면 <table> 구조가 되기도 하므로 당장은 끔
    block: { line_numbers: false }
  # (선택) 미지정 언어 추측 방지
  # rouge:
  #   guess_lang: false

2. 언어 이름 표시를 위한 JavaScript 추가

코드 블럭 왼쪽 상단에 언어 이름을 자동으로 표시해주는 역할을 하는 JavaScript 가 필요합니다. 이 JavaScript 는 코드 블럭에 언어를 명시하면, 실제 HTML 출력 시 class="language-xxx" 형태가 붙는데, 그 정보를 읽어와서 배지를 달아주는 역할을 합니다. 저는 /assets/js/code-lang-label.js 파일로 추가를 해주었습니다.

/* assets/js/code-lang-label.js */
(function () {
  function onReady(fn) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', fn);
    } else {
      fn();
    }
  }

  onReady(function () {
    const containers = document.querySelectorAll('div.highlighter-rouge, figure.highlight, div.highlight');
    if (!containers.length) return;

    const DISPLAY = {
      js: 'JavaScript', javascript: 'JavaScript',
      ts: 'TypeScript', typescript: 'TypeScript',
      py: 'Python', python: 'Python',
      bash: 'Bash', shell: 'Shell', sh: 'Shell', zsh: 'Zsh',
      html: 'HTML', css: 'CSS',
      json: 'JSON', yaml: 'YAML', yml: 'YAML',
      md: 'Markdown', markdown: 'Markdown',
      java: 'Java', c: 'C', cpp: 'C++', cxx: 'C++',
      csharp: 'C#', 'c#': 'C#',
      go: 'Go', rust: 'Rust', kotlin: 'Kotlin',
      php: 'PHP', ruby: 'Ruby', r: 'R', swift: 'Swift',
      sql: 'SQL', scala: 'Scala', perl: 'Perl', dart: 'Dart',
      scss: 'SCSS', sass: 'Sass'
    };

    function pickLang(container) {
      // 1) 컨테이너 클래스에서 language-xxx
      for (const cls of container.classList) {
        if (cls.startsWith('language-')) return cls.slice(9);
      }
      // 2) 내부 code/pre, (라인번호 구조까지 포함)
      const code =
        container.querySelector('pre code') ||
        container.querySelector('code') ||
        container.querySelector('td.code pre code');
      if (code) {
        for (const cls of code.classList) {
          if (cls.startsWith('language-')) return cls.slice(9);
        }
      }
      return null;
    }

    containers.forEach((outer) => {
      const container = outer.classList.contains('highlight')
        ? outer
        : (outer.querySelector('.highlight') || outer);

      if (!container) return;

      // 중복 방지
      if (container.dataset.langBadgeApplied === '1') return;

      const langRaw = pickLang(outer) || pickLang(container);
      if (!langRaw) return;

      const label = DISPLAY[langRaw.toLowerCase()] || langRaw.toUpperCase();

      // 코드 헤더가 있으면 헤더 왼쪽에 배지 엘리먼트 삽입
      const header = container.querySelector('.code-header');
      if (header) {
        header.style.display = 'flex';
        header.style.alignItems = 'center';
        header.style.justifyContent = 'space-between';

        // 이미 배지가 있으면 스킵
        if (!header.querySelector('.code-lang-badge')) {
          const badge = document.createElement('span');
          badge.className = 'code-lang-badge';
          badge.textContent = label;

          // 버튼이 오른쪽에 가도록, 배지는 맨 앞에 삽입
          header.insertBefore(badge, header.firstChild);
        }
        // === pre 패딩을 읽어 header 좌우 패딩을 '강제로' 동일하게 맞춤 ===
        try {
          const pre = container.querySelector('pre');
          const csPre = pre ? getComputedStyle(pre) : null;
          const csCont = getComputedStyle(container);

          // pre의 padding-left가 0이면 컨테이너 padding-left를 사용
          const padL = csPre && csPre.paddingLeft !== '0px'
            ? csPre.paddingLeft
            : (csCont.paddingLeft !== '0px' ? csCont.paddingLeft : '1rem');

          const padR = csPre && csPre.paddingRight !== '0px'
            ? csPre.paddingRight
            : (csCont.paddingRight !== '0px' ? csCont.paddingRight : '1rem');

          header.style.paddingLeft = padL;
          header.style.paddingRight = padR;
        } catch (e) {
          // 문제가 있어도 페이지 죽지 않게 무시
        }

      } else {
        // 헤더가 없으면 부모에 data-lang 달고 ::before 로 표시
        outer.classList.add('has-lang-badge');
        outer.setAttribute('data-lang', label);
        const cs = getComputedStyle(outer).position;
        if (cs === 'static') outer.style.position = 'relative';
      }

      container.dataset.langBadgeApplied = '1';
    });
  });
})();

3. footer.hmlt 에 추가한 JavaScript 적용

/includes/footer.html 에 추가한 JavaScript 파일을 적용해 줍니다. footer.html 파일의 가장 아래 부분에 아래와 같이 추가해 줍니다.

<!-- 코드 블럭 코드 언어 배치 삽입에 필요 -->
<script src="/assets/js/code-lang-label.js" defer></script>

4. 버튼 추가를 위한 scss 파일 추가

버튼 추가를 위한 scss 파일을 추가합니다. /assets/css/main.scss 에 추가해줘도 되지만 그렇게 되면 중복되는 내용들 때문에 나중에 적용하고자 하는 내용이 제대로 적용되지 않을 수 있어서 /_sass/minimal-mistakes/_custom.scss 라는 새로운 파일에 추가를 해주고자 합니다. 내용은 아래와 같습니다.

/* 헤더와 코드 본문의 좌우 패딩을 '같게' 맞춰 정렬 */
.highlighter-rouge > .highlight > .code-header,
figure.highlight > .code-header,
.highlight > .code-header {
  display: flex !important;
  align-items: center !important;
  justify-content: space-between !important;
  width: 100% !important;
  box-sizing: border-box !important;

  /* 패딩은 JS에서 동기화해 주지만, 기본값을 넣어 둡니다 */
  padding-left: 1rem !important;
  padding-right: 1rem !important;

  /* (선택) 배경을 코드 블록과 동일하게 하고 싶으면 유지 */
  background: $code-background-color;
  position: relative;
  z-index: 3;
}

/* 배지는 header 패딩에 맞춰 정렬 → margin 제거 */
.code-header .code-lang-badge {
  margin: 0 !important;
  padding: .15rem .5rem;
  font-size: .75rem;
  line-height: 1;
  border-radius: .4rem;
  background: rgba(0,0,0,.35);
  color: #fff;
  border: 1px solid rgba(255,255,255,.25);
  user-select: none;
}

/* 헤더가 없을 때를 위한 보강(그대로 유지) */
.highlight, .highlighter-rouge, figure.highlight { position: relative; }
.has-lang-badge::before {
  content: attr(data-lang);
  position: absolute;
  top: .6rem;
  left: 1rem;         /* 헤더 기본값과 맞춤 */
  z-index: 2;
  display: inline-block;
  padding: .15rem .5rem;
  font-size: .75rem;
  line-height: 1;
  border-radius: .4rem;
  background: rgba(0,0,0,.35);
  color: #fff;
  border: 1px solid rgba(255,255,255,.25);
  user-select: none;
}

/* 코드 본문 패딩(테마가 다르면 0일 수 있으니 기본값 보강) */
.highlight pre {
  padding-left: 1rem !important;
  padding-right: 1rem !important;
  box-sizing: border-box;
}

최종 결과

최종 결과는 아래 이미지와 같습니다. 아래 이미지와 같이 오른쪽 상단에는 복사하기 버튼이, 왼쪽 상단에는 코드 블럭에 적용된 언어이름이 표시 되는 것을 확인할 수 있습니다.

Comments