본문 바로가기

프로젝트 개발일지/와우 디자인 시스템

[와우 디자인 시스템 개발기] svg 파일 리액트 컴포넌트 변환 자동화 스크립트 작성

이번엔 svg 파일을 리액트 컴포넌트 방식으로 변환시켜 주는 자동화 스크립트를 작성했다.

 

이걸 한 이유는... svg 파일을 일일이 컴포넌트 방식으로 변환시켜 주는 게 너무 번거로워서 자동화할 방법이 없을까 싶어서였다.

하고 보니까 너무 편하더라.

그리고, 사실 스크립트 작성하면서 로직을 많이 생각해보는 과정에서 많이 공부할 수 있었던 거 같다.

처음에는 단순히 svg 파일을 리액트 컴포넌트 파일로 변환만 하는 스크립트를 작성했었는데, svg 파일을 삭제하고 나서 다시 스크립트를 실행시켜도 기존에 변환된 컴포넌트 파일은 삭제되지 않아서 이 부분을 고려하는 과정에서 자료구조도 생각해 보고 로직도 개선했던 거 같다.

 

주요 로직

내가 작성한 스크립트 파일은 generateReactComponentFromSvg라는 파일이고 코드는 아래 링크에 접속하면 볼 수 있다.

https://github.com/GDSC-Hongik/wow-design-system/blob/main/packages/scripts/generateReactComponentFromSvg.ts

 

그리고 주요 로직은 다음과 같다.

1. svg 폴더 내부 파일들을 불러와 해당 svg 파일명에 대응되는 컴포넌트명으로 구성된 map을 만든다.

2. 위에서 만든 map을 활용해 map에 존재하지 않는 컴포넌트명을 가진 컴포넌트 폴더 내부 파일들은 삭제한다.

3. 이미 변환된 컴포넌트 파일을 제외하고 새롭게 도입된 svg 파일은 컴포넌트로 변환한다.

4. 모든 아이콘을 내보낼 배럴 파일에 export 구문을 작성한다.

 

이게 끝이다.

처음엔 1, 2번 로직이 없었고, 모든 svg 파일들을 컴포넌트 방식으로 변환해서 덮어씌웠다.

 

1, 2번 로직은 몇 번의 테스트를 통해 개선했고, 3번의 변환된 컴포넌트 파일을 제외하는 부분은 코드 리뷰를 통해 제안 받았던 개선안이어서 이렇게 반영해서 최종 코드가 완성됐다.

 

코드

코드를 좀 더 자세히 살펴보도록 하자.

 

svg 폴더 내부 파일들을 불러와 해당 svg 파일명에 대응되는 컴포넌트명으로 구성된 map을 만든다.

위 로직에 해당하는 코드는 다음과 같다.

const generateSvgComponentMap = async () => {
  const svgFiles = (await fs.readdir(SVG_DIR)).reduce<SvgComponentMap>(
    (map, svgFile) => {
      const componentName = path
        .basename(svgFile, ".svg")
        .replace(/(^\w|-\w)/g, (match) => match.replace("-", "").toUpperCase());
      map[componentName] = svgFile;

      return map;
    },
    {}
  );

  return svgFiles;
};

 

reduce 구문에서는 컴포넌트명과 svg 파일명을 대응시키는데, svg 확장자를 제외한 이름을 불러와 파스칼 케이스로 변환한다.

그렇게 해서 reduce 구문을 돌면서 map을 완성한다.

 

위에서 만든 map을 활용해 map에 존재하지 않는 컴포넌트명을 가진 컴포넌트 폴더 내부 파일들은 삭제한다.

위 로직에 해당하는 코드는 다음과 같다.

const deleteUnusedComponentFiles = async (svgComponentMap: SvgComponentMap) => {
  if (!existsSync(COMPONENT_DIR)) {
    fs.mkdir(COMPONENT_DIR);
    return;
  }

  const componentFiles = await fs.readdir(COMPONENT_DIR);
  const componentFilesToDelete = componentFiles.filter((componentFile) => {
    const componentName = path.basename(componentFile, ".tsx");
    return !(componentName in svgComponentMap);
  });

  await Promise.all(
    componentFilesToDelete.map((file) => {
      const componentFilePath = path.resolve(COMPONENT_DIR, file);
      return fs.unlink(componentFilePath);
    })
  );
};

 

컴포넌트 폴더를 불러와서 svgComponentMap에 없는 이름을 가진 컴포넌트는 componentFilesToDelete라는 변수에 저장을 해둔다.

이는 현재 존재하는 svg 파일들에 대응되지 않는 컴포넌트 파일, 즉 삭제된 svg 파일이 변환되어 있던 파일이다.

삭제할 파일들을 쭉 map 함수를 통해 돌면서 삭제해준다.

 

이미 변환된 컴포넌트 파일을 제외하고 새롭게 도입된 svg 파일은 컴포넌트로 변환한다.

위 로직에 해당하는 코드는 다음과 같다.

const generateComponentFiles = async (svgComponentMap: SvgComponentMap) => {
  const components: string[] = [];

  for (const [componentName, svgFile] of Object.entries(svgComponentMap)) {
    const componentFilePath = path.resolve(
      COMPONENT_DIR,
      `${componentName}.tsx`
    );

    if (existsSync(componentFilePath)) {
      components.push(componentName);
      continue;
    }

    const svgFilePath = path.resolve(SVG_DIR, svgFile);
    const svgContent = (await fs.readFile(svgFilePath)).toString();

    const componentContent = createComponentContent(
      componentName,
      svgContent,
      svgFile
    );

    await fs.writeFile(componentFilePath, componentContent);
    components.push(componentName);
  }

  return components;
};

svgComponentMap에서 컴포넌트명과 svg 파일명을 가져와 이미 변환된 컴포넌트 파일이 있다면 다음 컴포넌트로 넘어간다.

변환된 파일이 없다면 이제 svg 파일 내용을 쭉 불러와 정해진 템플릿 기반으로 컴포넌트 방식으로 변환한 뒤 정해진 경로에 파일을 써준다.

 

컴포넌트 템플릿 및 변환 코드는 다음과 같다.

정규 표현식을 사용해서 속성을 커스텀해줬다.

const extractSvgAttributes = (svgContent: string) => {
  const widthMatch = svgContent.match(/width="(\d+)"/g);
  const heightMatch = svgContent.match(/height="(\d+)"/g);
  const viewBoxMatch = svgContent.match(/viewBox="([^"]*)"/g);

  return {
    width: widthMatch ? widthMatch[0] : "width = 24",
    height: heightMatch ? heightMatch[0] : "height = 24",
    viewBox: viewBoxMatch ? viewBoxMatch[0] : "viewBox = 0 0 24 24",
  };
};

const createComponentContent = (
  componentName: string,
  svgContent: string,
  svgFile: string
): string => {
  const iconName = path.basename(svgFile, ".svg");
  const hasStroke = svgContent.includes("stroke=");
  const fillAttributes = (svgContent.match(/fill="([^"]*)"/g) || []).filter(
    (attr) => attr !== 'fill="none"'
  );
  const hasFill = fillAttributes.length;
  const { width, height, viewBox } = extractSvgAttributes(svgContent);
  const propsString = `{ className, ${width}, ${height}, ${viewBox}${hasStroke || hasFill ? ` ${hasStroke ? ', stroke = "white"' : ""}${hasFill ? ', fill = "white"' : ""}` : ""}, ...rest }`;
  const modifiedSvgContent = svgContent
  	.replace(/-(\w)/g, (_, letter) => letter.toUpperCase())
    .replace(/width="(\d+)"/g, `width={width}`)
    .replace(/height="(\d+)"/g, `height={height}`)
    .replace(/viewBox="(.*?)"/g, `viewBox={viewBox}`)
    .replace(/<svg([^>]*)fill="[^"]*"([^>]*)>/, "<svg$1$2>")
    .replace(/fill="([^"]+)"/g, `fill={color[fill]}`)
    .replace(/stroke="([^"]+)"/g, `stroke={color[stroke]}`)
    .replace(
      /<svg([^>]*)>/,
      `<svg$1 aria-label="${iconName} icon" fill="none" ref={ref} className={className} {...rest}>`
    );

  return `
    import { forwardRef } from 'react';
    import { color } from "wowds-tokens";
    
    import type { IconProps } from "@/types/Icon.ts";

    const ${componentName} = forwardRef<SVGSVGElement, IconProps>(
      (${propsString}, ref) => {
        return (
          ${modifiedSvgContent}
        );
      }
    );    

    ${componentName}.displayName = '${componentName}';
    export default ${componentName};
  `;
};

이때, 코드 리뷰로 제안받은 내용이 있었는데, svg 파일에 stroke 속성이나 fill 속성 하나만 존재하는 경우 props 쪽에 두 속성을 정의해 주면 eslint 에러가 나니 개선하면 좋을 거 같다는 내용이었다.

이걸 커스텀하는 과정이 쉽지 않았고, 결국 위와 같이 복잡해 보이는 식이 완성됐다...

하지만 그 부분을 수정하니 진짜 이 스크립트만 작동시키면 웬만한 컴포넌트들은 그냥 바로바로 쓸 수 있어서 훨씬 좋았던 거 같다.

 

이제 마지막 로직이다.

모든 아이콘을 내보낼 배럴 파일에 export 구문을 작성한다.

위 로직에 해당하는 코드는 다음과 같다.

const generateExportFile = async (components: string[]) => {
  const EXPORT_FILE_PATH = "../wow-icons/src/component/index.ts";
  const exportFileContent = components
    .map(
      (component) =>
        `export { default as ${component} } from "./${component}.tsx";`
    )
    .join("\n");

  await fs.writeFile(EXPORT_FILE_PATH, exportFileContent);
};

 

위에서 컴포넌트 생성할 당시 저장해 뒀던 컴포넌트명을 쭉 돌면서 export 구문을 생성한다.

이렇게 하면 로직은 끝이다.

 

이 파일은 scripts 패키지에 구성해 두고 wow-icons 패키지에 스크립트를 등록해 준 후 사용하고 있다.

아래와 같이 빌드 전에 svg 폴더의 파일들을 모두 컴포넌트 방식으로 변환해 주고 해당 내용을 기반으로 빌드한다.

"scripts": {
    "build": "pnpm generate:icons && rm -rf dist && rollup -c --bundleConfigAsCjs && tsc-alias",
    "generate:icons": "tsx ../scripts/generateReactComponentFromSvg.ts && pnpm format && pnpm lint",
    "lint": "eslint --fix ./src/component/**/*.tsx",
    "format": "prettier --write ./src/component/**/*"
  },

 

이렇게 하니 정말 편했다.

유명한 디자인 시스템 레포지토리 가보면 다들 자동화를 많이 넣어놨는데 왜 쓰는지 알 거 같았다.

추후 더 개선해야 할 것들이 생긴다면 또 자동화해보고 싶다.