From 9a58984e8bcaef5bb37d173c9f9faaaed0a74c73 Mon Sep 17 00:00:00 2001 From: MaxLee-dev Date: Mon, 31 Aug 2026 16:03:34 +0900 Subject: [PATCH 1/6] refactor(sync-figma): migrate scripts to TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node` 대신 `tsx`로 실행하도록 바꾸고 `.mjs`/`.js` 11개를 `.ts`로 옮겼다. 빌드 산출물은 만들지 않는다 — 1년에 몇 번 도는 내부 CLI라 dist를 둘 이유가 없다. 타입을 붙이는 과정에서 드러난 문제 세 가지를 함께 고쳤다. - `TYPE`이 비었거나 오타면 `ICON_TYPES[undefined]`를 구조 분해하다 죽었다. FIGMA_TOKEN 검사 옆에 가드를 뒀다. - `sync-icons`의 `else` 분기는 도달할 수 없는 코드였다. `TYPE`은 basic 아니면 symbol뿐이라 앞 분기에서 모두 걸린다. - `lib`의 `hasOwnProperty` 직접 호출을 `Object.hasOwn`으로 바꿨다. `prettier`는 `sync-icons`가 이미 import하고 있었는데 선언만 빠져 있어 dependencies에 넣었다. --- pnpm-lock.yaml | 80 ++++++++++++------- .../{notify-slack.mjs => notify-slack.ts} | 2 +- .../{sync-icons.mjs => sync-icons.ts} | 51 ++++++------ scripts/sync-figma/package.json | 21 +++-- .../src/icons/{constants.js => constants.ts} | 6 +- .../icons/{icon-types.js => icon-types.ts} | 10 ++- ...onent-index.js => icon-component-index.ts} | 2 +- .../{icon-component.js => icon-component.ts} | 2 +- .../icon/{icons-index.js => icons-index.ts} | 2 +- .../sync-figma/src/integrations/figma/api.js | 41 ---------- .../sync-figma/src/integrations/figma/api.ts | 77 ++++++++++++++++++ .../src/integrations/figma/{lib.js => lib.ts} | 41 ++++++++-- .../figma/{transforms.js => transforms.ts} | 6 +- .../src/integrations/slack/{api.js => api.ts} | 2 +- scripts/sync-figma/tsconfig.json | 10 +++ 15 files changed, 233 insertions(+), 120 deletions(-) rename scripts/sync-figma/commands/{notify-slack.mjs => notify-slack.ts} (99%) rename scripts/sync-figma/commands/{sync-icons.mjs => sync-icons.ts} (88%) rename scripts/sync-figma/src/icons/{constants.js => constants.ts} (90%) rename scripts/sync-figma/src/icons/{icon-types.js => icon-types.ts} (74%) rename scripts/sync-figma/src/icons/templates/icon/{icon-component-index.js => icon-component-index.ts} (63%) rename scripts/sync-figma/src/icons/templates/icon/{icon-component.js => icon-component.ts} (78%) rename scripts/sync-figma/src/icons/templates/icon/{icons-index.js => icons-index.ts} (71%) delete mode 100644 scripts/sync-figma/src/integrations/figma/api.js create mode 100644 scripts/sync-figma/src/integrations/figma/api.ts rename scripts/sync-figma/src/integrations/figma/{lib.js => lib.ts} (65%) rename scripts/sync-figma/src/integrations/figma/{transforms.js => transforms.ts} (88%) rename scripts/sync-figma/src/integrations/slack/{api.js => api.ts} (81%) create mode 100644 scripts/sync-figma/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ce6cfd6e..589afecd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -434,13 +434,13 @@ importers: version: 9.39.5(jiti@2.7.0) jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + version: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) rimraf: specifier: ^6.1.3 version: 6.1.3 ts-jest: specifier: ^29.4.12 - version: 29.4.12(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(esbuild@0.28.2)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@5.9.3) @@ -936,6 +936,25 @@ importers: picocolors: specifier: ^1.1.1 version: 1.1.1 + prettier: + specifier: ^3.9.6 + version: 3.9.6 + devDependencies: + '@repo/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + tsx: + specifier: ^4.23.12 + version: 4.23.12 + typescript: + specifier: 'catalog:' + version: 5.9.3 scripts/ts-api-extractor: dependencies: @@ -12184,7 +12203,7 @@ snapshots: jest-util: 30.4.1 slash: 3.0.0 - '@jest/core@30.4.2(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3))': + '@jest/core@30.4.2(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3))': dependencies: '@jest/console': 30.4.1 '@jest/pattern': 30.4.0 @@ -12200,7 +12219,7 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@22.20.1)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@22.20.1)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 @@ -14471,9 +14490,9 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.7(@types/debug@4.1.12)(@types/node@22.20.1)(@vitest/browser@3.2.7)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.33.0)(sass@1.102.0)(tsx@4.23.12) + vitest: 3.2.7(@types/debug@4.1.12)(@types/node@20.19.43)(@vitest/browser@3.2.7)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.33.0)(sass@1.102.0)(tsx@4.23.12) optionalDependencies: - '@vitest/browser': 3.2.7(playwright@1.62.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(tsx@4.23.12))(vitest@3.2.7) + '@vitest/browser': 3.2.7(playwright@1.62.1)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(tsx@4.23.12))(vitest@3.2.7) transitivePeerDependencies: - supports-color @@ -15674,10 +15693,10 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild-register@3.6.0(esbuild@0.27.7): + esbuild-register@3.6.0(esbuild@0.28.2): dependencies: debug: 4.4.3 - esbuild: 0.27.7 + esbuild: 0.28.2 transitivePeerDependencies: - supports-color optional: true @@ -15785,8 +15804,8 @@ snapshots: '@typescript-eslint/parser': 8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)) @@ -15818,7 +15837,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15829,19 +15848,18 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -15863,7 +15881,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15874,7 +15892,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -15886,7 +15904,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -16947,15 +16965,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + jest-cli@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: - '@jest/core': 30.4.2(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + '@jest/core': 30.4.2(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest-config: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) jest-util: 30.4.1 jest-validate: 30.4.1 yargs: 17.7.2 @@ -16966,7 +16984,7 @@ snapshots: - supports-color - ts-node - jest-config@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + jest-config@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -16993,13 +17011,13 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.43 - esbuild-register: 3.6.0(esbuild@0.27.7) + esbuild-register: 3.6.0(esbuild@0.28.2) ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@30.4.2(@types/node@22.20.1)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + jest-config@30.4.2(@types/node@22.20.1)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -17026,7 +17044,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.20.1 - esbuild-register: 3.6.0(esbuild@0.27.7) + esbuild-register: 3.6.0(esbuild@0.28.2) ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros @@ -17285,12 +17303,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + jest@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: - '@jest/core': 30.4.2(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + '@jest/core': 30.4.2(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest-cli: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -19560,12 +19578,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.12(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(esbuild@0.28.2)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest: 30.4.2(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -19578,7 +19596,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 babel-jest: 30.4.1(@babel/core@7.29.0) - esbuild: 0.27.7 + esbuild: 0.28.2 jest-util: 30.4.1 ts-morph@22.0.0: diff --git a/scripts/sync-figma/commands/notify-slack.mjs b/scripts/sync-figma/commands/notify-slack.ts similarity index 99% rename from scripts/sync-figma/commands/notify-slack.mjs rename to scripts/sync-figma/commands/notify-slack.ts index 43d523766..a9bf8f981 100644 --- a/scripts/sync-figma/commands/notify-slack.mjs +++ b/scripts/sync-figma/commands/notify-slack.ts @@ -72,6 +72,6 @@ try { await sendWebhookMessage(SLACK_GDS_ALARM_WEBHOOK_URL, message); console.log('✅ Slack 알림이 성공적으로 전송되었습니다.'); } catch (error) { - console.error('❌ Slack 알림 전송 중 오류 발생:', error.message); + console.error('❌ Slack 알림 전송 중 오류 발생:', error); process.exit(1); } diff --git a/scripts/sync-figma/commands/sync-icons.mjs b/scripts/sync-figma/commands/sync-icons.ts similarity index 88% rename from scripts/sync-figma/commands/sync-icons.mjs rename to scripts/sync-figma/commands/sync-icons.ts index 70be7eb5d..7e86829b4 100644 --- a/scripts/sync-figma/commands/sync-icons.mjs +++ b/scripts/sync-figma/commands/sync-icons.ts @@ -2,13 +2,12 @@ * Sync icons from Figma to local React components * * Usage: - * node --env-file=.env ./commands/sync-icons.mjs + * tsx --env-file=.env ./commands/sync-icons.ts * * Environment variables required: * - FIGMA_TOKEN: Your Figma personal access token * - TYPE: Icon type to sync ('basic' or 'symbol') * - * Note: Node 20.6+ required for --env-file flag */ import { camelCase, startCase } from 'lodash-es'; import { createHash } from 'node:crypto'; @@ -31,6 +30,7 @@ import { ICON_TYPES } from '../src/icons/icon-types.js'; import getIconComponentIndex from '../src/icons/templates/icon/icon-component-index.js'; import getIconComponent from '../src/icons/templates/icon/icon-component.js'; import getIconsIndex from '../src/icons/templates/icon/icons-index.js'; +import type { IconNode } from '../src/integrations/figma/lib.js'; import { filterDocumentByNodeType, getIconJsx, @@ -39,7 +39,7 @@ import { const TYPE = process.env.TYPE; -function findRoot(dir) { +function findRoot(dir: string): string { if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir; const parent = path.dirname(dir); if (parent === dir) @@ -56,7 +56,7 @@ const PRETTIER_OPTIONS = { printWidth: 100, }; -function normalizeIconName(name) { +function normalizeIconName(name: string) { return startCase(camelCase(name.replace(FIGMA_EMOJI_PREFIX_PATTERN, ''))).replace(/ /g, ''); } @@ -67,31 +67,30 @@ if (!process.env.FIGMA_TOKEN) { process.exit(1); } +if (!TYPE || !(TYPE in ICON_TYPES)) { + console.error( + pc.red( + ` GDS FIGMA EXPORT ERROR: TYPE must be one of ${Object.keys(ICON_TYPES).join(', ')}.`, + ), + ); + process.exit(1); +} + try { const { nodeIds, targetPath } = ICON_TYPES[TYPE]; - let FILE_KEY = FIGMA_ICONS_FILE_KEY; + const FILE_KEY = FIGMA_ICONS_FILE_KEY; // Get nodes (icons) set as COMPONENT in the file. - let components = []; - if (TYPE === 'basic' || TYPE === 'symbol') { - FILE_KEY = FIGMA_ICONS_FILE_KEY; - // Basic icons are composed of 2 frames, so nodeIds are in array form - for (const nodeId of nodeIds) { - const nodeComponents = await filterDocumentByNodeType({ - nodeType: FIGMA_NODE_TYPES.Component, - fileKey: FILE_KEY, - nodeIds: nodeId, - depth: 1, - }); - components = components.concat(nodeComponents); - } - } else { - components = await filterDocumentByNodeType({ + // Each icon type spans several frames, so nodeIds are fetched one frame at a time. + let components: IconNode[] = []; + for (const nodeId of nodeIds) { + const nodeComponents = await filterDocumentByNodeType({ nodeType: FIGMA_NODE_TYPES.Component, fileKey: FILE_KEY, - nodeIds, + nodeIds: nodeId, depth: 1, }); + components = components.concat(nodeComponents); } const componentsInfo = { @@ -121,10 +120,10 @@ try { // Convert svg code to React components through image URLs and save locally. console.log(pc.yellow(` GDS FIGMA EXPORT: `) + `Converting to React components...`); const parentIconPath = path.join(CURRENT_DIRECTORY, targetPath); - const newIconNameArr = []; - const updatedIconNameArr = []; + const newIconNameArr: string[] = []; + const updatedIconNameArr: string[] = []; const limit = pLimit(10); - const md5 = (str) => createHash('md5').update(str).digest('hex'); + const md5 = (str: string) => createHash('md5').update(str).digest('hex'); const promiseCreateIcons = componentsWithUrl.map(({ name, url, parentId }) => limit(async () => { @@ -153,7 +152,7 @@ try { await fs.mkdir(saveTargetPath, { recursive: true }); newIconNameArr.push(iconName); } else { - let existingContent = null; + let existingContent: string | null = null; try { existingContent = await fs.readFile(iconFilePath, 'utf8'); } catch { @@ -184,7 +183,7 @@ try { // Detect and remove deleted icons console.log(pc.yellow(` GDS FIGMA EXPORT: `) + `Checking for deleted icons...`); - const deletedIconNameArr = []; + const deletedIconNameArr: string[] = []; const figmaIconNames = new Set(componentsInfo.nameArr); // Get existing icon directories diff --git a/scripts/sync-figma/package.json b/scripts/sync-figma/package.json index 9e1047801..6221c2c72 100644 --- a/scripts/sync-figma/package.json +++ b/scripts/sync-figma/package.json @@ -5,15 +5,24 @@ "description": "Internal CLI scripts for syncing assets from Figma", "type": "module", "scripts": { - "format": "prettier --write \"./src/**/*.{js,mjs,md}\"", - "format:check": "prettier --check \"./src/**/*.{js,mjs,md}\"", - "notify:slack": "node ./commands/notify-slack.mjs", - "sync-icons:basic": "TYPE=basic node --env-file-if-exists=.env ./commands/sync-icons.mjs", - "sync-icons:symbol": "TYPE=symbol node --env-file-if-exists=.env ./commands/sync-icons.mjs" + "format": "prettier --write \"./{commands,src}/**/*.{ts,md}\"", + "format:check": "prettier --check \"./{commands,src}/**/*.{ts,md}\"", + "notify:slack": "tsx ./commands/notify-slack.ts", + "sync-icons:basic": "TYPE=basic tsx --env-file-if-exists=.env ./commands/sync-icons.ts", + "sync-icons:symbol": "TYPE=symbol tsx --env-file-if-exists=.env ./commands/sync-icons.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "lodash-es": "^4.18.1", "p-limit": "^6.2.0", - "picocolors": "^1.1.1" + "picocolors": "^1.1.1", + "prettier": "^3.9.6" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/lodash-es": "^4.17.12", + "@types/node": "^22.20.1", + "tsx": "^4.23.12", + "typescript": "catalog:" } } diff --git a/scripts/sync-figma/src/icons/constants.js b/scripts/sync-figma/src/icons/constants.ts similarity index 90% rename from scripts/sync-figma/src/icons/constants.js rename to scripts/sync-figma/src/icons/constants.ts index 125422fad..3245c8552 100644 --- a/scripts/sync-figma/src/icons/constants.js +++ b/scripts/sync-figma/src/icons/constants.ts @@ -28,7 +28,11 @@ const FIGMA_NODE_TYPES = { Frame: 'FRAME', Component: 'COMPONENT', ComponentSet: 'COMPONENT_SET', -}; +} as const; + +type FigmaNodeType = (typeof FIGMA_NODE_TYPES)[keyof typeof FIGMA_NODE_TYPES]; + +export type { FigmaNodeType }; export { FIGMA_ICONS_FILE_KEY, diff --git a/scripts/sync-figma/src/icons/icon-types.js b/scripts/sync-figma/src/icons/icon-types.ts similarity index 74% rename from scripts/sync-figma/src/icons/icon-types.js rename to scripts/sync-figma/src/icons/icon-types.ts index 4e23d0548..7d0d04e75 100644 --- a/scripts/sync-figma/src/icons/icon-types.js +++ b/scripts/sync-figma/src/icons/icon-types.ts @@ -3,7 +3,13 @@ import { FIGMA_ICONS_BASIC_NODE_IDS, FIGMA_ICONS_SYMBOL_NODE_IDS } from './const /** * Script information by npm script */ -const ICON_TYPES = { +type IconType = { + id: string; + nodeIds: string[]; + targetPath: string; +}; + +const ICON_TYPES: Record = { basic: { id: 'basic', nodeIds: FIGMA_ICONS_BASIC_NODE_IDS, @@ -16,4 +22,6 @@ const ICON_TYPES = { }, }; +export type { IconType }; + export { ICON_TYPES }; diff --git a/scripts/sync-figma/src/icons/templates/icon/icon-component-index.js b/scripts/sync-figma/src/icons/templates/icon/icon-component-index.ts similarity index 63% rename from scripts/sync-figma/src/icons/templates/icon/icon-component-index.js rename to scripts/sync-figma/src/icons/templates/icon/icon-component-index.ts index ff523af71..433696b42 100644 --- a/scripts/sync-figma/src/icons/templates/icon/icon-component-index.js +++ b/scripts/sync-figma/src/icons/templates/icon/icon-component-index.ts @@ -1,4 +1,4 @@ -export default (name) => ` +export default (name: string) => ` import ${name} from './${name}'; export default ${name}; diff --git a/scripts/sync-figma/src/icons/templates/icon/icon-component.js b/scripts/sync-figma/src/icons/templates/icon/icon-component.ts similarity index 78% rename from scripts/sync-figma/src/icons/templates/icon/icon-component.js rename to scripts/sync-figma/src/icons/templates/icon/icon-component.ts index fb923445e..778a4e6d9 100644 --- a/scripts/sync-figma/src/icons/templates/icon/icon-component.js +++ b/scripts/sync-figma/src/icons/templates/icon/icon-component.ts @@ -1,4 +1,4 @@ -export default (IconName, Icon) => ` +export default (IconName: string, Icon: string) => ` import IconBase from '~/components/icon-base'; import type { IconProps } from '~/components/icon-base'; diff --git a/scripts/sync-figma/src/icons/templates/icon/icons-index.js b/scripts/sync-figma/src/icons/templates/icon/icons-index.ts similarity index 71% rename from scripts/sync-figma/src/icons/templates/icon/icons-index.js rename to scripts/sync-figma/src/icons/templates/icon/icons-index.ts index ec3d76fc4..04af1157b 100644 --- a/scripts/sync-figma/src/icons/templates/icon/icons-index.js +++ b/scripts/sync-figma/src/icons/templates/icon/icons-index.ts @@ -1,3 +1,3 @@ -export default (iconNames) => ` +export default (iconNames: string[]) => ` ${iconNames.map((iconName) => `export { default as ${iconName} } from './${iconName}';`).join(' ')} `; diff --git a/scripts/sync-figma/src/integrations/figma/api.js b/scripts/sync-figma/src/integrations/figma/api.js deleted file mode 100644 index 72e1ff3f4..000000000 --- a/scripts/sync-figma/src/integrations/figma/api.js +++ /dev/null @@ -1,41 +0,0 @@ -import process from 'node:process'; - -const headers = { - 'X-FIGMA-TOKEN': process.env.FIGMA_TOKEN, -}; - -/** - * GET file nodes - * - * @link https://www.figma.com/developers/api#get-file-nodes-endpoint - */ -const getFileNodes = async ({ fileKey, nodeIds, depth = 1 }) => { - const result = await fetch( - `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${nodeIds}&depth=${depth}`, - { headers }, - ); - if (!result.ok) { - throw new Error(`Figma API error: ${result.status} ${result.statusText}`); - } - return result.json(); -}; - -/** - * GET image - * - * @link https://www.figma.com/developers/api#get-images-endpoint - */ -const getImage = async ({ fileKey, nodeIds, format = 'svg' }) => { - const result = await fetch( - `https://api.figma.com/v1/images/${fileKey}?ids=${nodeIds}&format=${format}&svg_include_id=false`, - { - headers, - }, - ); - if (!result.ok) { - throw new Error(`Figma API error: ${result.status} ${result.statusText}`); - } - return result.json(); -}; - -export { getFileNodes, getImage }; diff --git a/scripts/sync-figma/src/integrations/figma/api.ts b/scripts/sync-figma/src/integrations/figma/api.ts new file mode 100644 index 000000000..9b1e7f578 --- /dev/null +++ b/scripts/sync-figma/src/integrations/figma/api.ts @@ -0,0 +1,77 @@ +import process from 'node:process'; + +import type { FigmaNodeType } from '../../icons/constants.js'; + +const headers = { + 'X-FIGMA-TOKEN': process.env.FIGMA_TOKEN ?? '', +}; + +/** Only the slice of the Figma node shape this script actually reads. */ +type FigmaNode = { + id: string; + name: string; + type: FigmaNodeType; + children?: FigmaNode[]; +}; + +type GetFileNodesResponse = { + nodes: Record; +}; + +type GetImageResponse = { + images: Record; +}; + +/** + * GET file nodes + * + * @link https://www.figma.com/developers/api#get-file-nodes-endpoint + */ +const getFileNodes = async ({ + fileKey, + nodeIds, + depth = 1, +}: { + fileKey: string; + nodeIds: string; + depth?: number; +}): Promise => { + const result = await fetch( + `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${nodeIds}&depth=${depth}`, + { headers }, + ); + if (!result.ok) { + throw new Error(`Figma API error: ${result.status} ${result.statusText}`); + } + return result.json() as Promise; +}; + +/** + * GET image + * + * @link https://www.figma.com/developers/api#get-images-endpoint + */ +const getImage = async ({ + fileKey, + nodeIds, + format = 'svg', +}: { + fileKey: string; + nodeIds: string; + format?: string; +}): Promise => { + const result = await fetch( + `https://api.figma.com/v1/images/${fileKey}?ids=${nodeIds}&format=${format}&svg_include_id=false`, + { + headers, + }, + ); + if (!result.ok) { + throw new Error(`Figma API error: ${result.status} ${result.statusText}`); + } + return result.json() as Promise; +}; + +export type { FigmaNode }; + +export { getFileNodes, getImage }; diff --git a/scripts/sync-figma/src/integrations/figma/lib.js b/scripts/sync-figma/src/integrations/figma/lib.ts similarity index 65% rename from scripts/sync-figma/src/integrations/figma/lib.js rename to scripts/sync-figma/src/integrations/figma/lib.ts index a4d1f0fd1..322bd59b5 100644 --- a/scripts/sync-figma/src/integrations/figma/lib.js +++ b/scripts/sync-figma/src/integrations/figma/lib.ts @@ -1,23 +1,38 @@ +import type { FigmaNodeType } from '../../icons/constants.js'; +import type { FigmaNode } from './api.js'; import { getFileNodes, getImage } from './api.js'; import { makeFlexibleColorIcon, remakeMaskStyle, svgToIconComponent } from './transforms.js'; +type IconNode = FigmaNode & { parentId: string }; +type IconNodeWithUrl = IconNode & { url: string }; + /** * Filter documents from Figma by specific Node Type. */ -const filterDocumentByNodeType = async ({ nodeType, fileKey, nodeIds, depth }) => { +const filterDocumentByNodeType = async ({ + nodeType, + fileKey, + nodeIds, + depth, +}: { + nodeType: FigmaNodeType; + fileKey: string; + nodeIds: string; + depth?: number; +}): Promise => { const { nodes } = await getFileNodes({ fileKey, nodeIds, depth, }); - const childrenNodes = []; + const childrenNodes: IconNode[] = []; for (const key in nodes) { - if (nodes.hasOwnProperty(key)) { + if (Object.hasOwn(nodes, key)) { const parent = nodes[key].document; const parentId = parent.id; - parent.children.forEach((child) => { + parent.children?.forEach((child) => { childrenNodes.push({ ...child, parentId: parentId, @@ -33,7 +48,13 @@ const filterDocumentByNodeType = async ({ nodeType, fileKey, nodeIds, depth }) = /** * Get image URLs through the IDs of nodes received from Figma. */ -const getNodesWithUrl = async ({ nodes, fileKey }) => { +const getNodesWithUrl = async ({ + nodes, + fileKey, +}: { + nodes: IconNode[]; + fileKey: string; +}): Promise => { const nodeIds = nodes.map((node) => node.id).join(','); const { images } = await getImage({ fileKey, @@ -50,7 +71,13 @@ const getNodesWithUrl = async ({ nodes, fileKey }) => { /** * Convert svg files from Figma to React components. */ -const getIconJsx = async ({ url, isColorIcon }) => { +const getIconJsx = async ({ + url, + isColorIcon, +}: { + url: string; + isColorIcon: boolean; +}): Promise => { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch SVG: ${response.status} ${response.statusText}`); @@ -66,4 +93,6 @@ const getIconJsx = async ({ url, isColorIcon }) => { } }; +export type { IconNode, IconNodeWithUrl }; + export { filterDocumentByNodeType, getNodesWithUrl, getIconJsx }; diff --git a/scripts/sync-figma/src/integrations/figma/transforms.js b/scripts/sync-figma/src/integrations/figma/transforms.ts similarity index 88% rename from scripts/sync-figma/src/integrations/figma/transforms.js rename to scripts/sync-figma/src/integrations/figma/transforms.ts index b6273a29c..6a8b7303c 100644 --- a/scripts/sync-figma/src/integrations/figma/transforms.js +++ b/scripts/sync-figma/src/integrations/figma/transforms.ts @@ -1,6 +1,6 @@ import { camelCase } from 'lodash-es'; -const svgToIconComponent = (svgDom) => +const svgToIconComponent = (svgDom: string) => svgDom .toString() .replace(/(?<=]*)\s+(width|height)="[^"]*"/g, '') @@ -10,7 +10,7 @@ const svgToIconComponent = (svgDom) => .replace(/\s\w*-\w*="/gm, (w) => ' ' + camelCase(w) + '="') .replace(/>/, ' {...props} >'); -const remakeMaskStyle = (IconComponent) => { +const remakeMaskStyle = (IconComponent: string) => { const matches = IconComponent.matchAll(/(style)="(.*?)"/gi); for (const match of matches) { const splitColon = match[2].split(':'); @@ -29,7 +29,7 @@ const remakeMaskStyle = (IconComponent) => { return IconComponent; }; -const makeFlexibleColorIcon = (IconComponent) => +const makeFlexibleColorIcon = (IconComponent: string) => IconComponent.replace(/fill="black"/g, '').replace(/fill="none"/g, ''); export { svgToIconComponent, remakeMaskStyle, makeFlexibleColorIcon }; diff --git a/scripts/sync-figma/src/integrations/slack/api.js b/scripts/sync-figma/src/integrations/slack/api.ts similarity index 81% rename from scripts/sync-figma/src/integrations/slack/api.js rename to scripts/sync-figma/src/integrations/slack/api.ts index 49392ffd0..ccacf13e1 100644 --- a/scripts/sync-figma/src/integrations/slack/api.js +++ b/scripts/sync-figma/src/integrations/slack/api.ts @@ -1,4 +1,4 @@ -const sendWebhookMessage = async (webhookUrl, payload) => { +const sendWebhookMessage = async (webhookUrl: string, payload: unknown) => { const response = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/scripts/sync-figma/tsconfig.json b/scripts/sync-figma/tsconfig.json new file mode 100644 index 000000000..485164fae --- /dev/null +++ b/scripts/sync-figma/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@repo/typescript-config/base", + "compilerOptions": { + "noEmit": true, + "target": "ES2023", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["commands", "src"] +} From 004cb71f387c23741049734946bd4ed67a4abfb6 Mon Sep 17 00:00:00 2001 From: MaxLee-dev Date: Mon, 31 Aug 2026 16:06:15 +0900 Subject: [PATCH 2/6] refactor(sync-figma): convert SVG with SVGR + svgo instead of regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma SVG를 React 컴포넌트로 바꾸는 일을 정규식 네 단계(`transforms.ts`)가 하고 있었다. 문자열 치환이라 선언이 둘 이상인 `style` 속성에서 깨졌고, `` 루트와 자식을 구분하지 않고 `fill="none"`을 지워 stroke만 있는 아이콘을 망가뜨렸다. 이 변환을 `@svgr/core`(+`plugin-svgo`, `plugin-jsx`)에 넘긴다. 정규식이 하던 네 가지(크기 제거·속성 camelCase·`style` 객체화·색 비우기)가 전부 라이브러리 기본기로 덮이고, 덤으로 경로 데이터가 최적화된다. 기본값에서 벗어난 설정 세 가지는 각각 주석으로 이유를 남겼다. - `prefixIds`를 명시한다. svgo의 `cleanupIds`가 id를 `a`로 줄여 두 아이콘이 한 페이지에 있으면 마스크가 충돌한다. - `removeViewBox: false` + `removeDimensions` 조합으로 viewBox를 남기고 width/height만 지운다. 크기는 `IconBase`가 정한다. - mono 아이콘은 루트 `fill="none"`을 지운다. 남기면 자식의 `fill="black"`을 제거한 뒤 상속할 색이 없어 아이콘이 사라진다. 커스텀 플러그인 둘을 새로 뒀다. `keepStrokedShapesHollow`는 루트 `fill="none"`이 사라지기 전에 그 의도를 도형에 직접 적고, `strokeFollowsCurrentColor`는 Figma가 박아 넣은 `stroke="black"`을 `currentColor`로 바꿔 stroke도 `color`를 따르게 한다. `prettier` 설정은 하드코딩 대신 저장소 설정을 읽어 쓴다. import 정렬 플러그인까지 걸려서 생성된 파일이 `pnpm format` 결과와 어긋나지 않는다. --- pnpm-lock.yaml | 106 +++++-------- scripts/sync-figma/commands/sync-icons.ts | 13 +- scripts/sync-figma/package.json | 3 + .../icons/templates/icon/icon-component.ts | 11 -- .../sync-figma/src/integrations/figma/lib.ts | 20 +-- .../sync-figma/src/integrations/figma/svgr.ts | 146 ++++++++++++++++++ .../src/integrations/figma/transforms.ts | 35 ----- 7 files changed, 203 insertions(+), 131 deletions(-) delete mode 100644 scripts/sync-figma/src/icons/templates/icon/icon-component.ts create mode 100644 scripts/sync-figma/src/integrations/figma/svgr.ts delete mode 100644 scripts/sync-figma/src/integrations/figma/transforms.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 589afecd9..0540970e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -927,6 +927,15 @@ importers: scripts/sync-figma: dependencies: + '@svgr/core': + specifier: ^8.1.0 + version: 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': + specifier: ^8.1.0 + version: 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-svgo': + specifier: ^8.1.0 + version: 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) lodash-es: specifier: ^4.18.1 version: 4.18.1 @@ -1050,10 +1059,6 @@ packages: resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} - engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} @@ -9779,26 +9784,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@7.28.5': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.29.3 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -9960,15 +9945,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -13469,54 +13445,54 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.28.5)': + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 - '@svgr/babel-preset@8.1.0(@babel/core@7.28.5)': + '@svgr/babel-preset@8.1.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.28.5) - '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.28.5) - '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.28.5) - '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.28.5) - '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.28.5) - '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.28.5) - '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.28.5) - '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.0) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.0) '@svgr/core@8.1.0(typescript@5.9.3)': dependencies: - '@babel/core': 7.28.5 - '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) camelcase: 6.3.0 cosmiconfig: 8.3.6(typescript@5.9.3) snake-case: 3.0.4 @@ -13526,13 +13502,13 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': dependencies: - '@babel/core': 7.28.5 - '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/hast-util-to-babel-ast': 8.0.0 svg-parser: 2.0.4 @@ -15330,7 +15306,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: diff --git a/scripts/sync-figma/commands/sync-icons.ts b/scripts/sync-figma/commands/sync-icons.ts index 7e86829b4..01c155a6b 100644 --- a/scripts/sync-figma/commands/sync-icons.ts +++ b/scripts/sync-figma/commands/sync-icons.ts @@ -28,12 +28,11 @@ import { } from '../src/icons/constants.js'; import { ICON_TYPES } from '../src/icons/icon-types.js'; import getIconComponentIndex from '../src/icons/templates/icon/icon-component-index.js'; -import getIconComponent from '../src/icons/templates/icon/icon-component.js'; import getIconsIndex from '../src/icons/templates/icon/icons-index.js'; import type { IconNode } from '../src/integrations/figma/lib.js'; import { filterDocumentByNodeType, - getIconJsx, + getIconComponent, getNodesWithUrl, } from '../src/integrations/figma/lib.js'; @@ -48,12 +47,11 @@ function findRoot(dir: string): string { } const CURRENT_DIRECTORY = findRoot(path.dirname(fileURLToPath(import.meta.url))); const FIGMA_EMOJI_PREFIX_PATTERN = /❤️\s*/g; +// Resolve the repo's prettier config so generated files match what `pnpm format` produces, +// plugins (import sorting) included. const PRETTIER_OPTIONS = { + ...(await prettier.resolveConfig(path.join(CURRENT_DIRECTORY, 'packages/icons/src/index.ts'))), parser: 'typescript', - tabWidth: 4, - semi: true, - singleQuote: true, - printWidth: 100, }; function normalizeIconName(name: string) { @@ -142,8 +140,7 @@ try { parentId === decodeURIComponent(FIGMA_ICONS_SYMBOL_COLOR_NODE_ID) || parentId === decodeURIComponent(FIGMA_ICONS_SYMBOL_COLOR_COUNTRY_NODE_ID); - const iconJsx = await getIconJsx({ url, isColorIcon }); - const IconComponent = getIconComponent(iconName, iconJsx); + const IconComponent = await getIconComponent({ url, iconName, isColorIcon }); const formattedComponent = await prettier.format(IconComponent, PRETTIER_OPTIONS); let shouldWrite = isNewIcon; diff --git a/scripts/sync-figma/package.json b/scripts/sync-figma/package.json index 6221c2c72..bc34750a1 100644 --- a/scripts/sync-figma/package.json +++ b/scripts/sync-figma/package.json @@ -13,6 +13,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", + "@svgr/plugin-svgo": "^8.1.0", "lodash-es": "^4.18.1", "p-limit": "^6.2.0", "picocolors": "^1.1.1", diff --git a/scripts/sync-figma/src/icons/templates/icon/icon-component.ts b/scripts/sync-figma/src/icons/templates/icon/icon-component.ts deleted file mode 100644 index 778a4e6d9..000000000 --- a/scripts/sync-figma/src/icons/templates/icon/icon-component.ts +++ /dev/null @@ -1,11 +0,0 @@ -export default (IconName: string, Icon: string) => ` - -import IconBase from '~/components/icon-base'; -import type { IconProps } from '~/components/icon-base'; - -const ${IconName} = (props : IconProps) => ( - ${Icon} -); - -export default ${IconName}; -`; diff --git a/scripts/sync-figma/src/integrations/figma/lib.ts b/scripts/sync-figma/src/integrations/figma/lib.ts index 322bd59b5..51854a4c7 100644 --- a/scripts/sync-figma/src/integrations/figma/lib.ts +++ b/scripts/sync-figma/src/integrations/figma/lib.ts @@ -1,7 +1,7 @@ import type { FigmaNodeType } from '../../icons/constants.js'; import type { FigmaNode } from './api.js'; import { getFileNodes, getImage } from './api.js'; -import { makeFlexibleColorIcon, remakeMaskStyle, svgToIconComponent } from './transforms.js'; +import { svgToIconComponent } from './svgr.js'; type IconNode = FigmaNode & { parentId: string }; type IconNodeWithUrl = IconNode & { url: string }; @@ -69,30 +69,26 @@ const getNodesWithUrl = async ({ }; /** - * Convert svg files from Figma to React components. + * Convert an svg file from Figma into a React component. */ -const getIconJsx = async ({ +const getIconComponent = async ({ url, + iconName, isColorIcon, }: { url: string; + iconName: string; isColorIcon: boolean; }): Promise => { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch SVG: ${response.status} ${response.statusText}`); } - const svgDom = await response.text(); - const IconComponent = svgToIconComponent(svgDom); - const NewIconComponent = remakeMaskStyle(IconComponent); + const svg = await response.text(); - if (isColorIcon) { - return NewIconComponent; - } else { - return makeFlexibleColorIcon(NewIconComponent); - } + return svgToIconComponent({ svg, iconName, isColorIcon }); }; export type { IconNode, IconNodeWithUrl }; -export { filterDocumentByNodeType, getNodesWithUrl, getIconJsx }; +export { filterDocumentByNodeType, getNodesWithUrl, getIconComponent }; diff --git a/scripts/sync-figma/src/integrations/figma/svgr.ts b/scripts/sync-figma/src/integrations/figma/svgr.ts new file mode 100644 index 000000000..ccc58a169 --- /dev/null +++ b/scripts/sync-figma/src/integrations/figma/svgr.ts @@ -0,0 +1,146 @@ +import type { Config } from '@svgr/core'; +import { transform } from '@svgr/core'; + +type SvgoPlugin = Extract< + NonNullable['plugins']>[number], + { fn: unknown } +>; + +/** + * Wrap the SVG in IconBase instead of rendering a bare . + */ +const template: Config['template'] = ({ componentName, jsx }, { tpl }) => { + const identifier = { + type: 'JSXIdentifier', + name: 'IconBase', + } as typeof jsx.openingElement.name; + jsx.openingElement.name = identifier; + if (jsx.closingElement) jsx.closingElement.name = identifier; + jsx.openingElement.attributes.push({ + type: 'JSXSpreadAttribute', + argument: { type: 'Identifier', name: 'props' }, + } as (typeof jsx.openingElement.attributes)[number]); + + return tpl` +import IconBase from '~/components/icon-base'; +import type { IconProps } from '~/components/icon-base'; + +const ${componentName} = (props: IconProps) => ( + ${jsx} +); + +export default ${componentName}; +`; +}; + +const SHAPE_ELEMENTS = new Set([ + 'path', + 'circle', + 'ellipse', + 'rect', + 'line', + 'polygon', + 'polyline', +]); + +/** + * Figma leans on the root fill="none" to keep stroked shapes hollow, but mono icons have to + * drop that fill so IconBase's colour can be inherited. Spell the intent out on the shape + * itself first, otherwise the stroke ends up outlining a solid block. + * + * Only runs while the root fill="none" is still there, which is exactly the first pass: + * on later passes removeAttrs has taken it away, and a shape that lost its own fill to + * removeAttrs (fill="black" plus a stroke) must keep inheriting instead of turning hollow. + */ +const keepStrokedShapesHollow: SvgoPlugin = { + name: 'keepStrokedShapesHollow', + fn: (root) => { + const svg = root.children.find((child) => 'name' in child && child.name === 'svg'); + if (svg == null || !('attributes' in svg) || svg.attributes.fill !== 'none') return null; + + return { + element: { + enter: (node) => { + if (!SHAPE_ELEMENTS.has(node.name)) return; + if (node.attributes.fill != null || node.attributes.stroke == null) return; + + node.attributes.fill = 'none'; + }, + }, + }; + }, +}; + +const BLACK = /^(#000|#000000|black)$/i; + +/** + * Figma hardcodes stroke="black" on the few stroked mono icons, which makes them ignore the + * colour IconBase hands down. Point them at currentColor so stroke follows `color` like fill does. + */ +const strokeFollowsCurrentColor: SvgoPlugin = { + name: 'strokeFollowsCurrentColor', + fn: () => ({ + element: { + enter: (node) => { + if (BLACK.test(node.attributes.stroke ?? '')) + node.attributes.stroke = 'currentColor'; + }, + }, + }), +}; + +/** + * Build the SVGR config for a single icon. + * + * Passing svgoConfig replaces SVGR's default svgo config wholesale, so prefixIds has to be + * listed explicitly. Without it every icon's ids collapse to `a`, and two icons on one page + * make the second reference the first one's mask. + */ +const buildConfig = ({ + iconName, + isColorIcon, +}: { + iconName: string; + isColorIcon: boolean; +}): Config => ({ + typescript: true, + template, + plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], + svgoConfig: { + multipass: true, + plugins: [ + // removeViewBox would drop the viewBox because it matches width/height; + // removeDimensions then drops width/height instead, leaving IconBase in charge of size. + { name: 'preset-default', params: { overrides: { removeViewBox: false } } }, + 'removeDimensions', + { + name: 'prefixIds', + params: { prefix: `vapor-icons-${isColorIcon ? 'color' : 'mono'}-${iconName}` }, + }, + // Mono icons take their colour from IconBase's fill, so the root fill="none" + // and every black fill have to go for it to be inherited. + ...(isColorIcon + ? [] + : [ + keepStrokedShapesHollow, + strokeFollowsCurrentColor, + { + name: 'removeAttrs' as const, + params: { attrs: ['svg:fill:none', '*:fill:(#000|#000000|black)'] }, + }, + ]), + ], + }, +}); + +const svgToIconComponent = ({ + svg, + iconName, + isColorIcon, +}: { + svg: string; + iconName: string; + isColorIcon: boolean; +}) => transform(svg, buildConfig({ iconName, isColorIcon }), { componentName: iconName }); + +export { svgToIconComponent }; diff --git a/scripts/sync-figma/src/integrations/figma/transforms.ts b/scripts/sync-figma/src/integrations/figma/transforms.ts deleted file mode 100644 index 6a8b7303c..000000000 --- a/scripts/sync-figma/src/integrations/figma/transforms.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { camelCase } from 'lodash-es'; - -const svgToIconComponent = (svgDom: string) => - svgDom - .toString() - .replace(/(?<=]*)\s+(width|height)="[^"]*"/g, '') - .replace(/^\/gm, '') - .replace(/\s\w*:\w*="/gm, (w) => ' ' + camelCase(w) + '="') - .replace(/\s\w*-\w*="/gm, (w) => ' ' + camelCase(w) + '="') - .replace(/>/, ' {...props} >'); - -const remakeMaskStyle = (IconComponent: string) => { - const matches = IconComponent.matchAll(/(style)="(.*?)"/gi); - for (const match of matches) { - const splitColon = match[2].split(':'); - const splitHypen = splitColon[0].split('-'); - const key = splitHypen - .map((word, index) => { - if (index === 0) return word; - word = word.charAt(0).toUpperCase() + word.slice(1); - return word; - }) - .join(''); - const value = splitColon[1]; - const styleReplace = `${match[1]}={{ ${key}: "${value}" }}`; - IconComponent = IconComponent.replace(match[0], styleReplace); - } - return IconComponent; -}; - -const makeFlexibleColorIcon = (IconComponent: string) => - IconComponent.replace(/fill="black"/g, '').replace(/fill="none"/g, ''); - -export { svgToIconComponent, remakeMaskStyle, makeFlexibleColorIcon }; From 400048b7ef1a8bfe52ed6bad688f0e2268ba9bc1 Mon Sep 17 00:00:00 2001 From: MaxLee-dev Date: Mon, 31 Aug 2026 16:33:02 +0900 Subject: [PATCH 3/6] refactor: update import paths to use absolute paths and streamline code --- scripts/sync-figma/commands/notify-slack.ts | 11 +++-------- scripts/sync-figma/commands/sync-icons.ts | 14 +++++++------- scripts/sync-figma/src/icons/icon-types.ts | 2 +- scripts/sync-figma/src/integrations/figma/api.ts | 2 +- scripts/sync-figma/src/integrations/figma/lib.ts | 8 ++++---- scripts/sync-figma/tsconfig.json | 4 ++++ 6 files changed, 20 insertions(+), 21 deletions(-) diff --git a/scripts/sync-figma/commands/notify-slack.ts b/scripts/sync-figma/commands/notify-slack.ts index a9bf8f981..4c2478e5f 100644 --- a/scripts/sync-figma/commands/notify-slack.ts +++ b/scripts/sync-figma/commands/notify-slack.ts @@ -13,7 +13,7 @@ */ import process from 'node:process'; -import { sendWebhookMessage } from '../src/integrations/slack/api.js'; +import { sendWebhookMessage } from '~/integrations/slack/api'; const { SLACK_GDS_ALARM_WEBHOOK_URL, @@ -68,10 +68,5 @@ const message = { ], }; -try { - await sendWebhookMessage(SLACK_GDS_ALARM_WEBHOOK_URL, message); - console.log('✅ Slack 알림이 성공적으로 전송되었습니다.'); -} catch (error) { - console.error('❌ Slack 알림 전송 중 오류 발생:', error); - process.exit(1); -} +await sendWebhookMessage(SLACK_GDS_ALARM_WEBHOOK_URL, message); +console.log('✅ Slack 알림이 성공적으로 전송되었습니다.'); diff --git a/scripts/sync-figma/commands/sync-icons.ts b/scripts/sync-figma/commands/sync-icons.ts index 7e86829b4..be00add8a 100644 --- a/scripts/sync-figma/commands/sync-icons.ts +++ b/scripts/sync-figma/commands/sync-icons.ts @@ -25,17 +25,17 @@ import { FIGMA_ICONS_SYMBOL_COLOR_COUNTRY_NODE_ID, FIGMA_ICONS_SYMBOL_COLOR_NODE_ID, FIGMA_NODE_TYPES, -} from '../src/icons/constants.js'; -import { ICON_TYPES } from '../src/icons/icon-types.js'; -import getIconComponentIndex from '../src/icons/templates/icon/icon-component-index.js'; -import getIconComponent from '../src/icons/templates/icon/icon-component.js'; -import getIconsIndex from '../src/icons/templates/icon/icons-index.js'; -import type { IconNode } from '../src/integrations/figma/lib.js'; +} from '~/icons/constants'; +import { ICON_TYPES } from '~/icons/icon-types'; +import getIconComponentIndex from '~/icons/templates/icon/icon-component-index'; +import getIconComponent from '~/icons/templates/icon/icon-component'; +import getIconsIndex from '~/icons/templates/icon/icons-index'; +import type { IconNode } from '~/integrations/figma/lib'; import { filterDocumentByNodeType, getIconJsx, getNodesWithUrl, -} from '../src/integrations/figma/lib.js'; +} from '~/integrations/figma/lib'; const TYPE = process.env.TYPE; diff --git a/scripts/sync-figma/src/icons/icon-types.ts b/scripts/sync-figma/src/icons/icon-types.ts index 7d0d04e75..03a2c1709 100644 --- a/scripts/sync-figma/src/icons/icon-types.ts +++ b/scripts/sync-figma/src/icons/icon-types.ts @@ -1,4 +1,4 @@ -import { FIGMA_ICONS_BASIC_NODE_IDS, FIGMA_ICONS_SYMBOL_NODE_IDS } from './constants.js'; +import { FIGMA_ICONS_BASIC_NODE_IDS, FIGMA_ICONS_SYMBOL_NODE_IDS } from './constants'; /** * Script information by npm script diff --git a/scripts/sync-figma/src/integrations/figma/api.ts b/scripts/sync-figma/src/integrations/figma/api.ts index 9b1e7f578..6e14b5c43 100644 --- a/scripts/sync-figma/src/integrations/figma/api.ts +++ b/scripts/sync-figma/src/integrations/figma/api.ts @@ -1,6 +1,6 @@ import process from 'node:process'; -import type { FigmaNodeType } from '../../icons/constants.js'; +import type { FigmaNodeType } from '~/icons/constants'; const headers = { 'X-FIGMA-TOKEN': process.env.FIGMA_TOKEN ?? '', diff --git a/scripts/sync-figma/src/integrations/figma/lib.ts b/scripts/sync-figma/src/integrations/figma/lib.ts index 322bd59b5..0ce3a99c9 100644 --- a/scripts/sync-figma/src/integrations/figma/lib.ts +++ b/scripts/sync-figma/src/integrations/figma/lib.ts @@ -1,7 +1,7 @@ -import type { FigmaNodeType } from '../../icons/constants.js'; -import type { FigmaNode } from './api.js'; -import { getFileNodes, getImage } from './api.js'; -import { makeFlexibleColorIcon, remakeMaskStyle, svgToIconComponent } from './transforms.js'; +import type { FigmaNodeType } from '~/icons/constants'; +import type { FigmaNode } from './api'; +import { getFileNodes, getImage } from './api'; +import { makeFlexibleColorIcon, remakeMaskStyle, svgToIconComponent } from './transforms'; type IconNode = FigmaNode & { parentId: string }; type IconNodeWithUrl = IconNode & { url: string }; diff --git a/scripts/sync-figma/tsconfig.json b/scripts/sync-figma/tsconfig.json index 485164fae..2f87b4400 100644 --- a/scripts/sync-figma/tsconfig.json +++ b/scripts/sync-figma/tsconfig.json @@ -2,6 +2,10 @@ "extends": "@repo/typescript-config/base", "compilerOptions": { "noEmit": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, "target": "ES2023", "lib": ["ES2023"], "types": ["node"] From 39f4729e73513418f67d0e7c8bc53b3679e27fad Mon Sep 17 00:00:00 2001 From: MaxLee-dev Date: Tue, 1 Sep 2026 13:53:24 +0900 Subject: [PATCH 4/6] fix(sync-figma): keep mono icon strokes hollow via root fill="none" --- .../sync-figma/src/integrations/figma/svgr.ts | 67 +++---------------- 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/scripts/sync-figma/src/integrations/figma/svgr.ts b/scripts/sync-figma/src/integrations/figma/svgr.ts index ccc58a169..a60fd8add 100644 --- a/scripts/sync-figma/src/integrations/figma/svgr.ts +++ b/scripts/sync-figma/src/integrations/figma/svgr.ts @@ -33,57 +33,22 @@ export default ${componentName}; `; }; -const SHAPE_ELEMENTS = new Set([ - 'path', - 'circle', - 'ellipse', - 'rect', - 'line', - 'polygon', - 'polyline', -]); - -/** - * Figma leans on the root fill="none" to keep stroked shapes hollow, but mono icons have to - * drop that fill so IconBase's colour can be inherited. Spell the intent out on the shape - * itself first, otherwise the stroke ends up outlining a solid block. - * - * Only runs while the root fill="none" is still there, which is exactly the first pass: - * on later passes removeAttrs has taken it away, and a shape that lost its own fill to - * removeAttrs (fill="black" plus a stroke) must keep inheriting instead of turning hollow. - */ -const keepStrokedShapesHollow: SvgoPlugin = { - name: 'keepStrokedShapesHollow', - fn: (root) => { - const svg = root.children.find((child) => 'name' in child && child.name === 'svg'); - if (svg == null || !('attributes' in svg) || svg.attributes.fill !== 'none') return null; - - return { - element: { - enter: (node) => { - if (!SHAPE_ELEMENTS.has(node.name)) return; - if (node.attributes.fill != null || node.attributes.stroke == null) return; - - node.attributes.fill = 'none'; - }, - }, - }; - }, -}; - const BLACK = /^(#000|#000000|black)$/i; /** - * Figma hardcodes stroke="black" on the few stroked mono icons, which makes them ignore the - * colour IconBase hands down. Point them at currentColor so stroke follows `color` like fill does. + * Figma paints mono icons with a literal black fill (and stroke on a few), which would ignore the + * colour IconBase hands down. Point both at currentColor; the root fill="none" Figma always emits + * stays put so stroked shapes keep inheriting `none` and stay hollow. */ -const strokeFollowsCurrentColor: SvgoPlugin = { - name: 'strokeFollowsCurrentColor', +const blackFollowsCurrentColor: SvgoPlugin = { + name: 'blackFollowsCurrentColor', fn: () => ({ element: { enter: (node) => { - if (BLACK.test(node.attributes.stroke ?? '')) - node.attributes.stroke = 'currentColor'; + for (const attr of ['fill', 'stroke'] as const) { + if (BLACK.test(node.attributes[attr] ?? '')) + node.attributes[attr] = 'currentColor'; + } }, }, }), @@ -117,18 +82,8 @@ const buildConfig = ({ name: 'prefixIds', params: { prefix: `vapor-icons-${isColorIcon ? 'color' : 'mono'}-${iconName}` }, }, - // Mono icons take their colour from IconBase's fill, so the root fill="none" - // and every black fill have to go for it to be inherited. - ...(isColorIcon - ? [] - : [ - keepStrokedShapesHollow, - strokeFollowsCurrentColor, - { - name: 'removeAttrs' as const, - params: { attrs: ['svg:fill:none', '*:fill:(#000|#000000|black)'] }, - }, - ]), + // Mono icons follow the consumer's `color`; colour icons keep Figma's palette. + ...(isColorIcon ? [] : [blackFollowsCurrentColor]), ], }, }); From c6232cafda20b606bc73ebc3c6adabd72815e25e Mon Sep 17 00:00:00 2001 From: MaxLee-dev Date: Tue, 1 Sep 2026 14:11:21 +0900 Subject: [PATCH 5/6] fix: apply CodeRabbit auto-fixes --- scripts/sync-figma/commands/sync-icons.ts | 10 +++------- .../sync-figma/src/integrations/figma/api.ts | 4 ++-- .../sync-figma/src/integrations/figma/lib.ts | 20 ++++++++++++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/scripts/sync-figma/commands/sync-icons.ts b/scripts/sync-figma/commands/sync-icons.ts index be00add8a..b698d9155 100644 --- a/scripts/sync-figma/commands/sync-icons.ts +++ b/scripts/sync-figma/commands/sync-icons.ts @@ -27,15 +27,11 @@ import { FIGMA_NODE_TYPES, } from '~/icons/constants'; import { ICON_TYPES } from '~/icons/icon-types'; -import getIconComponentIndex from '~/icons/templates/icon/icon-component-index'; import getIconComponent from '~/icons/templates/icon/icon-component'; +import getIconComponentIndex from '~/icons/templates/icon/icon-component-index'; import getIconsIndex from '~/icons/templates/icon/icons-index'; import type { IconNode } from '~/integrations/figma/lib'; -import { - filterDocumentByNodeType, - getIconJsx, - getNodesWithUrl, -} from '~/integrations/figma/lib'; +import { filterDocumentByNodeType, getIconJsx, getNodesWithUrl } from '~/integrations/figma/lib'; const TYPE = process.env.TYPE; @@ -67,7 +63,7 @@ if (!process.env.FIGMA_TOKEN) { process.exit(1); } -if (!TYPE || !(TYPE in ICON_TYPES)) { +if (!TYPE || !Object.hasOwn(ICON_TYPES, TYPE)) { console.error( pc.red( ` GDS FIGMA EXPORT ERROR: TYPE must be one of ${Object.keys(ICON_TYPES).join(', ')}.`, diff --git a/scripts/sync-figma/src/integrations/figma/api.ts b/scripts/sync-figma/src/integrations/figma/api.ts index 6e14b5c43..52a5e0195 100644 --- a/scripts/sync-figma/src/integrations/figma/api.ts +++ b/scripts/sync-figma/src/integrations/figma/api.ts @@ -15,11 +15,11 @@ type FigmaNode = { }; type GetFileNodesResponse = { - nodes: Record; + nodes: Record; }; type GetImageResponse = { - images: Record; + images: Record; }; /** diff --git a/scripts/sync-figma/src/integrations/figma/lib.ts b/scripts/sync-figma/src/integrations/figma/lib.ts index 0ce3a99c9..f7df33e81 100644 --- a/scripts/sync-figma/src/integrations/figma/lib.ts +++ b/scripts/sync-figma/src/integrations/figma/lib.ts @@ -1,4 +1,5 @@ import type { FigmaNodeType } from '~/icons/constants'; + import type { FigmaNode } from './api'; import { getFileNodes, getImage } from './api'; import { makeFlexibleColorIcon, remakeMaskStyle, svgToIconComponent } from './transforms'; @@ -29,7 +30,12 @@ const filterDocumentByNodeType = async ({ for (const key in nodes) { if (Object.hasOwn(nodes, key)) { - const parent = nodes[key].document; + const node = nodes[key]; + if (!node) { + throw new Error(`Figma node not found: ${key}`); + } + + const parent = node.document; const parentId = parent.id; parent.children?.forEach((child) => { @@ -60,10 +66,14 @@ const getNodesWithUrl = async ({ fileKey, nodeIds, }); - const nodesWithUrl = nodes.map((item) => ({ - ...item, - url: images[item.id], - })); + const nodesWithUrl = nodes.map((item) => { + const url = images[item.id]; + if (!url) { + throw new Error(`Figma image URL missing for node: ${item.id} (${item.name})`); + } + + return { ...item, url }; + }); return nodesWithUrl; }; From 5c66a14ab19be5a5eac000e9b06a82ad1ff8abd0 Mon Sep 17 00:00:00 2001 From: MaxLee-dev Date: Tue, 1 Sep 2026 16:51:06 +0900 Subject: [PATCH 6/6] refactor(sync-figma): use `~/*` path alias for src imports --- scripts/sync-figma/commands/notify-slack.ts | 2 +- scripts/sync-figma/commands/sync-icons.ts | 12 ++++++------ scripts/sync-figma/src/icons/icon-types.ts | 2 +- scripts/sync-figma/src/integrations/figma/api.ts | 2 +- scripts/sync-figma/src/integrations/figma/lib.ts | 9 +++++---- scripts/sync-figma/tsconfig.json | 4 ++++ 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/scripts/sync-figma/commands/notify-slack.ts b/scripts/sync-figma/commands/notify-slack.ts index a9bf8f981..e7947861d 100644 --- a/scripts/sync-figma/commands/notify-slack.ts +++ b/scripts/sync-figma/commands/notify-slack.ts @@ -13,7 +13,7 @@ */ import process from 'node:process'; -import { sendWebhookMessage } from '../src/integrations/slack/api.js'; +import { sendWebhookMessage } from '~/integrations/slack/api'; const { SLACK_GDS_ALARM_WEBHOOK_URL, diff --git a/scripts/sync-figma/commands/sync-icons.ts b/scripts/sync-figma/commands/sync-icons.ts index 01c155a6b..e43c368e2 100644 --- a/scripts/sync-figma/commands/sync-icons.ts +++ b/scripts/sync-figma/commands/sync-icons.ts @@ -25,16 +25,16 @@ import { FIGMA_ICONS_SYMBOL_COLOR_COUNTRY_NODE_ID, FIGMA_ICONS_SYMBOL_COLOR_NODE_ID, FIGMA_NODE_TYPES, -} from '../src/icons/constants.js'; -import { ICON_TYPES } from '../src/icons/icon-types.js'; -import getIconComponentIndex from '../src/icons/templates/icon/icon-component-index.js'; -import getIconsIndex from '../src/icons/templates/icon/icons-index.js'; -import type { IconNode } from '../src/integrations/figma/lib.js'; +} from '~/icons/constants'; +import { ICON_TYPES } from '~/icons/icon-types'; +import getIconComponentIndex from '~/icons/templates/icon/icon-component-index'; +import getIconsIndex from '~/icons/templates/icon/icons-index'; +import type { IconNode } from '~/integrations/figma/lib'; import { filterDocumentByNodeType, getIconComponent, getNodesWithUrl, -} from '../src/integrations/figma/lib.js'; +} from '~/integrations/figma/lib'; const TYPE = process.env.TYPE; diff --git a/scripts/sync-figma/src/icons/icon-types.ts b/scripts/sync-figma/src/icons/icon-types.ts index 7d0d04e75..03a2c1709 100644 --- a/scripts/sync-figma/src/icons/icon-types.ts +++ b/scripts/sync-figma/src/icons/icon-types.ts @@ -1,4 +1,4 @@ -import { FIGMA_ICONS_BASIC_NODE_IDS, FIGMA_ICONS_SYMBOL_NODE_IDS } from './constants.js'; +import { FIGMA_ICONS_BASIC_NODE_IDS, FIGMA_ICONS_SYMBOL_NODE_IDS } from './constants'; /** * Script information by npm script diff --git a/scripts/sync-figma/src/integrations/figma/api.ts b/scripts/sync-figma/src/integrations/figma/api.ts index 9b1e7f578..6e14b5c43 100644 --- a/scripts/sync-figma/src/integrations/figma/api.ts +++ b/scripts/sync-figma/src/integrations/figma/api.ts @@ -1,6 +1,6 @@ import process from 'node:process'; -import type { FigmaNodeType } from '../../icons/constants.js'; +import type { FigmaNodeType } from '~/icons/constants'; const headers = { 'X-FIGMA-TOKEN': process.env.FIGMA_TOKEN ?? '', diff --git a/scripts/sync-figma/src/integrations/figma/lib.ts b/scripts/sync-figma/src/integrations/figma/lib.ts index 51854a4c7..c3a4e101f 100644 --- a/scripts/sync-figma/src/integrations/figma/lib.ts +++ b/scripts/sync-figma/src/integrations/figma/lib.ts @@ -1,7 +1,8 @@ -import type { FigmaNodeType } from '../../icons/constants.js'; -import type { FigmaNode } from './api.js'; -import { getFileNodes, getImage } from './api.js'; -import { svgToIconComponent } from './svgr.js'; +import type { FigmaNodeType } from '~/icons/constants'; + +import type { FigmaNode } from './api'; +import { getFileNodes, getImage } from './api'; +import { svgToIconComponent } from './svgr'; type IconNode = FigmaNode & { parentId: string }; type IconNodeWithUrl = IconNode & { url: string }; diff --git a/scripts/sync-figma/tsconfig.json b/scripts/sync-figma/tsconfig.json index 485164fae..55bf0fcdf 100644 --- a/scripts/sync-figma/tsconfig.json +++ b/scripts/sync-figma/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "@repo/typescript-config/base", "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, "noEmit": true, "target": "ES2023", "lib": ["ES2023"],