🐶

SVGコンポーネントの自動生成処理を作ってみた。

2023/01/19に公開

こんにちは株式会社スペースマーケットのwado63です。

僕が働いているスペースマーケットでは、毎週金曜日にチャプタータイムという時間があります。
その中で技術的負債の返却や技術的な探究を行う時間があり、その時間を利用してfigmaからSVGコンポーネントを生成するスクリプトを作成しましたのでご紹介をいたします。

スペースマーケットのチャプターについてはこちらをご覧ください。
https://note.com/narihara/n/n9e84e31fb967

はじめに

FigmaからSVGアイコンを出力するという記事は既にいくつもあります。

https://tjmschk.hatenablog.com/entry/figma-svg-to-github
https://zenn.dev/kodai/articles/7d84b5ddfea928

僕自身も上記の1番目の記事を過去に見ていて、参考に作ったら2番目の記事とほぼ同じ内容が出来ていました笑

先人の記事に感謝しつつ、実際に実装する際に詰まった箇所や、ちょっと工夫した点などを紹介したいと思います。

作ったもの

FigmaのAPIからReactのアイコンコンポーネントを自動生成し、PRを作成するgithub actionを作りました。

  1. Github Actionsで以下のスクリプトを実行
  2. figmaからコンポーネント一覧を取得
  3. コンポーネントのmeta情報からSVGのコードを取得
  4. svgrを使って変換
  5. 差分があればPRを作成

その他: 動的にアイコンを表示するIconsコンポーネント

figmaからコンポーネント一覧を取得 / コンポーネントのmeta情報からSVGのコードを取得

まずはコンポーネントのmeta情報を取得します。

注意点としてはfigmaのコンポーネントをAPIから取得しようとした場合、公開しているコンポーネントでないと取得できません。
実際に一個だけLocalComponentになっており追加されないコンポーネントがあってハマりました。
https://forum.figma.com/t/how-do-i-get-figma-assets-components-variants-via-rest-api/7365/2

以下のコードはAPIからコンポーネントの一覧を取得し、meta情報からSVGのコードを取得し、
アイコン名、コンポーネント名、SVGコードを配列にまとめて返す処理です。

fetchComponentsAndSVGImages.ts
/**
 * @file figmaのAPIを用いてComponentListを取得し、
 * そこからSVGのURLを取得して、SVGコードを取得する
 *
 * そしてアイコン名, コンポーネント名, SVGのコードをセットにした配列を返す
 */

// NOTE: figmaのAPIに型を当てたものです。
import { FigmaAPIResponse, FigmaImagesAPIResponse, Component } from './apiType'

type SvgList = { name: string; componentName: string; svgCode: string }[]

const FILE_ID = 'xxxxxxxxxxxxxx'
const NAME_PREFIX = 'icon/' // figmaのコンポーネントでiconか否かを判定するためのprefix

/**
 * figmaのAPIからmeta情報を取得
 */
const fetchComponentList = async (token: string): Promise<FigmaAPIResponse> =>
  fetch(`https://api.figma.com/v1/files/${FILE_ID}/components`, {
    headers: {
      'X-FIGMA-TOKEN': token,
    },
  })
    .then((res) => res.json())

/**
 * 1文字目を大文字に変更する
 */
const convertFirstCharToUpperCase = (str: string): string =>
  str.replace(
    /(^\w)(.*)/g,
    (_match, first, restText) => `${first.toUpperCase()}${restText}`,
  )

/**
 * Figma APIから取得したコンポーネント一覧をSVGコードに変換して返す
 */
const fetchSVGImages = async (
  components: Component[],
  token: string,
): Promise<SvgList> => {
  const nodeIds = components
    .filter(({ name }) => name.includes(NAME_PREFIX))
    .filter(({ name }) => !/[_-]/.exec(name)) // 命名規則は icon/iconName なのでsnakeやkebabは除外
    .map(({ node_id }) => node_id)
    .join()

  const imageResponse: FigmaImagesAPIResponse = await fetch(
    `https://api.figma.com/v1/images/${FILE_ID}?ids=${nodeIds}&format=svg`,
    {
      headers: {
        'X-Figma-Token': token,
      },
    },
  )
    .then((res) => res.json())

  const imageNameAndUrls = Object.entries(imageResponse.images).map(
    ([nodeId, url]) => {
      const name = components.find(({ node_id }) => nodeId === node_id)?.name
      if (name === undefined) throw new Error(`name is undefined`)
      const nameWithoutPrefix = name.replace(NAME_PREFIX, '')
      const pascalCaseName = convertFirstCharToUpperCase(nameWithoutPrefix)
      return {
        name: nameWithoutPrefix, // e.g. "accessibility"
        componentName: `Icon${pascalCaseName}`, // e.g. "IconAccessibility"
        url, // e.g. "https://s3-alpha.figma.com/profile/......"
      }
    },
  )

  const svgList: SvgList = await Promise.all(
    imageNameAndUrls.map(async ({ url, ...props }) => {
      const svgCode = await fetch(url)
        .then((res) => res.text())
        .then((data) => data)
      return {
        svgCode,
	...props
      }
    }),
  )

  return svgList
}

/**
 * Figma APIからコンポーネントのSVGを取得する
 */
export const fetchComponentsAndSVGImages = async (
  token: string,
): Promise<SvgList> => {
  const {
    meta: { components },
  } = await fetchComponentList(token)

  return fetchSVGImages(components, token)
}

この辺の処理は他の記事でも書かれているので、色々と参考にしてみてください。

SVGからアイコンの変換

SVGからアイコンコンポーネントへの変換はお馴染みSVGRを使用します。
https://react-svgr.com/

以下は先ほどFigmaから取得したSVGをアイコンに変換する処理です。

transformSVGImagesToIcons.ts
/**
 * @file figmaのAPIを用いてSVGコードを取得し、コンポーネントに変換して保存する
 */

import { Config, transform } from '@svgr/core'

type SvgList = { name: string; componentName: string; svgCode: string }[]

/**
 * svgrで使用するコンポーネントテンプレート
 */
const componentTemplate: Config['template'] = (
  { componentName, jsx },
  { tpl },
) => tpl`
import { FC } from "react";
import { Icon, IconProps } from '@chakra-ui/react';

const IconBase:FC<IconProps> = (props) => ${jsx}
export const ${componentName} = IconBase
`

/**
 * svgrを用いてSVGコードをコンポーネントに変換する
 */
const transformSvgToComponentAsync = (
  componentName: string,
  svgCode: string,
): Promise<string> =>
  transform(
    svgCode,
    {
      icon: true,
      template: componentTemplate,
      replaceAttrValues: {
        '#222': 'currentColor',
      },
      typescript: true,
      prettier: true,
      runtimeConfig: true,
      prettierConfig: {
        tabWidth: 2,
        singleQuote: true,
        semi: false,
        trailingComma: 'all',
        printWidth: 80,
      },
      svgProps: {
        fontSize: '0.875em',
        verticalAlign: '-0.0625em',
      },
      plugins: [
        '@svgr/plugin-svgo',
        '@svgr/plugin-jsx',
        '@svgr/plugin-prettier',
      ],
    },
    { componentName },
  )

/**
 * SVGの配列をアイコンに変換
 */
export const transformSVGImagesToIcons = async (
  svgList: SvgList,
): Promise<
  {
    name: string
    componentName: string
    componentCode: string
  }[]
> => {
  const promises = svgList.map(async ({ name, componentName, svgCode }) => {
    const componentCode = await transformSvgToComponentAsync(
      componentName,
      svgCode,
    )
    return { name, componentName, componentCode }
  })
  const icons = await Promise.all(promises)

  return icons.map(({ name, componentName, componentCode }) => ({
    name,
    componentName,
    componentCode: componentCode
      .replace('<svg', '<Icon')
      .replace('</svg', '</Icon'),
  }))
}

/**
 * 各コンポーネントをexportするindex.tsを作成する
 */
export const generateIndexFile = (
  componentInformation: {
    name: string
    componentName: string
  }[],
) => {
  const exportTemplate = (componentName: string) =>
    `export { ${componentName} } from './${componentName}'`
  const exports = componentInformation
    .map(({ componentName }) => componentName)
    .map(exportTemplate)
    .sort()
    .join('\n')
  const iconKeys = `export type IconKeys =
${componentInformation
  .map(({ name }) => name)
  .sort()
  .map((name) => `  | '${name}'`)
  .join('\n')}
`
  const iconImportMap = `export { iconImportMap } from './iconImportMap'`

  const code = `${exports}\n${iconKeys}\n${iconImportMap}\n`
  return code
}

/**
 * dynamic importするためにimportのpathをまとめたObjectを生成する
 */
export const generateIconImportMap = (
  componentInformation: {
    name: string
    componentName: string
  }[],
) => {
  const iconImportMap = `export const iconImportMap = {
${componentInformation
  .sort(({ name: a }, { name: b }) => (a > b ? 1 : -1))
  .map(
    ({ name, componentName }) =>
      `  ${name}: () => import('./${componentName}'),`,
  )
  .join('\n')}
} as const
`
  return iconImportMap
}

基本的にはSVGRのドキュメントに従えばSVGをアイコンに変換できます。とても簡単でした。

ポイント1

  return icons.map(({ name, componentName, componentCode }) => ({
    name,
    componentName,
    componentCode: componentCode
      .replace('<svg', '<Icon')
      .replace('</svg', '</Icon'),
  }))

ちょっと手を加えた点としてはスペースマーケットではChakra-UIを使用しているので、svgのタグをChakra-UIのIconに置きかえています。
SVGRの処理の中でタグを差し替えれるかなと思っていたのですが、うまく出来ずreplaceを使って無理やり置き換えています。ちょっと恥ずかしいです笑

ポイント2

  svgProps: {
    fontSize: '0.875em',
    verticalAlign: '-0.0625em',
  },

アイコンと文字を揃えるときのスタイルです。
スペースマーケットではアイコンを文字と揃える時に文字の0.875倍の大きさとしています。
あらかじめこのようなスタイルを当てておけばinlineで使うときはいい感じの大きさとなりますし、
アイコン単体で使用する場合、別途width, heightを当てればデフォルトの1emが上書きされて、狙った通りの大きさで表示することができます。

ポイント3

export const generateIconImportMap = (
  componentInformation: {
    name: string
    componentName: string
  }[],
) => {
  const iconImportMap = `export const iconImportMap = {
${componentInformation
  .sort(({ name: a }, { name: b }) => (a > b ? 1 : -1))
  .map(
    ({ name, componentName }) =>
      `  ${name}: () => import('./${componentName}'),`,
  )
  .join('\n')}
} as const
`
  return iconImportMap
}

あとで説明するのですが、アイコンを動的に読み込めるようdynamic importのpathをまとめたオブジェクトを作っておきます。

export const iconImportMap = {
  accessibility: () => import('./IconAccessibility'),
  addAcount: () => import('./IconAddAcount'),
  airConditioner: () => import('./IconAirConditioner'),
}

このようなファイルが出来ます。

Github ActionでのPR自動生成

アイコンの自動生成のPRは以下のGithub Actionsで動かしています。

name: Update SVG Component
run-name: ${{ github.actor }} dispatch GitHub Actions🚀
on:
  workflow_dispatch:

jobs:
  update-svg-component:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16

      - uses: actions/cache@v3
        id: node_modules_cache_id
        env:
          cache-name: cache-node-modules
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn-lock.json') }}

      - name: Check cache hit
        run: echo '${{ toJSON(steps.node_modules_cache_id.outputs) }}'

      - name: If cache hit, skip install
        if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
        run: yarn install --flozen-lockfile

      - name: Run icongen script
        env:
          FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
        run: yarn run icongen --token="$FIGMA_TOKEN"

      - name: if no changes, exit
        id: diff
        run: |
          git add -N .
          git diff --name-only --exit-code
        continue-on-error: true

      - name: Create Pull Request
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        if: steps.diff.outcome == 'failure'
        run: |
          TODAY=$(date '+%Y-%m-%d')
          BRANCH="update-icon-components-${TODAY}"
          git config --local user.name 'github-actions[bot]'
          git config --local user.email 'github-actions[bot]@users.noreply.github.com'
          git checkout -b $BRANCH
          git add .
          git commit -m "chore: update icon components"
          echo "push to $BRANCH"
          git push -u origin $BRANCH
          gh pr create \
            -B deployment/staging \
            -t "chore: アイコンの更新 ${TODAY}" \
            -a ${{ github.actor }}  \
            --body-file ./.github/PULL_REQUEST_TEMPLATE.md

差分があるときだけPRを立てるようにしたかったので、以下の記事を参考にさせていただきました。ありがとうございます。

https://zenn.dev/snowcait/articles/903d86d668fcb7

動的にアイコンを切り替えるIconsコンポーネント

スペースマーケットではいままでアイコンフォントを使用したアイコンコンポーネント使用していました。
アイコンフォントを使って、全ての種類のアイコンを予め読み込ませているため、以下のようなアイコンを使うことができていました。

<Icon icon={iconKey} />

iconKeyの部分をAPIからのレスポンスに合わせて変えることで付帯情報を表示するラベルなどに利用することができます。

ですが、SVGRによって作成されたアイコンコンポーネントは1つのSVGが1つのコンポーネントに対応しているため、このように動的にアイコンを切り替えたい場合は、<Icons />コンポーネントを作成する必要が出てきます。

注意すべき点としては、以下のように予めコンポーネントを静的にimportしてしまうと、<Icons />のbundleサイズが大きくなってしまいます。

import { 
  IconAccessibility,
  IconAddAcount,
  IconAirConditioner,
  ... 
} from './icons'

bundleサイズを拡大を避けるため、以下のような<Icons />コンポーネントを作り、動的にアイコンを切り替えられるようにしています。

import { FC, useMemo, useReducer, useRef } from 'react'

import { chakra, HTMLChakraProps } from '@chakra-ui/react'

import { IconKeys, iconImportMap } from '../../../icons'

import { retry } from './retry'

type IconsProps = {
  /* iconの種類 */
  icon: IconKeys
} & HTMLChakraProps<'svg'>

/**
 * 動的にiconを変える場合に使用するアイコンコンポーネントです。
 *
 * 静的なものには<IconTime />のような個別のコンポーネントを使用してください。
 */
export const Icons: FC<IconsProps> = ({ icon, ...chakraProps }) => {
  const IconRef = useRef<FC<Omit<IconsProps, 'icon'>>>()
  const [, reRender] = useReducer(() => ({}), {})

  useMemo(() => {
    if (typeof window === 'undefined') return
    const load = async () => {
      const Component = await getComponent(icon)
      IconRef.current = Component
      reRender()
    }
    void load()
  }, [icon])

  const Icon = IconRef.current

  return Icon ? (
    <Icon {...chakraProps} />
  ) : (
    // Iconが読み込まれるまでdummyのsvgを表示する
    <chakra.svg
      width="1em"
      height="1em"
      fontSize="0.875em"
      verticalAlign="-0.0625em"
      {...chakraProps}
    />
  )
}

/**
 * 1文字目を大文字に変更する
 */
const convertFirstCharToUpperCase = (str: string): string =>
  str.replace(
    /(^\w)(.*)/g,
    (_match, first, restText) => `${first.toUpperCase()}${restText}`,
  )

/**
 * iconの名前からコンポーネントを取得する
 */
const getComponent = async (
  icon: IconKeys,
): Promise<FC<Omit<IconsProps, 'icon'>>> => {
  const componentName = `Icon${convertFirstCharToUpperCase(icon)}`
  const importFn = iconImportMap[icon] as () => Promise<any>
  return retry(importFn).then(
    (module) => module[componentName as keyof typeof module],
  )
}

ハマりポイント

アイコンの動的importの実装は、ちょっと失敗すると一気にbundleサイズが上がってしまうのでwebpack-bundle-analyzerなどでを様子を見ながら実装するといいです。

スペースマーケットでは自動生成したアイコンとNext.js製のアプリケーションを同じリポジトリで管理しております。その環境で上記の実装をしてもtree-shakingが上手く働かずだいぶハマりました。

原因はbarrelと呼ばれる以下のようなコンポーネントをまとめて、再度exportするファイルでした。

generated/index.ts
export { IconAccessibility } from "./IconAccessibility";
export { IconAddAcount } from "./IconAddAcount";
export { IconAirConditioner } from "./IconAirConditioner";
// ...略

これをやってしまうとNext.jsでtree-shakingがうまく働かないというissueがありました。
https://github.com/vercel/next.js/issues/12557

issueを参考に一部のmoduleだけsideEffectをfalseにすることで、tree-shakingを有効にします。

next.config.js
{
  module: {
    rules: [
      // other rules...
      {
        test: [/src\/icons\/index.ts/i],
        sideEffects: false,
      }
    ]
  }
}

これで最低限のbundleサイズで<Icons />コンポーネントを作ることができました。

感想

先人の記事がたくさんあるし簡単だろうと思ってましたが、いざやるとなると色々考慮すべき点が出てくるのでなかなか面白い題材でした。

この記事を踏み台にして、よりよいアイコン自動生成の記事が出てくるのを楽しみにしています。

最後に

スペースマーケットでは、一緒にサービスを成長させていく仲間を探しています。

開発者だけでなくビジネスサイドとも距離感が近くて、風通しがとてもよい会社ですのでとても仕事がしやすいです。

とりあえずどんなことしているのか聞いてみたいという方も大歓迎です!
ご興味ありましたら是非ご覧ください!

https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1061116

https://spacemarket.co.jp/recruit/engineer/

スペースマーケット Engineer Blog

Discussion