본문 바로가기

프로젝트 개발일지/warrr-ui 디자인 시스템

[warrr-ui 디자인 시스템 개발기] 아이콘 배포 및 자동화

이번 주는 아이콘에 대해서 자세히 알아보는 시간을 가졌다.

아이콘 npm 배포 및 자동화할 수 있는 방법이 있는지에 대해서 공부했다.

 

일단, 내가 GDSC 디자인 시스템을 개발하며 느꼈던 아이콘 관련 문제 상황은 다음과 같다.

1. svg 파일들을 일일이 내 프로젝트에 넣어 두어야 한다.

2. 해당 svg 파일을 리액트 컴포넌트 방식으로 변환해야 한다.

 

이 작업들을 수작업으로 하려다 보니 화가 났다.

그래서, 어떻게 하면 이 두 가지를 자동화할 수 있는지에 대해서 조사했던 것 같다.

다만, 시간 부족 이슈로 완전 자세하게는 못했는데, 다다음주에 직접 실습해 보면서 자세히 알아보려고 한다.

 

여러 디자인 시스템 아이콘 관리 방법 조사

일단, 여러 디자인 시스템 깃허브 레포를 둘러보면서, 어떤 방식으로 관리하고 있는지 알아봤다.

그중에서 인상적이었던 건 당근과 채널톡이었다.

 

사실, 당근과 채널톡에서 아이콘을 관리하는 방법은 비슷하다.

먼저, 내가 말한 첫 번째 문제 상황을 해결하기 위해서 피그마 플러그인을 직접 개발했다.

해당 피그마 플러그인에 대해서 잠깐 알아보자면, 피그마 상에 아이콘 프레임을 하나 두고, 해당 프레임에서 버튼 하나만 누르면 연결된 깃허브 레포지토리에 해당 아이콘 코드가 담긴 채로 PR이 올라간다.

이때, 이 아이콘 정보들은 icons.json이라는 파일에 추가되는데, 거기에 svg 형태로 저장이 된다.

아래 링크를 통해 확인해 보면 좋을 것 같다.

https://github.com/daangn/seed-icon/blob/main/.icona/icons.json

 

어쨌든 이렇게 가져온 정보를 가공해서 리액트 컴포넌트 방식으로 변환하는 스크립트를 작동시키면, 완성이다.

이렇게 하면 내가 말한 두 번째 문제 상황도 해결된다.

 

그런데, 팀원들과 스터디를 하면서 당근과 채널톡의 피그마 플러그인의 차이에 대해서도 알게 되었다.

당근은 아이콘을 담는 프레임 이름이 꼭 icona-frame이라고 되어 있어야 인식을 한다는 것이었고, 채널톡은 그에 상관없이 동작한다고 했다.

또, 당근은 웹뿐만 아니라 aos, ios까지 수용할 수 있도록 svg, xml, pdf, 리액트 컴포넌트 방식 모두를 지원하는 반면, 채널톡은 웹만 지원하도록 동작했다.

그리고 채널톡의 경우, 아무래도 사내에서 쓰는 플러그인이다 보니, 채널톡 관련 코드에 의존성이 많이 존재한다고 느꼈다.

 

이걸 보면서 느낀 점이 있었는데, 두 번째 문제 상황이야 기존에 했던 것처럼 스크립트 하나 짜면 잘 될 거 같다고 느꼈는데, 첫 번째 문제 상황의 경우 피그마 플러그인을 만드는 게 상당히 낯설고 어렵게 느껴졌다.

그런데 두 회사의 플러그인 코드를 보는데 생각보다 막 코드가 많다거나 하진 않았다.

물론 위에 언급한 플러그인을 사용해도 되긴 하겠지만, 기왕 할 거 플러그인까지 만드는 게 좋지 않나 싶은 생각이었다.

 

그래서 다다음주엔 꼭 피그마 플러그인을 만들어볼 예정이다.

 

svg 파일 리액트 컴포넌트 변환 스크립트 작성

일단 이번 주엔 svg 파일을 리액트 컴포넌트 방식으로 변환하는 간단한 코드를 작성하는 실습을 해봤다.

두 번의 시도 끝에 성공했다.

 

첫 번째 시도는 plop을 활용한 방법이었다.

스토리 코드, package.json, 컴포넌트 코드 등 템플릿을 정해두고 plop을 작동시키면 우리가 원하는 기본 파일들이 생성되는 것을 본 적이 있을 것이다.

그런 관점으로 svg도 리액트 컴포넌트로 변환할 수 있지 않을까?라는 생각을 가지고 해봤는데 결론부터 말하자면 실패했다.

 

일단, 내가 작성한 plop generator와 템플릿 코드는 다음과 같다.

plop.setGenerator("Icon", {
    description: "Convert SVG to React component",
    prompts: [],
    actions: () => {
      const actions = [];
      const svgDir = "../icons/src/svg";
      const svgFiles = fs
        .readdirSync(svgDir)
        .filter((file) => file.endsWith(".svg"));

      for (const file of svgFiles) {
        const componentName = path
          .basename(file, ".svg")
          .replace(/(^\w|-\w)/g, (match) =>
            match.replace("-", "").toUpperCase()
          );
        const svgFilePath = path.resolve(svgDir, file);
        const componentContent = fs.readFileSync(svgFilePath).toString();

        actions.push({
          type: "add",
          path: `../icons/src/react/{{pascalCase componentName}}.tsx`,
          templateFile: "templates/Icon.tsx.hbs",
          data: {
            componentName,
            componentContent,
          },
        });
      }

      return actions;
    },
  });
const {{componentName}} = () => { 
  return `{{componentContent}}`; 
}

export default {{componentName}};

 

간단하게 설명하자면, 로직은 다음과 같다.

1. svg 파일들이 들어있는 디렉토리에서 svg 확장자를 가진 파일들을 모두 불러온다.

2. 한 파일씩 순회하면서 컴포넌트 파일로 변환하기 위해서 필요한 정보들을 모은다.

3. 원하는 경로에 공통 아이콘 템플릿을 이용해서 컴포넌트 방식 아이콘을 생성한다.

 

잘 될 줄 알았는데 결과물은 아래와 같았다.

const UpArrow = () => { 
  return `<svg
  fill="none"
  height="24"
  viewBox="0 0 24 24"
  width="24"
  xmlns="http://www.w3.org/2000/svg"
>
  <g clip-path="url(#clip0_36_3519)">
    <path
      d="M19 12L5 12"
      stroke="#E4E4E5"
      stroke-linecap="round"
      stroke-linejoin="round"
      stroke-width="1.6"
    />
    <path
      d="M19 12L13 6"
      stroke="#E4E4E5"
      stroke-linecap="round"
      stroke-linejoin="round"
      stroke-width="1.6"
    />
    <path
      d="M19 12L13 18"
      stroke="#E4E4E5"
      stroke-linecap="round"
      stroke-linejoin="round"
      stroke-width="1.6"
    />
  </g>
  <defs>
    <clipPath id="clip0_36_3519">
      <rect fill="white" height="24" width="24" />
    </clipPath>
  </defs>
</svg>
`; 
}

export default UpArrow;

 

plop은 svg를 변환하는 과정에서 HTML 엔티티로 변환하는 것 같았다.

그래서 좀 찾아봤는데, 이런 이슈 관련해서는 별도의 방안을 두진 않은 거 같고, 다른 템플릿 관련 라이브러리를 쓰라고 하는 것 같길래 깔끔하게 포기하고 스크립트 방식으로 도전했다.

 

사실 스크립트는 이전에 몇 번 작성해봤기 때문에 작성하는데 오래 걸리지 않았다.

로직은 다음과 같다.

1. svg 파일이 들어있는 디렉토리에서 svg 확장자를 가진 파일들을 불러온다.

2. 한 파일씩 순회하면서 컴포넌트 방식으로 변환하기 위한 정보들을 모은다.

3. 컴포넌트 내부 내용을 prettier로 포맷팅한다.

4. 컴포넌트 파일에 내용을 채워준다.

5. rollup에서 빌드할 때 input 파일로 지정해 줄 index.ts 파일에 모든 아이콘에 대해 export 구문을 작성한다.

 

코드는 다음과 같다.

import { promises as fs } from "fs";
import path from "path";
import prettier from "prettier";

const generateReactComponentFromSvg = async () => {
  const svgDir = "../icons/src/svg";
  const svgFiles = (await fs.readdir(svgDir)).filter((file) =>
    file.endsWith(".svg")
  );

  const components = [];

  for (const file of svgFiles) {
    const componentName = path
      .basename(file, ".svg")
      .replace(/(^\w|-\w)/g, (match) => match.replace("-", "").toUpperCase());
    components.push(componentName);
    const svgFilePath = path.resolve(svgDir, file);
    const svgContent = (await fs.readFile(svgFilePath)).toString();

    const componentContent = `
      import type { SVGProps } from 'react';
      import { Ref, forwardRef } from 'react';

      const ${componentName} = (
        {
          size = 24,
          ...props
        }: SVGProps<SVGSVGElement> & {
          size?: number | string,
        },
        ref: Ref<SVGSVGElement>
      ) => (
        ${svgContent.replace(/-(\w)/g, (_, letter) => letter.toUpperCase())}
      );

      const ForwardRef = forwardRef(${componentName});
      export default ForwardRef;
    `;
    const componentDir = "../icons/src/react";
    const componentFilePath = path.resolve(
      componentDir,
      `${componentName}.tsx`
    );

    const formattedComponentContent = await prettier.format(componentContent, {
      parser: "typescript",
    });

    await fs.writeFile(componentFilePath, formattedComponentContent);
  }

  return components;
};

const generateEntryFile = async (components: string[]) => {
  const entryFilePath = "../icons/src/react/index.ts";
  const entryFileContent = components
    .map(
      (component) =>
        `export { default as ${component} } from "./${component}.tsx";`
    )
    .join("\n");
  const formattedEntryFileContent = await prettier.format(entryFileContent, {
    parser: "typescript",
  });

  await fs.writeFile(entryFilePath, formattedEntryFileContent);
};

(async () => {
  const components = await generateReactComponentFromSvg();
  generateEntryFile(components);
})();

 

중간에 보면 replace 관련 내용이 나오는데 하이픈으로 연결되어 있는 속성들을 camelCase 방식으로 변환하도록 해놨다.

svg 파일에서 stroke-linecap 등으로 되어 있지만 컴포넌트 파일에서는 strokeLinecap을 보여야 하기 때문에...

 

그리고 사실 위에서 사용한 컴포넌트 템플릿은 팀원들과 확장성 있게 사용할 수 있는 템플릿을 정의해서 변경만 해주면 된다.

이건 추후에 개발할 때 정하겠..지..?

 

아래는 위 스크립트를 실행시켜 본 영상이다.

svg 파일도 잘 변환되고 export 구문이 담긴 index.ts 파일도 잘 생성되는 것을 볼 수 있었다.