youngminss-log

블로그 개발기 (Code-Block Copy Button)

Server-Side-Rendering
Client-Side-Rendering
youngmin's profile
Youngmin2024-11-11
Reading Time : 11 min
post-body-thumbnail

안녕하세요 youngminss 입니다. 저번 블로그 개발기 (Code-Block Customizing) 에서 글이 길어져 함께 소개하지 못한 코드 블록 복사 기능을 추가하는 방법을 소개하려 합니다.



✅ Rehype-Pretty-Code 의 Copy Button 플러그인 적용 (Experimental)

코드 블록 복사 기능 추가를 위해 시도했던 첫 번째 방법은 코드 블록 스타일 커스텀하는 단계의 첫 단계에서 적용했던 Rehype-Pretty-Code 플러그인에서 제공하는 Copy Button 플러그인 을 사용했습니다.

Experimental 기능이라고 표현해 놓았지만 블록 복사 기능은 제 기준에서는 선택 기능이라고 생각했기 때문에 기능 역할을 잘 한다면 빠르게 적용하고 넘어가도 좋다고 생각했습니다.

이 기능을 사용하기 위해서는 @rehype-pretty/transformers 의존성 패키지를 추가로 설치해야 합니다.

npm install @rehype-pretty/transformers

설치한 후 가이드처럼 적용도 간단해 보입니다. rehypePlugins 에 주입할 때 선언해 놓았던 rehype-pretty-code 의 Options 타입인 객체에 설치했던 @rehype-pretty/transformers 로부터 transformerCopyButton 을 import 해서 transformers 속성에 다음과 같이 할당해 줍니다.

@components/post/PostBody.tsx
...
import rehypePrettyCode, { type Options } from "rehype-pretty-code";
import { transformerCopyButton } from "@rehype-pretty/transformers";
...
 
const prettyCodeOptions: Options = {
  ...
  transformers: [
    transformerCopyButton({
      visibility: "always",
      feedbackDuration: 3000,
    }),
  ],
};
 
 
const PostBody = ({ post }: { post: TPost }) => {
  ...
  return (
      <MDXRemote
        ...
		options={{
          mdxOptions: {
            ...
            rehypePlugins: [
              ...
              [rehypePrettyCode, prettyCodeOptions],
            ],
          },
        }}
      />
  );
};
 
export default PostBody;

참고로 transformers 속성은 아래와 같이 ShikiTransformer 타입을 요소로 가지는 Array 형태의 데이터가 있어야 하는데요.

rehype-pretty-code/dist/index.d.ts - Options interface
interface Options {
  grid?: boolean;
  theme?: Theme | Record<string, Theme>;
  keepBackground?: boolean;
  defaultLang?:
    | string
    | {
        block?: string;
        inline?: string;
      };
  tokensMap?: Record<string, string>;
  transformers?: Array<ShikiTransformer>;
  filterMetaString?(str: string): string;
  getHighlighter?(
    options: BundledHighlighterOptions<any, any>,
  ): Promise<Highlighter>;
  onVisitLine?(element: LineElement): void;
  onVisitHighlightedLine?(element: LineElement, id: string | undefined): void;
  onVisitHighlightedChars?(element: CharsElement, id: string | undefined): void;
  onVisitTitle?(element: Element): void;
  onVisitCaption?(element: Element): void;
}

transformerCopyButton 함수의 반환 값이 ShikiTransformer 타입이기 때문에 할당이 가능한 것입니다.

declare function transformerCopyButton(
  options?: CopyButtonOptions,
): ShikiTransformer;

transformerCopyButton 함수의 param 으로는 options 객체를 받습니다. CopyButtonOptions 타입의 이 객체는 아래와 같은 타입의 속성을 제공합니다. (자세한 명세는 여기를 확인하세요.)

interface CopyButtonOptions {
  feedbackDuration?: number; // copy 되었을 경우 UI 가 얼마나 보일 것인지
  copyIcon?: string; // copy 버튼 아이콘
  successIcon?: string; // copy 성공 시 아이콘
  visibility?: "hover" | "always"; // copy 버튼이 언제 보일 것인지
}

여기까지 진행했다면 다음과 같이 코드 블록 우측 상단에 코드 블록 복사 버튼이 보이는 것을 확인할 수 있습니다. 하지만, 그와 함께 좌측 하단에 불안한 에러 메시지도 함께 보이네요 🤔

image-1


😢 (SSR 환경에서) 발생한 이슈

에러 메시지 확인해 봤을 때와 실제 렌더링 된 코드 블록 복사 버튼의 결과를 보면 각각 다음과 같습니다.

에러 메시지실제 렌더링된 코드 블록 복사 버튼
image-2
image-3

일단 관련된 문제에 대한 Github issue 을 확인해 볼 수 있었으며 해당 이슈에 대한 관리자에 답변 도 확인할 수 있습니다. 결론은 Server Component 환경에서는 정상적으로 작동하지 않는 문제가 있다는 것입니다.(2024.08.27 기준)

에러 메시지가 의미하는 것은 button 에 대해 onClick 핸들러가 정의되어 있지 않으며, data 속성에 string 형태의 코드들이 삽입되어 있기 때문으로 파악했습니다. 결론적으로 해당 방법은 실패 !



✅ 직접 구현하자

스펙 파악

직접 구현하기 전에 이 버튼의 스펙을 정의해 보면 다음과 같습니다.

  • 사용자가 버튼을 클릭하면 버튼이 포함된 코드 블록 들의 내용을 클립보드에 복사하는 기능이 있으면 된다.
  • 이 버튼은 SEO 에 중요한 요소는 아니다.

사용자의 액션 이 필요하되, SEO 에는 중요하지 않다면 이 부분만 Client Component 로 만들어 주입해 주면 될 것 같습니다.


구현

마크다운으로 작성한 코드 블록이 렌더링 된 결과에서는 어떤 식으로 노출되는지 확인했습니다.

image-4

실제 코드 블록이 담기는 구조는 대략 pre 👉 code -> ... 구조입니다. 상세한 구조까지는 필요가 없고 코드 블록이 담기는 구조의 시작이 pre 태그가 래핑하고 있다는 것이 포인트입니다.

위 정보까지 파악한 다음에는 MDXRemote 의 Component 재정의를 활용해서 접근하면 됩니다. 첨부한 링크의 예제에서 확인할 수 있듯이 MDX 의 Component 에서 제공하는 element 에 대해 원하는 형태로 사용자의 환경에 맞게 재정의가 가능합니다. (remark-gfm 플러그인을 사용하면 기본 지원 element 외 것들도 제어가 가능합니다.)

다시 복사 버튼 기능 추가로 돌아와서, 우리는 그럼 코드 블럭이 담기는 pre 태그를 재정의해서 기존 구조에서 Client Component 로 생성한 코드 블록 복사 버튼을 주입해주면 되는 것 아닐까요 ?

먼저 코드 블록 복사 버튼 부터 정의해야겠습니다.

"use client";
 
import { Check, Copy } from "lucide-react";
import { DetailedHTMLProps, HTMLAttributes, useRef, useState } from "react";
 
const CustomCodeBlock = ({
  className = "",
  children,
  ...props
}: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) => {
  const [isCopied, setIsCopied] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
 
  const preRef = useRef<HTMLPreElement>(null);
 
  // 코드 복사 버튼 이벤트 핸들러
  const handleClickCopy = async () => {
    const code = preRef.current?.textContent;  // 코드 블록 내용 추출
 
    if (code) {
      setIsLoading(true);
      await navigator.clipboard.writeText(code);  // 클립보드에 복사
      setIsLoading(false);
      setIsCopied(true);
 
      setTimeout(() => {
        setIsCopied(false);
      }, 1500);
    }
  };
 
  return (
   {/* 커스텀 코드 블록 컨테이너 */}
    <div className="relative">
      {/* 코드 블록 복사 버튼 */}
      <button
        disabled={isCopied || isLoading}
        aria-label={isCopied ? "Copied!" : "Copy code"}
        onClick={handleClickCopy}
        className="absolute right-[1rem] top-[1rem] flex h-fit w-fit items-center gap-x-[0.4rem] rounded-[0.4rem] bg-black px-[0.8rem] py-[0.4rem] !text-[rgb(220,220,213)]"
      >
        <span className="...">
          {isCopied ? <Check size="100%" /> : <Copy size="100%" />}
        </span>
        <span className="...">{isCopied ? "Copied !" : "Copy"}</span>
      </button>
 
      {/* 코드 블록 */}
      <pre ref={preRef} {...props} className={className}>
        {children}
      </pre>
    </div>
  );
};
 
export default CustomCodeBlock;
 

이제 정의한 CustomCodeBlock 컴포넌트를 MDXRemote 컴포넌트 components 속성의 pre 태그 에 매핑시켜주면 됩니다.

import { MDXRemote } from "next-mdx-remote/rsc";
import CustomCodeBlock from "./common/CustomCodeBlock";
...
 
const components = {
  ...,
  pre: (props: any) => <CustomCodeBlock {...props} />,
};
 
...
 
const PostBody = ({ post }: { post: TPost }) => {
  ...
  return (
      <MDXRemote
        components={{ ...components }}
        ...
      />
  );
};
 
 
export default PostBody;

그러면 다음과 같이 의도한 코드 블록 복사 기능을 추가할 수 있습니다.

video-1

CustomCodeBlock 의 JSX 를 보면 pre 태그 내부에 button 태그를 배치한 것이 아닌 button 과 pre 태그를 래핑하는 div 태그가 존재하는데요. 이유는 pre 태그에 담기는 코드 블록에서 특정 line 의 내용이 길어질 경우 스크롤이 생길 수 있습니다. 이때 코드 블록 복사 버튼이 가로로 스크롤 해도 우측 상단에 fixed 하게 고정되길 바랬던 것과 달리 pre 태그 기준으로 우측 상단에 위치해 애매하게 배치되는 것을 해결하고자 한 의도입니다.

As-isTo-be
video-3
video-2


👋 마무리하며

이번 글에서는 지난 블로그 개발기 (Code-Block Customizing) 에 이어 생성된 코드 블록의 내용을 클립보드에 복사할 수 있는 버튼을 구현했던 과정을 소개했습니다. 선택 기능이긴 했으나 레퍼런스로 참고한 타 블로그에서도 많이 볼 수 있었고 구현하는 단계에서 참고할 만할 것도 많아서 생각보다 쉽게 완성할 수 있었습니다.

혹시나 비슷한 개발 환경에서 블로그 개발 중, 코드 블록 복사 기능을 추가하고 싶다면 참고해 봐도 좋습니다. 긴 글 읽어주셔서 감사합니다 😋

오류 제보나 피드백은 언제나 환영입니다. 🙂