돌아가기
#github
#readme
#satori
#jsx

JSX로 깃허브 리드미 꾸미기 (with. Satori)

Satori를 통해 HTML과 CSS를 SVG로 변환할 수 있다는 것을 발견하고, 이를 활용해 JSX 컴포넌트로 깃허브 리드미를 꾸미는 방법을 소개하려고 한다.

먼저 완성본이다.

  • Preferred Tech와 Toy Project등의 heading 글꼴을 서울알림체 를 사용하였다.
  • 라이트/다크 모드 모두 반응하도록 설정하였다.
  • 커스텀 배너를 제작하여 Toy Project에 추가하였다.

로딩 중...windoper-github-readme

로딩 중...light-windoper-github-readme

시작하기

먼저 리드미 레포지토리를 로컬 환경에 클론한다.

리드미 레포지토리는 본인 닉네임과 동일한 이름으로 생성되어 있으며, 없다면 생성하면 된다.

bash
git clone https://github.com/<your-nickname>/<your-nickname>.git

처음 레포지토리를 만들었거나 따로 리드미 레포지토리를 건드린 적이 없다면 파일 구조는 다음과 같을 것이다.

README.md리드미 파일

그 후, 레포지토리 루트에서 다음 명령어를 실행하여 프로젝트를 초기화한다.

bash
npm init

이제 리드미 파일을 꾸미기 위해 필요한 패키지를 설치한다.

  • Satori : HTML과 CSS를 SVG로 변환해주는 라이브러리
  • React : 리액트
  • Babel : 자바스크립트 트랜스파일러
    • @babel/cli : Babel 명령행 인터페이스
    • @babel/core : Babel 핵심 라이브러리
    • @babel/preset-react : React 프리셋
    • babel-plugin-add-import-extension : 자동으로 import 문을 추가하는 플러그인
bash
npm install satori react @babel/cli @babel/core @babel/preset-react babel-plugin-add-import-extension

설치가 완료되었다면, 기본 설정을 위해 .babelrc 파일을 생성한다.

json
// .babelrc
{
  "presets": ["@babel/preset-react"],
  "plugins": [
    ["babel-plugin-add-import-extension", { "extension": "js" }]
  ]
}
.babelrc 파일 설정 설명
펼치기

package.json 파일 스크립트 및 모듈 설정도 추가한다.

json
// package.json
...
  "scripts": {
    "prebuild": "babel src --out-dir dist", // 빌드 전에 바벨로 트랜스파일
    "build": "node dist/main.js" // 트랜스파일된 파일을 실행
  },
  "type": "module",
...

마지막으로 src 디렉토리와 resources 디렉토리를 생성하고, src 디렉토리 안에 render.jsx 파일을 생성한다.

지금까지의 파일 구조는 다음과 같다.

README.md
src/
render.jsx
resources/
package.json
package-lock.json
.babelrc

SVG 렌더 함수 작성

이제 작성한 JSX 컴포넌트를 SVG로 렌더링하는 함수를 작성한다. render.jsx 파일을 열고 다음 코드를 작성한다.

satori를 사용하여 SVG로 렌더링하고 이를 따로 저장한다.

폰트의 경우 TTF, OTF, WOFF 형식만 사용할 수 있다. 미리 폰트를 다운받아 레포지토리 루트에 위치시킨다.

  • 이모지의 경우 유니코드 코드 포인트를 16진수로 변환하여 이모지를 추출한다.
  • 이모지 추출 실패 시 기본 이모지를 반환한다.
  • SVG 변환 후 resources 디렉토리에 저장한다.
jsx
import React from 'react';
import satori from 'satori';
import fs from 'fs';
 
export async function generateSvg(element, outputName, options = {}) {
  const font = fs.readFileSync('./SeoulAlrimTTF-Heavy.ttf')
 
  const svg = await satori(
    element,
    {
      width: options.width,
      height: options.height,
      fonts: [
        {
          name: "SeoulAlrimTTF-Heavy",
          data: font,
          weight: 900,
          style: "normal",
        },
      ],
      loadAdditionalAsset: async (code, segment) => {
        if (code === "emoji") {
          // 유니코드 이모지 코드 포인트 16진수 변환
          const codePoint = segment.codePointAt(0).toString(16);
          
          try {
            // Twemoji SVG API를 사용하여 이모지 가져오기
            const response = await fetch(`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoint}.svg`);
            
            if (response.ok) {
              const svgText = await response.text();
              // SVG 텍스트를 base64로 인코딩하여 반환
              return `data:image/svg+xml;base64,${Buffer.from(svgText).toString('base64')}`;
            }
          } catch (error) {
            console.warn(`Failed to load emoji ${segment}:`, error);
          }
          
          // 이모지 로드 실패 fallback
          return `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72"><rect width="72" height="72" fill="#ccc"/><text x="36" y="45" text-anchor="middle" font-size="20">?</text></svg>').toString('base64')}`;
        }
        
        return undefined;
      }
    }
  );
 
  fs.writeFileSync(`./resources/${outputName}.svg`, svg);
  return svg;
}

함수 테스트

이제 작성한 함수를 테스트해보자. src 디렉토리 안에 main.jsx 파일을 생성하고, 다음 코드를 작성한다.

jsx
import React from "react";
import { generateSvg } from "./render";
 
async function main() {
  await generateSvg(<div>✨ 테스트입니다</div>, 'test');
}
 
main();

코드 작성 후 빌드를 실행한다.

bash
npm run build

빌드 후에 resources 디렉토리에 테스트 SVG 파일이 생성된 것을 확인할 수 있다. SVG 프리뷰로 열어보면 다음과 같다.

로딩 중...test-svg

리드미에 추가하여 확인해보면 다음과 같다.

로딩 중...test-readme

라이트/다크 모드에 반응하도록 설정

picture 요소를 사용하여 사용자의 시스템에 따라 이미지가 자동으로 변경되도록 설정한다.

html
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="다크모드경로">
  <img src="라이트모드경로" />
</picture>

여러 컴포넌트 작성하기

이제 본인이 원하는대로 컴포넌트를 작성하면 된다. 코드 가독성을 위해 컴포넌트를 여러 파일로 분리할 수 있다.

jsx
import React from "react";
import { generateSvg } from "./render";
import Kamilereon from "./kamilereon";
import Takeoff from "./takeoff";
 
async function main() {
  await generateSvg(<Title darkMode>✨ Toy Project</Title>, "darkToyProject");
 
  await generateSvg(<Title darkMode>⚡ Preferred Tech</Title>, "darkFavoriteTech");
 
  await generateSvg(<Title darkMode>💻 Coding Activity</Title>, "darkCodingActivity");
 
  await generateSvg(<Title darkMode>💡 Algorithm Problem Solving</Title>, "darkAlgorithmProblemSolving");
 
  await generateSvg(<Title>✨ Toy Project</Title>, "toyProject");
  await generateSvg(<Title>⚡ Preferred Tech</Title>, "favoriteTech");
  await generateSvg(<Title>💻 Coding Activity</Title>, "codingActivity");
  await generateSvg(
    <Title>💡 Algorithm Problem Solving</Title>,
    "algorithmProblemSolving"
  );
 
  await generateSvg(<Kamilereon />, "kamilereon", 
    { width: 600, height: 60 });
 
  await generateSvg(<Takeoff />, "takeoff", 
    { width: 600, height: 60 });
}
 
function Title({ children, darkMode = false }) {
  return (
    <div
      style={{
        position: "relative",
        color: darkMode ? "white" : "black",
        display: "flex",
        fontSize: "24px",
        fontFamily: "SeoulAlrimTTF-Heavy",
        padding: "5px 0px",
      }}
    >
      {children}
    </div>
  );
}
 
main();

전체코드는 여기에서 확인할 수 있다.

추가로

OG Image Playground