diff --git a/.changeset/dull-pillows-clean.md b/.changeset/dull-pillows-clean.md new file mode 100644 index 0000000000..63653ed237 --- /dev/null +++ b/.changeset/dull-pillows-clean.md @@ -0,0 +1,7 @@ +--- +"bezier-figma-plugin": minor +--- + +Enhance bezier-figma-plugin running performance +- Remove svg extract process using FigmaAPI and merely send json file that contains svg string +- Make icon files based on given json file during Github Action \ No newline at end of file diff --git a/.changeset/healthy-kiwis-destroy.md b/.changeset/healthy-kiwis-destroy.md new file mode 100644 index 0000000000..4bc779ab08 --- /dev/null +++ b/.changeset/healthy-kiwis-destroy.md @@ -0,0 +1,5 @@ +--- +"@channel.io/bezier-icons": minor +--- + +Add script for generating svg file from icons.json diff --git a/.github/workflows/generate-icon-files.yml b/.github/workflows/generate-icon-files.yml new file mode 100644 index 0000000000..bea6e92dbc --- /dev/null +++ b/.github/workflows/generate-icon-files.yml @@ -0,0 +1,38 @@ +name: Generate icon files from icons.json file + +on: + push: + branches: + - icon-update-* + paths: + - packages/bezier-icons/icons.json + +jobs: + generate-svg: + name: Generate icon files from icons.json file + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18.17.1 + + - name: Git Config + run: | + git config --global user.email "eng@channel.io" + git config --global user.name "ch-builder" + + - name: Generate Svg files from icons.json + run: | + node packages/bezier-icons/scripts/generate-icon-files.js + git add . + git commit -m "feat(bezier-icons): generate icon files from icons.json" + + - name: Delete icons.json files + run: | + git rm packages/bezier-icons/icons.json + git commit -m "feat(bezier-icons): remove icons.json" + git push diff --git a/packages/bezier-figma-plugin/src/plugin/index.ts b/packages/bezier-figma-plugin/src/plugin/index.ts index b38579128a..55b8373597 100644 --- a/packages/bezier-figma-plugin/src/plugin/index.ts +++ b/packages/bezier-figma-plugin/src/plugin/index.ts @@ -1,35 +1,42 @@ import { type ExtractIconPluginMessage, type GetTokenPluginMessage, + type SvgByName, type UIMessage, } from '../types/Message' -import { - findAllComponentNode, - flatten, -} from './utils' +import { findAllComponentNode } from './utils' // eslint-disable-next-line no-console console.info('Figma file key: ', figma.fileKey) figma.showUI(__html__, { width: 400, height: 300 }) -function extractIcon() { +async function extractIcon() { const componentNodes = figma.currentPage.selection .map(findAllComponentNode) - .reduce(flatten, []) - .map(({ id, name }) => ({ id, name })) + .flatMap(v => v) + + const svgs = await Promise.all(componentNodes.map(( + async (node) => { + const svg = await node.exportAsync({ format: 'SVG_STRING' }) + const id = node.name + return { + svg, + id, + } + } + ))) - const componentNodesIdsQuery = componentNodes - .map(({ id }) => id) - .join(',') + const svgByName = svgs.reduce((acc, cur) => { + acc[cur.id] = cur + return acc + }, {} as SvgByName) const pluginMessage: ExtractIconPluginMessage = { type: 'extractIcon', payload: { - fileKey: figma.fileKey as string, - ids: componentNodesIdsQuery, - nodes: componentNodes, + svgByName, }, } diff --git a/packages/bezier-figma-plugin/src/plugin/utils.ts b/packages/bezier-figma-plugin/src/plugin/utils.ts index a4c81db2b8..c40e55923a 100644 --- a/packages/bezier-figma-plugin/src/plugin/utils.ts +++ b/packages/bezier-figma-plugin/src/plugin/utils.ts @@ -1,5 +1,3 @@ -export const flatten = (a: T[], b: T[]) => [...a, ...b] - export const isComponentNode = (node: SceneNode): node is ComponentNode => node.type === 'COMPONENT' export const findAllComponentNode = (rootNode: SceneNode) => { diff --git a/packages/bezier-figma-plugin/src/types/Message.ts b/packages/bezier-figma-plugin/src/types/Message.ts index 5892705f88..9023a7e84f 100644 --- a/packages/bezier-figma-plugin/src/types/Message.ts +++ b/packages/bezier-figma-plugin/src/types/Message.ts @@ -1,9 +1,8 @@ +export type SvgByName = { [id: string]: object } export interface ExtractIconPluginMessage { type: 'extractIcon' payload: { - fileKey: string - ids: string - nodes: Array<{ id: string, name: string }> + svgByName: SvgByName } } diff --git a/packages/bezier-figma-plugin/src/ui/components/IconExtract.tsx b/packages/bezier-figma-plugin/src/ui/components/IconExtract.tsx index 5a46328e1f..e38a19f749 100644 --- a/packages/bezier-figma-plugin/src/ui/components/IconExtract.tsx +++ b/packages/bezier-figma-plugin/src/ui/components/IconExtract.tsx @@ -26,9 +26,7 @@ import { import config from '../../config' import type { PluginMessage } from '../../types/Message' -import useFigmaAPI from '../hooks/useFigmaAPI' -import useGithubAPI from '../hooks/useGithubAPI' -import { createSvgGitBlob } from '../utils' +import { useCreatePRWithSvgMap } from '../hooks/useCreatePRWithSvgMap' enum Step { Pending, @@ -42,7 +40,7 @@ interface ProgressProps { onError: (msg: string) => void } -function useProgress() { +export function useProgress() { const [progressTitle, setProgressTitle] = useState('') const [progressValue, setProgressValue] = useState(0) @@ -85,13 +83,7 @@ function Progress({ progressValue, } = useProgress() - const figmaAPI = useFigmaAPI({ token: figmaToken }) - - const githubAPI = useGithubAPI({ - auth: githubToken, - owner: config.repository.owner, - repo: config.repository.name, - }) + const createPr = useCreatePRWithSvgMap({ progress, githubToken }) useEffect(function bindOnMessageHandler() { window.onmessage = async (event: MessageEvent) => { @@ -99,146 +91,9 @@ function Progress({ if (type === 'extractIcon') { try { - const { fileKey, ids, nodes } = payload - - const getSvgImagesFromFigma = async () => { - const { images } = await figmaAPI.getSvg({ fileKey, ids }) - if (!images) { - throw new Error('선택된 아이콘이 없거나 잘못된 피그마 토큰입니다.') - } - return images - } - - const createSvgGitBlobsFromSvgImages = (images: Record) => async () => { - const gitBlobs = await Promise.all( - nodes.map(async ({ id, name }) => { - const response = await fetch(images[id]) - const svg = await response.text() - const { sha } = await githubAPI.createGitBlob(svg) - return { name, sha } - }), - ) - - const svgGitBlobs = gitBlobs.reduce((acc, { name, sha }) => { - const path = `${name}.svg` - return { ...acc, [path]: createSvgGitBlob(path, sha) } - }, {} as Record>) - - return svgGitBlobs - } - - const createNewGitCommitFromSvgGitBlobs = (svgGitBlobs: Record>) => async () => { - const newSvgBlobs = Object.values(svgGitBlobs) - - const baseRef = await githubAPI.getGitRef(config.repository.baseBranchName) - const headCommit = await githubAPI.getGitCommit(baseRef.sha) - const headTree = await githubAPI.getGitTree(headCommit.sha) - - const splittedPaths = config.repository.iconExtractPath.split('/') - - const parentTrees: Awaited>[] = [] - - const prevSvgBlobsTree = await splittedPaths.reduce(async (parentTreePromise, splittedPath) => { - const parentTree = await parentTreePromise - const targetTree = parentTree.find(({ path }) => path === splittedPath) - if (!targetTree || !targetTree.sha) { - throw new Error(`${splittedPath} 경로가 없습니다. 올바른 경로를 입력했는지 확인해주세요.`) - } - parentTrees.push(parentTree) - return githubAPI.getGitTree(targetTree.sha) - }, Promise.resolve(headTree)) - - const newSvgBlobsTree = [ - ...prevSvgBlobsTree.map((blob) => { - const overridedBlob = svgGitBlobs[blob.path as string] - if (overridedBlob) { - delete svgGitBlobs[blob.path as string] - return { ...blob, ...overridedBlob } - } - return null - }).filter(Boolean), - ...newSvgBlobs, - ] - - const newGitSvgTree = await githubAPI.createGitTree({ - // @ts-ignore - tree: newSvgBlobsTree, - }) - - const gitTree = await splittedPaths.reduceRight(async (prevTreePromise, cur, index) => { - const parentTree = parentTrees[index] - const targetTree = parentTree.find(({ path }) => path === cur) - const { sha } = await prevTreePromise - return githubAPI.createGitTree({ - tree: [ - // @ts-ignore - ...parentTree.filter(({ path }) => path !== cur), { ...targetTree, sha }, - ], - }) - }, Promise.resolve(newGitSvgTree)) - - const now = new Date() - const commit = await githubAPI.createGitCommit({ - message: config.commit.message, - author: { - ...config.commit.author, - date: now.toISOString(), - }, - parents: [headCommit.sha], - tree: gitTree.sha, - }) - - return commit - } - - const createGitPullRequestFromGitCommit = (commit: Awaited>) => async () => { - const now = new Date() - const newBranchName = `update-icons-${now.valueOf()}` - - await githubAPI.createGitRef({ - branchName: newBranchName, - sha: commit.sha, - }) - - const { labels, ...rest } = config.pr - - const { html_url, number } = await githubAPI.createPullRequest({ - ...rest, - head: newBranchName, - base: config.repository.baseBranchName, - }) - - await githubAPI.addLabels({ - issueNumber: number, - labels, - }) - - return html_url - } - - const svgImages = await progress({ - callback: getSvgImagesFromFigma, - title: '🚚 피그마에서 svg를 가져오는 중...', - successValueOffset: 0.2, - }) - - const svgGitBlobs = await progress({ - callback: createSvgGitBlobsFromSvgImages(svgImages), - title: '📦 svg를 파일로 만드는 중...', - successValueOffset: 0.3, - }) - - const gitCommit = await progress({ - callback: createNewGitCommitFromSvgGitBlobs(svgGitBlobs), - title: '📦 svg 파일을 변환하는 중...', - successValueOffset: 0.3, - }) - - const pullRequestUrl = await progress({ - callback: createGitPullRequestFromGitCommit(gitCommit), - title: '🚚 PR을 업로드하는 중...', - successValueOffset: 0.2, - }) + const { svgByName } = payload + + const prUrl = await createPr(svgByName) parent.postMessage({ pluginMessage: { @@ -247,7 +102,7 @@ function Progress({ }, }, '*') - navigate('../extract_success', { state: { url: pullRequestUrl } }) + navigate('../extract_success', { state: { url: prUrl } }) } catch (e: any) { onError(e?.message) } diff --git a/packages/bezier-figma-plugin/src/ui/hooks/useCreatePRWithSvgMap.ts b/packages/bezier-figma-plugin/src/ui/hooks/useCreatePRWithSvgMap.ts new file mode 100644 index 0000000000..a1fe48c903 --- /dev/null +++ b/packages/bezier-figma-plugin/src/ui/hooks/useCreatePRWithSvgMap.ts @@ -0,0 +1,101 @@ +import { useCallback } from 'react' + +import config from '../../config' +import { type SvgByName } from '../../types/Message' +import { type useProgress } from '../components/IconExtract' + +import useGithubAPI from './useGithubAPI' + +export function useCreatePRWithSvgMap({ + progress, + githubToken, +}: { + githubToken: string + progress: ReturnType['progress'] +}) { + const githubAPI = useGithubAPI({ + auth: githubToken, + owner: config.repository.owner, + repo: config.repository.name, + }) + + const getMainBranch = useCallback((branchName: string) => async () => githubAPI.getGitRef(branchName), [githubAPI]) + + const createCommit = useCallback((svgByName: SvgByName, baseBranchSha: string) => async () => { + const blob = await githubAPI.createGitBlob(JSON.stringify(svgByName)) + const tree = await githubAPI.createGitTree({ + baseTreeSha: baseBranchSha, + tree: [{ + sha: blob.sha, + path: 'packages/bezier-icons/icons.json', + type: 'blob', + mode: '100644', + }], + }) + const commit = await githubAPI.createGitCommit({ + message: 'feat(bezier-icons): add icons.json file', + tree: tree.sha, + parents: [baseBranchSha], + author: { + ...config.commit.author, + date: new Date().toISOString(), + } }) + + return commit + }, [githubAPI]) + + const createPullRequest = useCallback((commitSha: string) => async () => { + /** + * NOTE: this branch name is used in ./github/workflows/generate-icon-files.yml + */ + const newBranchName = `icon-update-${new Date().getTime()}` + + await githubAPI.createGitRef({ + branchName: newBranchName, + sha: commitSha, + }) + + const pr = await githubAPI.createPullRequest({ + title: config.pr.title, + body: config.pr.body, + head: newBranchName, + base: config.repository.baseBranchName, + }) + + await githubAPI.addLabels({ + issueNumber: pr.number, + labels: config.pr.labels, + }) + + return pr + }, [githubAPI]) + + const createPRWithSvgMap = useCallback(async (svgByName: SvgByName) => { + const mainBranch = await progress({ + callback: getMainBranch('main'), + title: '📦 깃헙에서 정보를 가져오는 중...', + successValueOffset: 0.3, + }) + + const commit = await progress({ + callback: createCommit(svgByName, mainBranch.sha), + title: '🎨 베지어 아이콘 변경사항을 반영하는 중...', + successValueOffset: 0.3, + }) + + const pr = await progress({ + callback: createPullRequest(commit.sha), + title: '🚚 깃헙에 Pull request를 만드는 중...', + successValueOffset: 0.4, + }) + + return pr.html_url + }, [ + createCommit, + createPullRequest, + getMainBranch, + progress, + ]) + + return createPRWithSvgMap +} diff --git a/packages/bezier-figma-plugin/src/ui/hooks/useFigmaAPI.ts b/packages/bezier-figma-plugin/src/ui/hooks/useFigmaAPI.ts deleted file mode 100644 index 7b6df340dc..0000000000 --- a/packages/bezier-figma-plugin/src/ui/hooks/useFigmaAPI.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - useCallback, - useMemo, -} from 'react' - -const BASE_URL = 'https://api.figma.com/v1' - -interface UseFigmaAPIProps { - token: string -} - -function useFigmaAPI({ - token, -}: UseFigmaAPIProps) { - const headers = useMemo((): RequestInit['headers'] => ({ 'X-Figma-Token': token }), [token]) - - const getSvg = useCallback(async (params: { - fileKey: string - ids: string - }) => { - const { fileKey, ids } = params - const response = await fetch(`${BASE_URL}/images/${fileKey}?ids=${ids}&format=svg`, { - headers, - }) - // TODO: 타입 정의 - return response.json() - }, [headers]) - - return { - getSvg, - } -} - -export default useFigmaAPI diff --git a/packages/bezier-figma-plugin/src/ui/utils/index.ts b/packages/bezier-figma-plugin/src/ui/utils/index.ts deleted file mode 100644 index 8e442ce426..0000000000 --- a/packages/bezier-figma-plugin/src/ui/utils/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function createSvgGitBlob(path: string, sha: string) { - return { - path, - mode: '100644', - type: 'blob', - sha, - } as const -} diff --git a/packages/bezier-icons/scripts/generate-icon-files.js b/packages/bezier-icons/scripts/generate-icon-files.js new file mode 100644 index 0000000000..516dfc9777 --- /dev/null +++ b/packages/bezier-icons/scripts/generate-icon-files.js @@ -0,0 +1,33 @@ +const fs = require('fs') +const path = require('path') + +const bezierIconsDirectory = path.resolve(__dirname, '..') +const iconsJson = path.resolve(bezierIconsDirectory, 'icons.json') +const iconsDir = path.join(bezierIconsDirectory, 'icons') +const svgByName = JSON.parse(fs.readFileSync(iconsJson, 'utf-8')) + +const flushAndMakeIconsDirectory = () => { + if (fs.existsSync(iconsDir)) { + fs.rmSync(iconsDir, { recursive: true, force: true }) + fs.mkdirSync(iconsDir) + } +} + +const makeSvgFiles = ([iconName, svgObject]) => { + const svgPath = path.resolve(iconsDir, `${iconName}.svg`) + const { svg } = svgObject + + fs.writeFileSync(svgPath, svg, 'utf-8') +} + +const generateSVGFilesFromMap = () => { + Object.entries(svgByName) + .forEach(makeSvgFiles) +} + +const generateIconFiles = () => { + flushAndMakeIconsDirectory() + generateSVGFilesFromMap() +} + +generateIconFiles()