diff --git a/packages/composites/package.json b/packages/composites/package.json index 6822771a8..dad721e25 100644 --- a/packages/composites/package.json +++ b/packages/composites/package.json @@ -54,6 +54,7 @@ "scripts": { "build": "rimraf dist && rolldown -c rolldown.config.ts", "clean": "rimraf dist node_modules .turbo", + "figma:gen": "extract-code-connect", "lint": "eslint ./src", "test": "vitest --run --passWithNoTests", "test:coverage": "vitest --coverage --passWithNoTests", @@ -79,6 +80,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vapor-ui/core": "workspace:^", + "@vapor-ui/extract-code-connect": "workspace:*", "@vitest/browser": "^3.2.7", "@vitest/coverage-v8": "^3.2.7", "eslint": "catalog:", diff --git a/packages/composites/src/components/dialog/dialog.figma.ts b/packages/composites/src/components/dialog/dialog.figma.ts index d602b6c17..35d1ac0b4 100644 --- a/packages/composites/src/components/dialog/dialog.figma.ts +++ b/packages/composites/src/components/dialog/dialog.figma.ts @@ -13,7 +13,7 @@ const popup = getProperties(instance, '(Popup)', { const header = getProperties(instance, '(Header)', { title: { kind: 'string', name: 'title' }, - description: { kind: 'string', name: 'description' }, + description: { kind: 'string', name: 'description', visibleWhen: '(has description)' }, }); const body = getProperties(instance, '(Body)', { @@ -21,15 +21,13 @@ const body = getProperties(instance, '(Body)', { }); const footer = getProperties(instance, '(Footer)', { - assistive: { kind: 'instance', name: 'Assistive' }, + assistive: { kind: 'instance', name: 'Assistive', visibleWhen: '(has assistive)' }, action: { kind: 'instance', name: 'Action' }, }); export default { example: figma.code` } - | { kind: 'instance'; name: string }; + | { kind: 'string'; name: string; visibleWhen?: string } + | { kind: 'boolean'; name: string; visibleWhen?: string } + | { kind: 'slot'; name: string; visibleWhen?: string } + | { kind: 'enum'; name: string; options: Record; visibleWhen?: string } + | { kind: 'instance'; name: string; visibleWhen?: string }; /** * Descend into a nested INSTANCE child of `parent` by name. * Returns the INSTANCE ref (chainable into `getProperties` / `findChild`), * or `undefined` when the child is missing or not an INSTANCE. - * - * Use when a component is wrapped in an anatomy layer (e.g. `(Popup)` around - * `(Header)` / `(Body)` / `(Footer)`) and you need to read properties from - * the inner instances. - * - * @param parent Parent instance/component to search inside. - * @param instanceName Direct-child instance name (e.g. '(Popup)'). - * @returns Child INSTANCE ref, or `undefined`. */ export function findChild( parent: AnyProp, @@ -41,6 +34,8 @@ export function findChild( * Returns `undefined` when the value is missing (e.g. instance not found). */ function readRawValue(target: AnyProp, spec: PropSpec): AnyProp { + if (spec.visibleWhen && target.getBoolean(spec.visibleWhen) === false) return; + switch (spec.kind) { case 'string': return target.getString(spec.name); @@ -63,20 +58,6 @@ function readRawValue(target: AnyProp, spec: PropSpec): AnyProp { * Read multiple properties from a nested INSTANCE inside `parent` and return * them **pre-rendered as JSX attribute fragments**, keyed by the code prop name. * - * Each key in `specs` is the code prop name. Every non-`slot` value is rendered - * with `figma.helpers.react.renderProp(key, value)`: - * - * - `undefined` / `''` → `''` (attribute omitted — safe for optional props) - * - `string` → ` key="value"` - * - `boolean` → ` key` or `''` - * - `ResultSection[]` → ` key={}` (or fragment-wrapped when > 1) - * - * `slot` values are passed through `renderChildren` and stay raw so they can be - * interpolated as element children. - * - * All keys are always present, so a missing target instance yields `''` for - * every attribute instead of the literal string `undefined`. - * * @example * const footer = getProperties(instance, '(Footer)', { * assistive: { kind: 'instance', name: 'Assistive' }, @@ -84,10 +65,6 @@ function readRawValue(target: AnyProp, spec: PropSpec): AnyProp { * }); * figma.code`${body.children}`; * - * @param parent Parent instance/component to search inside. - * @param instanceName Direct-child instance name (e.g. '(Header)'). - * @param specs Map of code prop name → figma property descriptor. - * @returns Map of code prop name → rendered attribute (or raw slot). */ export function getProperties( parent: AnyProp, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec8ae51db..1babc8023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,6 +542,9 @@ importers: '@vapor-ui/core': specifier: workspace:^ version: link:../core + '@vapor-ui/extract-code-connect': + specifier: workspace:* + version: link:../../scripts/extract-code-connect '@vitest/browser': specifier: ^3.2.7 version: 3.2.7(playwright@1.62.1)(vite@8.2.1(@types/node@22.19.21)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@3.2.7) @@ -934,6 +937,37 @@ importers: packages/typescript-config: {} + scripts/extract-code-connect: + dependencies: + prettier: + specifier: ^3.9.6 + version: 3.9.6 + yargs: + specifier: ^18.1.0 + version: 18.1.0 + devDependencies: + '@repo/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + '@types/yargs': + specifier: ^17.0.35 + version: 17.0.35 + eslint: + specifier: 'catalog:' + version: 9.39.5(jiti@2.7.0) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.13)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 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.13)(yaml@2.9.0) + scripts/sync-figma: dependencies: '@svgr/core': @@ -5666,6 +5700,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -6028,6 +6066,9 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6660,6 +6701,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -8945,6 +8990,14 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -9751,6 +9804,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -9808,10 +9865,18 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -15353,6 +15418,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + clone-deep@4.0.1: dependencies: is-plain-object: 2.0.4 @@ -15663,6 +15734,8 @@ snapshots: emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -15929,7 +16002,7 @@ snapshots: '@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)(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(@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@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-import-resolver-typescript@3.10.1)(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)) @@ -15962,7 +16035,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)(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(@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@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -15985,7 +16058,7 @@ snapshots: '@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)(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(@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@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -16529,6 +16602,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -19695,6 +19770,17 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -20828,6 +20914,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} write-file-atomic@5.0.1: @@ -20853,6 +20945,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -20863,6 +20957,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yn@3.1.1: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb4f3caa1..822850d71 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - packages/* - scripts/ts-api-extractor - scripts/sync-figma + - scripts/extract-code-connect # Unified React 19 for entire monorepo # Libraries (packages/*) maintain peerDependencies for React 17/18/19 compatibility diff --git a/scripts/extract-code-connect/README.md b/scripts/extract-code-connect/README.md new file mode 100644 index 000000000..0a3ca7393 --- /dev/null +++ b/scripts/extract-code-connect/README.md @@ -0,0 +1,53 @@ +# @vapor-ui/extract-code-connect + +Figma 컴포넌트(URL 또는 MCP `get_context_for_code_connect` JSON)에서 `getProperties` 기반 +parserless Code Connect 템플릿 `.figma.ts`를 생성하는 CLI. + +추출·렌더 규칙: `docs/superpowers/specs/2026-09-02-gen-code-connect-design.md` +패키지 설계: `docs/superpowers/specs/2026-09-03-extract-code-connect-cli-design.md` + +## 소비 패키지에서 사용 + +```jsonc +// packages//package.json +"scripts": { "figma:gen": "extract-code-connect" }, +"devDependencies": { "@vapor-ui/extract-code-connect": "workspace:*" } +``` + +```bash +pnpm --filter @vapor-ui/extract-code-connect build # 최초 1회 (turbo test/typecheck 는 자동 선행) +pnpm figma:gen "" [--force] [--from-json

] [--out

] [--utils

] +pnpm figma:gen --help # 옵션 설명 +pnpm figma:gen --version +``` + +CLI 는 현재 작업 디렉터리를 소비 패키지 루트로 본다. + +| 항목 | 결정 | +| ---------------------- | -------------------------------------------------------------------------- | +| 출력 기본 경로 | `src/components//.figma.ts` | +| `imports` 패키지명 | `figma.config.json` `packageImportPath` → 없으면 `package.json` `name` | +| `getProperties` import | `--utils` (기본 `src/utils/figma-utils`) 를 출력 파일 기준 상대경로로 변환 | +| 루트 태그 | `src/components//index.parts.ts` 존재 → `` | +| `FIGMA_TOKEN` | 환경변수 → 없으면 `/.env`. `--from-json` 이면 불필요 | + +## 새 패키지에 도입하기 (예: core) + +1. `src/utils/figma-utils.ts` 를 composites 에서 복사한다 (`getProperties`, `findChild`). +2. `figma.config.json` 에 `packageImportPath` 를 두거나 `package.json` `name` 에 맡긴다. +3. 위 devDependency·스크립트를 추가한다. + +## 개발 + +```bash +pnpm --filter @vapor-ui/extract-code-connect test +pnpm --filter @vapor-ui/extract-code-connect typecheck +pnpm --filter @vapor-ui/extract-code-connect lint +``` + +테스트는 구현 파일 옆 `.test.ts`. fixture 는 `src/fixtures/`. + +엔트리는 `bin/extract-code-connect.mjs` (빌드 없는 shebang 파일, `dist/cli/index.js` 를 import). +CLI 는 [yargs](https://yargs.js.org/) 기반. `src/cli/index.ts` 가 `CommandModule` 을 `.command()` 로 등록하고 +(`src/cli/cmdGenerate.ts` = `$0 `), 실제 생성 파이프라인은 `src/generate.ts`. 명령 추가 시 +`src/cli/cmd.ts` 를 만들고 `index.ts` 에 `.command(cmdName)` 한 줄을 더한다. diff --git a/scripts/extract-code-connect/bin/extract-code-connect.mjs b/scripts/extract-code-connect/bin/extract-code-connect.mjs new file mode 100755 index 000000000..924fff123 --- /dev/null +++ b/scripts/extract-code-connect/bin/extract-code-connect.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/index.js'; diff --git a/scripts/extract-code-connect/eslint.config.mjs b/scripts/extract-code-connect/eslint.config.mjs new file mode 100644 index 000000000..68b4b5c13 --- /dev/null +++ b/scripts/extract-code-connect/eslint.config.mjs @@ -0,0 +1,3 @@ +import { configs } from '@repo/eslint-config/base'; + +export default [...configs]; diff --git a/scripts/extract-code-connect/package.json b/scripts/extract-code-connect/package.json new file mode 100644 index 000000000..6e2961f2c --- /dev/null +++ b/scripts/extract-code-connect/package.json @@ -0,0 +1,38 @@ +{ + "name": "@vapor-ui/extract-code-connect", + "version": "0.0.1", + "private": true, + "description": "Generate parserless Figma Code Connect templates (.figma.ts) from a Figma component", + "type": "module", + "bin": { + "extract-code-connect": "./bin/extract-code-connect.mjs" + }, + "files": [ + "bin", + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src bin", + "test": "vitest --run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "prettier": "^3.9.6", + "yargs": "^18.1.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@types/node": "^22.20.1", + "@types/yargs": "^17.0.35", + "eslint": "catalog:", + "tsup": "^8.5.1", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=20.20.2" + } +} diff --git a/scripts/extract-code-connect/src/adapters/mcp.test.ts b/scripts/extract-code-connect/src/adapters/mcp.test.ts new file mode 100644 index 000000000..f37ea2ea5 --- /dev/null +++ b/scripts/extract-code-connect/src/adapters/mcp.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { extract } from '../extract'; +import { fromMcp } from './mcp'; + +const dialogMcp: unknown = JSON.parse( + readFileSync(new URL('../fixtures/dialog.mcp.json', import.meta.url), 'utf8'), +); + +describe('fromMcp', () => { + it('INSTANCE descendants 만 트리로 만들고 이름·타입·옵션을 옮긴다', () => { + const { name, tree } = fromMcp(dialogMcp); + expect(name).toBe('Dialog'); + expect(tree.kind).toBe('ROOT'); + expect(tree.children.map((c) => c.name)).toEqual(['(Popup)']); + + const popup = tree.children[0]; + expect(popup.props).toEqual([{ name: 'size', type: 'VARIANT', variantOptions: ['md'] }]); + expect(popup.children.map((c) => c.name)).toEqual(['(Header)', '(Body)', '(Footer)']); + + const header = popup.children[0]; + expect(header.props.map((p) => p.name)).toEqual([ + 'title', + '(has description)', + 'description', + ]); + expect(header.children.map((c) => c.name)).toEqual(['💙IconButton']); + }); + + it('배열이면 첫 항목을 쓴다', () => { + const { name } = fromMcp([{ name: 'X' }, { name: 'Y' }]); + expect(name).toBe('X'); + }); + + it('name 이 없으면 에러', () => { + expect(() => fromMcp({})).toThrow(/Invalid MCP JSON/); + expect(() => fromMcp(null)).toThrow(/Invalid MCP JSON/); + }); + + it('Dialog 픽스처 end-to-end: extract 결과가 손으로 쓴 템플릿 구조와 같다', () => { + const { tree } = fromMcp(dialogMcp); + const blocks = extract(tree, { warn: () => {} }); + expect(blocks.map((b) => [b.varName, Object.keys(b.entries)])).toEqual([ + ['popup', ['size']], + ['header', ['title', 'description']], + ['body', ['children']], + ['footer', ['assistive', 'action']], + ]); + expect(blocks[0].entries.size).toEqual({ kind: 'enum', name: 'size', options: ['md'] }); + }); + + it('TEXT 자식의 visibility bind 를 해당 컴포넌트 속성의 visibleWhen 으로 옮긴다', () => { + const { tree } = fromMcp(dialogMcp); + const header = tree.children[0].children[0]; + const description = header.props.find((p) => p.name === 'description'); + expect(description?.visibleWhen).toBe('(has description)'); + const title = header.props.find((p) => p.name === 'title'); + expect(title?.visibleWhen).toBeUndefined(); + }); + + it('INSTANCE 자식의 visibility bind 를 자식 TreeNode 의 visibleWhen 으로 옮긴다', () => { + const { tree } = fromMcp(dialogMcp); + const footer = tree.children[0].children[2]; + const assistive = footer.children.find((c) => c.name === 'Assistive'); + expect(assistive?.visibleWhen).toBe('(has assistive)'); + const action = footer.children.find((c) => c.name === 'Action'); + expect(action?.visibleWhen).toBeUndefined(); + }); + + it('extract 를 통과해도 visibleWhen 이 Spec 에 유지된다', () => { + const { tree } = fromMcp(dialogMcp); + const blocks = extract(tree, { warn: () => {} }); + expect(blocks[1].entries.description).toEqual({ + kind: 'string', + name: 'description', + visibleWhen: '(has description)', + }); + expect(blocks[3].entries.assistive).toEqual({ + kind: 'instance', + name: 'Assistive', + visibleWhen: '(has assistive)', + }); + expect(blocks[3].entries.action).toEqual({ kind: 'instance', name: 'Action' }); + }); +}); diff --git a/scripts/extract-code-connect/src/adapters/mcp.ts b/scripts/extract-code-connect/src/adapters/mcp.ts new file mode 100644 index 000000000..292f0210d --- /dev/null +++ b/scripts/extract-code-connect/src/adapters/mcp.ts @@ -0,0 +1,98 @@ +import type { ComponentTree, Prop, PropType, TreeNode } from '../model'; +import { stripPropId } from '../naming'; + +/** `get_context_for_code_connect` 응답에서 쓰는 필드만. */ +interface McpProperty { + name: string; + type: PropType; + variantOptions?: string[]; +} + +interface McpReferences { + /** 이 레이어의 표시 텍스트가 bind 된 컴포넌트 속성 키 (예: `description#2328:2`). */ + characters?: string; + /** 이 레이어의 표시 여부가 bind 된 BOOLEAN 컴포넌트 속성 키 (예: `(has description)#2328:1`). */ + visible?: string; +} + +interface McpNode { + name: string; + type?: string; + properties?: Record; + descendants?: Record; + references?: McpReferences; +} + +/** + * Figma MCP `get_context_for_code_connect` 결과 → ComponentTree. + * 배열이면 첫 항목을 쓴다. + */ +export function fromMcp(json: unknown): ComponentTree { + const root = Array.isArray(json) ? json[0] : json; + if (!isMcpNode(root)) { + throw new Error('Invalid MCP JSON: expected object with `name` and `descendants`'); + } + return { + name: root.name, + tree: buildNode(root, 'ROOT'), + }; +} + +function isMcpNode(v: unknown): v is McpNode { + return ( + typeof v === 'object' && v !== null && typeof (v as { name?: unknown }).name === 'string' + ); +} + +function buildNode(node: McpNode, kind: 'ROOT' | 'INSTANCE'): TreeNode { + const visibilityByPropKey = collectTextVisibility(node.descendants ?? {}); + return { + kind, + name: node.name, + props: toProps(node.properties, visibilityByPropKey), + children: toChildren(node.descendants), + }; +} + +/** + * 컴포넌트 자기 자신의 컴포넌트 속성 키(`description#2328:2` 등) → 이 속성을 참조하는 + * TEXT 레이어의 visibility bind (`(has description)#2328:1`) 매핑. + * INSTANCE descendant 서브트리는 자기 스코프이므로 걸어도 outer prop key 와 + * id 가 다르지만, 안전하게 스킵한다. + */ +function collectTextVisibility(descendants: Record): Map { + const map = new Map(); + const walk = (n: McpNode): void => { + if (n.type === 'INSTANCE') return; + const ref = n.references; + if (n.type === 'TEXT' && ref?.characters && ref.visible) { + map.set(ref.characters, ref.visible); + } + for (const child of Object.values(n.descendants ?? {})) walk(child); + }; + for (const d of Object.values(descendants)) walk(d); + return map; +} + +function toProps( + properties: Record = {}, + visibility: Map = new Map(), +): Prop[] { + return Object.entries(properties).map(([key, p]) => { + const prop: Prop = { name: p.name, type: p.type }; + if (p.type === 'VARIANT') prop.variantOptions = p.variantOptions ?? []; + const visRef = visibility.get(key); + if (visRef) prop.visibleWhen = stripPropId(visRef); + return prop; + }); +} + +function toChildren(descendants: Record = {}): TreeNode[] { + return Object.values(descendants) + .filter((d) => d.type === 'INSTANCE') + .map((d) => { + const child = buildNode(d, 'INSTANCE'); + if (d.references?.visible) child.visibleWhen = stripPropId(d.references.visible); + return child; + }); +} diff --git a/scripts/extract-code-connect/src/adapters/rest.test.ts b/scripts/extract-code-connect/src/adapters/rest.test.ts new file mode 100644 index 000000000..3aa5bf38e --- /dev/null +++ b/scripts/extract-code-connect/src/adapters/rest.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; + +import { collectComponentSetIds, fromRest } from './rest'; +import type { RestComponentSetDoc, RestNodesResponse } from './rest'; + +const nodeId = '1:1'; + +const restJson: RestNodesResponse = { + nodes: { + [nodeId]: { + document: { + id: nodeId, + type: 'COMPONENT', + name: 'Dialog', + children: [ + { + id: '1:2', + type: 'INSTANCE', + name: '(Popup)', + componentId: 'c-popup-md', + componentProperties: { + size: { type: 'VARIANT', value: 'md' }, + }, + children: [ + { + id: '1:3', + type: 'INSTANCE', + name: '(Header)', + componentId: 'c-header', + componentProperties: { + 'title#2328:0': { type: 'TEXT', value: 'Title' }, + '(has description)#2328:1': { type: 'BOOLEAN', value: true }, + }, + children: [{ id: '1:4', type: 'TEXT', name: 'Title' }], + }, + { + id: '1:5', + type: 'INSTANCE', + name: 'Action', + componentId: 'c-action', + componentProperties: {}, + children: [], + }, + ], + }, + ], + }, + components: { + 'c-popup-md': { componentSetId: 'set-popup' }, + 'c-header': {}, + 'c-action': {}, + }, + }, + }, +}; + +const setDocs: Record = { + 'set-popup': { + componentPropertyDefinitions: { + size: { type: 'VARIANT', variantOptions: ['md', 'lg', 'xl'] }, + }, + }, +}; + +describe('collectComponentSetIds', () => { + it('INSTANCE 의 componentSetId 를 중복 없이 모은다', () => { + expect(collectComponentSetIds(restJson, nodeId)).toEqual(['set-popup']); + }); + + it('노드가 응답에 없으면 에러', () => { + expect(() => collectComponentSetIds({ nodes: {} }, nodeId)).toThrow(/not found/); + }); +}); + +describe('fromRest', () => { + it('#id 접미 제거, VARIANT 옵션은 component set 정의에서 가져온다', () => { + const { name, tree } = fromRest(restJson, nodeId, setDocs); + expect(name).toBe('Dialog'); + const popup = tree.children[0]; + expect(popup.props).toEqual([ + { name: 'size', type: 'VARIANT', variantOptions: ['md', 'lg', 'xl'] }, + ]); + const header = popup.children[0]; + expect(header.props).toEqual([ + { name: 'title', type: 'TEXT' }, + { name: '(has description)', type: 'BOOLEAN' }, + ]); + expect(header.children).toEqual([]); + expect(popup.children[1].name).toBe('Action'); + }); + + it('set 정의가 없으면 인스턴스의 현재 값만 옵션으로 쓴다', () => { + const { tree } = fromRest(restJson, nodeId, {}); + expect(tree.children[0].props[0].variantOptions).toEqual(['md']); + }); + + it('TEXT 자식의 componentPropertyReferences.visible 을 대응 prop 의 visibleWhen 으로 옮긴다', () => { + const json: RestNodesResponse = { + nodes: { + [nodeId]: { + document: { + id: nodeId, + type: 'COMPONENT', + name: 'Header', + children: [ + { + id: '2:1', + type: 'INSTANCE', + name: '(Header)', + componentProperties: { + 'description#2328:2': { type: 'TEXT', value: 'x' }, + 'title#2328:0': { type: 'TEXT', value: 'y' }, + }, + children: [ + { + id: '2:2', + type: 'TEXT', + name: 'Description', + componentPropertyReferences: { + characters: 'description#2328:2', + visible: '(has description)#2328:1', + }, + }, + { + id: '2:3', + type: 'TEXT', + name: 'Title', + componentPropertyReferences: { + characters: 'title#2328:0', + }, + }, + ], + }, + ], + }, + }, + }, + }; + const { tree } = fromRest(json, nodeId, {}); + const header = tree.children[0]; + expect(header.props).toEqual([ + { + name: 'description', + type: 'TEXT', + visibleWhen: '(has description)', + }, + { name: 'title', type: 'TEXT' }, + ]); + }); + + it('INSTANCE 자식의 componentPropertyReferences.visible 을 TreeNode.visibleWhen 으로 옮긴다', () => { + const json: RestNodesResponse = { + nodes: { + [nodeId]: { + document: { + id: nodeId, + type: 'COMPONENT', + name: 'Footer', + children: [ + { + id: '3:1', + type: 'INSTANCE', + name: '(Footer)', + componentProperties: {}, + children: [ + { + id: '3:2', + type: 'INSTANCE', + name: 'Assistive', + componentPropertyReferences: { + visible: '(has assistive)#2122:24', + }, + children: [], + }, + { + id: '3:3', + type: 'INSTANCE', + name: 'Action', + children: [], + }, + ], + }, + ], + }, + }, + }, + }; + const { tree } = fromRest(json, nodeId, {}); + const footer = tree.children[0]; + expect(footer.children.map((c) => [c.name, c.visibleWhen])).toEqual([ + ['Assistive', '(has assistive)'], + ['Action', undefined], + ]); + }); +}); diff --git a/scripts/extract-code-connect/src/adapters/rest.ts b/scripts/extract-code-connect/src/adapters/rest.ts new file mode 100644 index 000000000..fcc2ce8d7 --- /dev/null +++ b/scripts/extract-code-connect/src/adapters/rest.ts @@ -0,0 +1,149 @@ +import type { ComponentTree, Prop, PropType, TreeNode } from '../model'; +import { stripPropId } from '../naming'; + +/** Figma REST `/v1/files/:key/nodes` 응답에서 쓰는 필드만. */ +export interface RestComponentProperty { + type: PropType; + value?: unknown; +} + +export interface RestComponentSetDoc { + componentPropertyDefinitions?: Record; +} + +/** COMPONENT_SET document 는 componentPropertyDefinitions 를 함께 가진다. */ +export interface RestNode extends RestComponentSetDoc { + id: string; + type: string; + name: string; + componentId?: string; + componentProperties?: Record; + /** + * 이 레이어의 특정 속성이 소속 컴포넌트 property 에 bind 된 관계. + * 대표 키: `characters`, `visible`. 값은 대상 컴포넌트 property 의 raw key (`#id` 포함). + */ + componentPropertyReferences?: Record; + children?: RestNode[]; +} + +export interface RestNodeEntry { + document?: RestNode; + components?: Record; +} + +export interface RestNodesResponse { + nodes?: Record; +} + +/** 응답에서 INSTANCE 가 참조하는 component set id 를 모은다. */ +export function collectComponentSetIds(nodesJson: RestNodesResponse, nodeId: string): string[] { + const entry = getEntry(nodesJson, nodeId); + const ids = new Set(); + walk(entry.document, (n) => { + const setId = n.componentId ? entry.components?.[n.componentId]?.componentSetId : undefined; + if (setId) ids.add(setId); + }); + return [...ids]; +} + +/** + * Figma REST nodes 응답 → ComponentTree. + * `setDocs[setId]` 는 `nodes?ids=&depth=1` 응답의 `nodes[setId].document`. + */ +export function fromRest( + nodesJson: RestNodesResponse, + nodeId: string, + setDocs: Record, +): ComponentTree { + const entry = getEntry(nodesJson, nodeId); + const doc = entry.document; + + const toNode = (n: RestNode): TreeNode => { + const visibility = collectTextVisibility(n); + const node: TreeNode = { + kind: 'INSTANCE', + name: n.name, + props: toProps(n, entry.components, setDocs, visibility), + children: instanceChildren(n).map((child) => { + const built = toNode(child); + const visRef = child.componentPropertyReferences?.visible; + if (visRef) built.visibleWhen = stripPropId(visRef); + return built; + }), + }; + return node; + }; + + return { + name: doc.name, + tree: { + kind: 'ROOT', + name: doc.name, + props: [], + children: instanceChildren(doc).map(toNode), + }, + }; +} + +function getEntry( + nodesJson: RestNodesResponse, + nodeId: string, +): RestNodeEntry & { document: RestNode } { + const entry = nodesJson.nodes?.[nodeId]; + if (!entry?.document) throw new Error(`Node '${nodeId}' not found in REST response`); + return { ...entry, document: entry.document }; +} + +function instanceChildren(n: RestNode): RestNode[] { + return (n.children ?? []).filter((c) => c.type === 'INSTANCE'); +} + +function walk(node: RestNode, visit: (n: RestNode) => void): void { + if (node.type === 'INSTANCE') visit(node); + for (const c of node.children ?? []) walk(c, visit); +} + +/** + * 이 INSTANCE 안 TEXT 레이어들의 `componentPropertyReferences` 를 걸어 + * (characters key → visible key) 매핑을 만든다. 중첩 INSTANCE 서브트리는 스코프가 다르므로 스킵. + */ +function collectTextVisibility(instance: RestNode): Map { + const map = new Map(); + const walkChildren = (n: RestNode): void => { + for (const child of n.children ?? []) { + if (child.type === 'INSTANCE') continue; + if (child.type === 'TEXT') { + const chars = child.componentPropertyReferences?.characters; + const vis = child.componentPropertyReferences?.visible; + if (chars && vis) map.set(chars, vis); + } + walkChildren(child); + } + }; + walkChildren(instance); + return map; +} + +function toProps( + instance: RestNode, + components: RestNodeEntry['components'], + setDocs: Record, + visibility: Map, +): Prop[] { + const setId = instance.componentId + ? components?.[instance.componentId]?.componentSetId + : undefined; + const defs = (setId && setDocs[setId]?.componentPropertyDefinitions) || {}; + + return Object.entries(instance.componentProperties ?? {}).map(([rawName, p]) => { + const name = stripPropId(rawName); + const prop: Prop = { name, type: p.type }; + if (p.type === 'VARIANT') { + prop.variantOptions = defs[rawName]?.variantOptions ?? + defs[name]?.variantOptions ?? [String(p.value)]; + } + const visRef = visibility.get(rawName); + if (visRef) prop.visibleWhen = stripPropId(visRef); + return prop; + }); +} diff --git a/scripts/extract-code-connect/src/cli/cmdGenerate.ts b/scripts/extract-code-connect/src/cli/cmdGenerate.ts new file mode 100644 index 000000000..cdfbcf734 --- /dev/null +++ b/scripts/extract-code-connect/src/cli/cmdGenerate.ts @@ -0,0 +1,60 @@ +import type { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; + +import { generate } from '../generate'; +import { resolveContext } from './context'; + +function builder(y: Argv) { + return y + .positional('figma-url', { + type: 'string', + demandOption: true, + describe: 'Figma design URL with node-id (component or component set)', + }) + .options({ + 'from-json': { + type: 'string', + describe: 'Use a saved get_context_for_code_connect JSON instead of the REST API', + }, + out: { + type: 'string', + describe: 'Output path (default: src/components//.figma.ts)', + }, + utils: { + type: 'string', + default: 'src/utils/figma-utils', + describe: 'Module that exports getProperties', + }, + force: { + type: 'boolean', + default: false, + describe: 'Overwrite an existing output file', + }, + }) + .epilogue( + [ + 'Paths are relative to the current working directory (the consuming package root).', + 'Env: FIGMA_TOKEN (required unless --from-json). Read from /.env when present.', + ].join('\n'), + ); +} + +type GenerateArgs = ReturnType extends Argv ? T : never; + +const cmdGenerate: CommandModule = { + command: '$0 ', + describe: 'Generate a parserless Code Connect template (.figma.ts) from a Figma component', + builder, + handler: (argv: ArgumentsCamelCase) => + generate( + { + url: argv.figmaUrl, + fromJson: argv.fromJson, + out: argv.out, + utils: argv.utils, + force: argv.force, + }, + resolveContext(), + ), +}; + +export default cmdGenerate; diff --git a/scripts/extract-code-connect/src/cli/context.ts b/scripts/extract-code-connect/src/cli/context.ts new file mode 100644 index 000000000..e2890857a --- /dev/null +++ b/scripts/extract-code-connect/src/cli/context.ts @@ -0,0 +1,20 @@ +/** 주입 가능한 실행 환경. 테스트는 임시 cwd 와 spy 로거를 넘긴다. */ +export interface CliIo { + cwd?: string; + env?: NodeJS.ProcessEnv; + log?: (msg: string) => void; + warn?: (msg: string) => void; + error?: (msg: string) => void; +} + +export type CliContext = Required; + +export function resolveContext(io: CliIo = {}): CliContext { + return { + cwd: io.cwd ?? process.cwd(), + env: io.env ?? process.env, + log: io.log ?? console.log, + warn: io.warn ?? console.warn, + error: io.error ?? console.error, + }; +} diff --git a/scripts/extract-code-connect/src/cli/index.ts b/scripts/extract-code-connect/src/cli/index.ts new file mode 100644 index 000000000..6df4f34ad --- /dev/null +++ b/scripts/extract-code-connect/src/cli/index.ts @@ -0,0 +1,25 @@ +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import pkg from '../../package.json'; +import cmdGenerate from './cmdGenerate'; + +await yargs(hideBin(process.argv)) + .scriptName('extract-code-connect') + .usage('$0 [options]') + .command(cmdGenerate) + .strict() + .help() + .version(pkg.version) + .showHelpOnFail(false) + .exitProcess(false) + .fail((msg, err, instance) => { + if (msg) { + instance.showHelp(); + console.error(`\n${msg}`); + } else if (err) { + console.error(err.message); + } + process.exit(1); + }) + .parseAsync(); diff --git a/scripts/extract-code-connect/src/extract.test.ts b/scripts/extract-code-connect/src/extract.test.ts new file mode 100644 index 000000000..ec0874333 --- /dev/null +++ b/scripts/extract-code-connect/src/extract.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { extract } from './extract'; +import type { Prop, TreeNode } from './model'; + +const node = (name: string, props: Prop[] = [], children: TreeNode[] = []): TreeNode => ({ + kind: 'INSTANCE', + name, + props, + children, +}); +const root = (children: TreeNode[]): TreeNode => ({ + kind: 'ROOT', + name: 'Root', + props: [], + children, +}); + +describe('extract', () => { + it('괄호 인스턴스는 블록이 되고 속성은 kind 로 매핑된다', () => { + const tree = root([ + node( + '(Popup)', + [{ name: 'size', type: 'VARIANT', variantOptions: ['md', 'lg', 'xl'] }], + [ + node('(Header)', [ + { name: 'title', type: 'TEXT' }, + { name: 'description', type: 'TEXT' }, + { name: 'compact', type: 'BOOLEAN' }, + ]), + ], + ), + ]); + + expect(extract(tree)).toEqual([ + { + varName: 'popup', + instanceName: '(Popup)', + entries: { size: { kind: 'enum', name: 'size', options: ['md', 'lg', 'xl'] } }, + todos: [], + }, + { + varName: 'header', + instanceName: '(Header)', + entries: { + title: { kind: 'string', name: 'title' }, + description: { kind: 'string', name: 'description' }, + compact: { kind: 'boolean', name: 'compact' }, + }, + todos: [], + }, + ]); + }); + + it('괄호 속성은 제외하지만 SLOT 은 항상 children 이다', () => { + const tree = root([ + node('(Body)', [ + { name: '(content)', type: 'SLOT' }, + { name: '(scroll)', type: 'VARIANT', variantOptions: ['false'] }, + { name: '(has x)', type: 'BOOLEAN' }, + { name: '(label)', type: 'TEXT' }, + ]), + ]); + + expect(extract(tree)[0].entries).toEqual({ + children: { kind: 'slot', name: '(content)' }, + }); + }); + + it('괄호 없는 SLOT 도 children 으로 매핑된다', () => { + const tree = root([node('(Body)', [{ name: 'content', type: 'SLOT' }])]); + expect(extract(tree)[0].entries).toEqual({ children: { kind: 'slot', name: 'content' } }); + }); + + it('비괄호 인스턴스는 조상 블록에 instance 로 붙고 하위는 탐색하지 않는다', () => { + const tree = root([ + node( + '(Footer)', + [], + [ + node( + 'Assistive', + [{ name: 'assistive', type: 'VARIANT', variantOptions: ['button'] }], + [node('(Inner)', [{ name: 'x', type: 'TEXT' }])], + ), + node('Action'), + ], + ), + ]); + + const blocks = extract(tree); + expect(blocks).toHaveLength(1); + expect(blocks[0].entries).toEqual({ + assistive: { kind: 'instance', name: 'Assistive' }, + action: { kind: 'instance', name: 'Action' }, + }); + }); + + it('이모지 접두 인스턴스는 하위까지 스킵한다', () => { + const tree = root([ + node( + '(Header)', + [{ name: 'title', type: 'TEXT' }], + [ + node( + '💙IconButton', + [{ name: 'size', type: 'VARIANT', variantOptions: ['xl'] }], + [node('(Deep)', [{ name: 'y', type: 'TEXT' }])], + ), + ], + ), + ]); + + const blocks = extract(tree); + expect(blocks).toHaveLength(1); + expect(Object.keys(blocks[0].entries)).toEqual(['title']); + }); + + it('루트 직속 비괄호 인스턴스는 경고 후 스킵한다', () => { + const warn = vi.fn(); + const tree = root([ + node('Orphan'), + node('(Popup)', [{ name: 'size', type: 'VARIANT', variantOptions: ['md'] }]), + ]); + + const blocks = extract(tree, { warn }); + expect(blocks.map((b) => b.varName)).toEqual(['popup']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Orphan')); + }); + + it('INSTANCE_SWAP 은 todos 로 남긴다', () => { + const tree = root([node('(Slot)', [{ name: 'Icon', type: 'INSTANCE_SWAP' }])]); + const [block] = extract(tree); + expect(block.entries).toEqual({}); + expect(block.todos).toEqual(['Icon']); + }); + + it('블록 변수명 중복은 에러', () => { + const tree = root([node('(Popup)'), node('(popup)')]); + expect(() => extract(tree)).toThrow(/duplicate block/i); + }); + + it('같은 블록 안 key 중복은 에러', () => { + const tree = root([node('(Footer)', [{ name: 'action', type: 'TEXT' }], [node('Action')])]); + expect(() => extract(tree)).toThrow(/duplicate key/i); + }); + + it('VARIANT 옵션이 비어 있으면 에러', () => { + const tree = root([ + node('(Popup)', [{ name: 'size', type: 'VARIANT', variantOptions: [] }]), + ]); + expect(() => extract(tree)).toThrow(/no variant options/i); + }); + + it('Prop.visibleWhen 은 Spec.visibleWhen 으로 전파된다', () => { + const tree = root([ + node('(Header)', [ + { name: 'title', type: 'TEXT' }, + { + name: 'description', + type: 'TEXT', + visibleWhen: '(has description)', + }, + ]), + ]); + const [block] = extract(tree); + expect(block.entries.description).toEqual({ + kind: 'string', + name: 'description', + visibleWhen: '(has description)', + }); + expect(block.entries.title).toEqual({ kind: 'string', name: 'title' }); + }); + + it('TreeNode.visibleWhen 은 instance Spec.visibleWhen 으로 전파된다', () => { + const tree = root([ + node( + '(Footer)', + [], + [ + { + kind: 'INSTANCE', + name: 'Assistive', + props: [], + children: [], + visibleWhen: '(has assistive)', + }, + node('Action'), + ], + ), + ]); + const [block] = extract(tree); + expect(block.entries).toEqual({ + assistive: { + kind: 'instance', + name: 'Assistive', + visibleWhen: '(has assistive)', + }, + action: { kind: 'instance', name: 'Action' }, + }); + }); +}); diff --git a/scripts/extract-code-connect/src/extract.ts b/scripts/extract-code-connect/src/extract.ts new file mode 100644 index 000000000..d47c38ad3 --- /dev/null +++ b/scripts/extract-code-connect/src/extract.ts @@ -0,0 +1,106 @@ +import type { Block, Prop, Spec, TreeNode } from './model'; +import { isNamedInstance, isParen, lowerFirst, stripParens } from './naming'; + +function withGate(spec: Spec, prop: Prop): Spec { + return prop.visibleWhen ? { ...spec, visibleWhen: prop.visibleWhen } : spec; +} + +export interface ExtractOptions { + warn?: (msg: string) => void; +} + +/** + * ComponentTree → Block[]. + * + * 규칙: + * 1. 이름이 영문자/'(' 로 시작하지 않는 인스턴스는 하위까지 스킵. + * 2. `(Name)` 인스턴스 → 블록. 괄호 속성 제외, SLOT 은 항상 `children`. + * 3. 비괄호 인스턴스 → 가장 가까운 괄호 조상 블록에 `kind: 'instance'`. 하위 탐색 중단. + * 4. 루트 직속 비괄호 인스턴스 → warn 후 스킵. + */ +export function extract( + root: TreeNode, + { warn = (msg) => console.warn(msg) }: ExtractOptions = {}, +): Block[] { + const blocks: Block[] = []; + const varNames = new Set(); + + const visit = (node: TreeNode, parentBlock: Block | null): void => { + for (const child of node.children) { + if (!isNamedInstance(child.name)) continue; + + if (isParen(child.name)) { + const block = toBlock(child); + if (varNames.has(block.varName)) { + throw new Error(`Duplicate block name '${block.varName}' from '${child.name}'`); + } + varNames.add(block.varName); + blocks.push(block); + visit(child, block); + continue; + } + + if (!parentBlock) { + warn( + `Skip root-level instance '${child.name}': no parenthesized parent to attach to`, + ); + continue; + } + const spec: Spec = { kind: 'instance', name: child.name }; + if (child.visibleWhen) spec.visibleWhen = child.visibleWhen; + addEntry(parentBlock, lowerFirst(child.name), spec); + } + }; + + visit(root, null); + return blocks; +} + +function toBlock(node: TreeNode): Block { + const block: Block = { + varName: lowerFirst(stripParens(node.name)), + instanceName: node.name, + entries: {}, + todos: [], + }; + + for (const prop of node.props) { + if (prop.type === 'SLOT') { + addEntry(block, 'children', withGate({ kind: 'slot', name: prop.name }, prop)); + continue; + } + if (isParen(prop.name)) continue; + + switch (prop.type) { + case 'TEXT': + addEntry(block, prop.name, withGate({ kind: 'string', name: prop.name }, prop)); + break; + case 'BOOLEAN': + addEntry(block, prop.name, withGate({ kind: 'boolean', name: prop.name }, prop)); + break; + case 'VARIANT': { + const options = prop.variantOptions ?? []; + if (options.length === 0) { + throw new Error(`No variant options for '${prop.name}' in '${node.name}'`); + } + addEntry( + block, + prop.name, + withGate({ kind: 'enum', name: prop.name, options }, prop), + ); + break; + } + case 'INSTANCE_SWAP': + block.todos.push(prop.name); + break; + } + } + return block; +} + +function addEntry(block: Block, key: string, spec: Spec): void { + if (key in block.entries) { + throw new Error(`Duplicate key '${key}' in block '${block.instanceName}'`); + } + block.entries[key] = spec; +} diff --git a/scripts/extract-code-connect/src/figma-url.test.ts b/scripts/extract-code-connect/src/figma-url.test.ts new file mode 100644 index 000000000..6b4bed2a9 --- /dev/null +++ b/scripts/extract-code-connect/src/figma-url.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { parseFigmaUrl } from './figma-url'; + +describe('parseFigmaUrl', () => { + it('design URL 에서 fileKey 와 node-id 를 추출하고 하이픈을 콜론으로 바꾼다', () => { + expect( + parseFigmaUrl( + 'https://www.figma.com/design/he4tiAGOKGPl0Fm56ZpJsy/-Composites?node-id=2337-38499&m=dev', + ), + ).toEqual({ fileKey: 'he4tiAGOKGPl0Fm56ZpJsy', nodeId: '2337:38499' }); + }); + + it('branch URL 은 branchKey 를 fileKey 로 쓴다', () => { + expect( + parseFigmaUrl('https://www.figma.com/design/AAAA/branch/BBBB/Name?node-id=1-2'), + ).toEqual({ fileKey: 'BBBB', nodeId: '1:2' }); + }); + + it('node-id 가 없으면 에러', () => { + expect(() => parseFigmaUrl('https://www.figma.com/design/AAAA/Name')).toThrow(/node-id/); + }); + + it('figma 파일 URL 이 아니면 에러', () => { + expect(() => parseFigmaUrl('https://example.com/x?node-id=1-2')).toThrow(/Not a Figma/); + }); +}); diff --git a/scripts/extract-code-connect/src/figma-url.ts b/scripts/extract-code-connect/src/figma-url.ts new file mode 100644 index 000000000..a060a59b9 --- /dev/null +++ b/scripts/extract-code-connect/src/figma-url.ts @@ -0,0 +1,20 @@ +export interface FigmaRef { + fileKey: string; + nodeId: string; +} + +export function parseFigmaUrl(url: string): FigmaRef { + const u = new URL(url); + // ['design', fileKey, ('branch', branchKey,) name] + const parts = u.pathname.split('/').filter(Boolean); + const idx = parts.findIndex((p) => p === 'design' || p === 'file'); + if (idx === -1 || !parts[idx + 1]) throw new Error(`Not a Figma file URL: ${url}`); + + let fileKey = parts[idx + 1]; + if (parts[idx + 2] === 'branch' && parts[idx + 3]) fileKey = parts[idx + 3]; + + const rawNode = u.searchParams.get('node-id'); + if (!rawNode) throw new Error('Figma URL must include a node-id query parameter'); + + return { fileKey, nodeId: rawNode.replace('-', ':') }; +} diff --git a/scripts/extract-code-connect/src/fixtures/dialog.mcp.json b/scripts/extract-code-connect/src/fixtures/dialog.mcp.json new file mode 100644 index 000000000..c9f25aba8 --- /dev/null +++ b/scripts/extract-code-connect/src/fixtures/dialog.mcp.json @@ -0,0 +1,205 @@ +[ + { + "fileKey": "he4tiAGOKGPl0Fm56ZpJsy", + "nodeId": "2337:38499", + "name": "Dialog", + "properties": {}, + "descendants": { + "INSTANCE[(Popup)]": { + "type": "INSTANCE", + "name": "(Popup)", + "mainComponentName": "(Popup)", + "references": {}, + "properties": { + "size": { + "name": "size", + "type": "VARIANT", + "key": "size", + "variantOptions": ["md"] + } + }, + "descendants": { + "INSTANCE[(Header)]": { + "type": "INSTANCE", + "name": "(Header)", + "mainComponentName": "(Header)", + "references": {}, + "properties": { + "title#2328:0": { + "name": "title", + "type": "TEXT", + "key": "title#2328:0" + }, + "(has description)#2328:1": { + "name": "(has description)", + "type": "BOOLEAN", + "key": "(has description)#2328:1" + }, + "description#2328:2": { + "name": "description", + "type": "TEXT", + "key": "description#2328:2" + } + }, + "descendants": { + "TEXT[Title]": { + "type": "TEXT", + "name": "Title", + "nodeId": "2328:28293", + "references": { "characters": "title#2328:0" } + }, + "TEXT[Description]": { + "type": "TEXT", + "name": "Description", + "nodeId": "2328:28294", + "references": { + "visible": "(has description)#2328:1", + "characters": "description#2328:2" + } + }, + "INSTANCE[💙IconButton]": { + "type": "INSTANCE", + "name": "💙IconButton", + "mainComponentName": "💙IconButton", + "references": {}, + "properties": { + "size": { + "name": "size", + "type": "VARIANT", + "key": "size", + "variantOptions": ["xl"] + }, + "variant": { + "name": "variant", + "type": "VARIANT", + "key": "variant", + "variantOptions": ["ghost"] + } + }, + "descendants": { + "INSTANCE[🟨IconButton/SlotLayer]": { + "type": "INSTANCE", + "name": "🟨IconButton/SlotLayer", + "mainComponentName": "🟨IconButton/SlotLayer", + "references": {}, + "properties": { + "Icon#31044:0": { + "name": "Icon", + "type": "INSTANCE_SWAP", + "key": "Icon#31044:0" + } + }, + "descendants": {} + } + } + } + } + }, + "INSTANCE[(Body)]": { + "type": "INSTANCE", + "name": "(Body)", + "mainComponentName": "(Body)", + "references": {}, + "properties": { + "(content)#2260:77": { + "name": "(content)", + "type": "SLOT", + "key": "(content)#2260:77" + }, + "(scroll)": { + "name": "(scroll)", + "type": "VARIANT", + "key": "(scroll)", + "variantOptions": ["false"] + } + }, + "descendants": {} + }, + "INSTANCE[(Footer)]": { + "type": "INSTANCE", + "name": "(Footer)", + "mainComponentName": "(Footer)", + "references": {}, + "properties": { + "(has assistive)#2122:24": { + "name": "(has assistive)", + "type": "BOOLEAN", + "key": "(has assistive)#2122:24" + }, + "(has footer)": { + "name": "(has footer)", + "type": "VARIANT", + "key": "(has footer)", + "variantOptions": ["true"] + } + }, + "descendants": { + "INSTANCE[Assistive]": { + "type": "INSTANCE", + "name": "Assistive", + "mainComponentName": "Assistive", + "references": { "visible": "(has assistive)#2122:24" }, + "properties": { + "assistive": { + "name": "assistive", + "type": "VARIANT", + "key": "assistive", + "variantOptions": ["button"] + } + }, + "descendants": { + "INSTANCE[💙Button]": { + "type": "INSTANCE", + "name": "💙Button", + "mainComponentName": "💙Button", + "references": {}, + "properties": { + "size": { + "name": "size", + "type": "VARIANT", + "key": "size", + "variantOptions": ["lg"] + } + }, + "descendants": {} + } + } + }, + "INSTANCE[Action]": { + "type": "INSTANCE", + "name": "Action", + "mainComponentName": "Action", + "references": {}, + "properties": { + "action": { + "name": "action", + "type": "VARIANT", + "key": "action", + "variantOptions": ["button"] + } + }, + "descendants": { + "INSTANCE[💙Button]": { + "type": "INSTANCE", + "name": "💙Button", + "mainComponentName": "💙Button", + "references": {}, + "properties": { + "size": { + "name": "size", + "type": "VARIANT", + "key": "size", + "variantOptions": ["lg"] + } + }, + "descendants": {} + } + } + } + } + } + } + } + } + } +] diff --git a/scripts/extract-code-connect/src/generate.test.ts b/scripts/extract-code-connect/src/generate.test.ts new file mode 100644 index 000000000..a901969a6 --- /dev/null +++ b/scripts/extract-code-connect/src/generate.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { generate, relativeImport, resolvePackageImportPath } from './generate'; +import type { GenerateOptions } from './generate'; + +const fixture = fileURLToPath(new URL('./fixtures/dialog.mcp.json', import.meta.url)); +const url = 'https://www.figma.com/design/he4tiAGOKGPl0Fm56ZpJsy/X?node-id=2337-38499&m=dev'; + +let cwd: string; +const ctx = () => ({ cwd, log: vi.fn(), warn: vi.fn(), error: vi.fn(), env: {} }); +const opts = (o: Partial = {}): GenerateOptions => ({ + url, + fromJson: fixture, + utils: 'src/utils/figma-utils', + force: false, + ...o, +}); + +/** 소비 패키지 루트 흉내: package.json + figma.config.json + dialog/index.parts.ts + .prettierrc */ +function scaffoldConsumer(o: { figmaConfig?: boolean } = { figmaConfig: true }) { + writeFileSync( + path.join(cwd, '.prettierrc'), + JSON.stringify({ singleQuote: true, tabWidth: 4, printWidth: 100 }), + ); + writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ name: '@scope/pkg' })); + if (o.figmaConfig) { + writeFileSync( + path.join(cwd, 'figma.config.json'), + JSON.stringify({ codeConnect: {}, packageImportPath: '@vapor-ui/composites' }), + ); + } + mkdirSync(path.join(cwd, 'src/components/dialog'), { recursive: true }); + writeFileSync(path.join(cwd, 'src/components/dialog/index.parts.ts'), ''); +} + +beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'extract-code-connect-')); +}); +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('relativeImport', () => { + it('출력 파일 기준 상대 경로, 확장자 없음, ./ 또는 ../ 접두', () => { + expect( + relativeImport('/p/src/components/dialog/dialog.figma.ts', '/p/src/utils/figma-utils'), + ).toBe('../../utils/figma-utils'); + expect(relativeImport('/p/src/a.figma.ts', '/p/src/figma-utils')).toBe('./figma-utils'); + expect(relativeImport('/p/src/a.figma.ts', '/p/src/utils/figma-utils.ts')).toBe( + './utils/figma-utils', + ); + }); +}); + +describe('resolvePackageImportPath', () => { + it('figma.config.json 의 packageImportPath 를 우선한다', () => { + scaffoldConsumer(); + expect(resolvePackageImportPath(cwd)).toBe('@vapor-ui/composites'); + }); + + it('figma.config.json 이 없으면 package.json name', () => { + scaffoldConsumer({ figmaConfig: false }); + expect(resolvePackageImportPath(cwd)).toBe('@scope/pkg'); + }); + + it('둘 다 없으면 에러', () => { + expect(() => resolvePackageImportPath(cwd)).toThrow(/package name/i); + }); +}); + +describe('generate', () => { + it('--from-json 으로 기본 경로에 파일을 만든다', async () => { + scaffoldConsumer(); + const c = ctx(); + await generate(opts(), c); + + const out = path.join(cwd, 'src/components/dialog/dialog.figma.ts'); + expect(existsSync(out)).toBe(true); + const text = readFileSync(out, 'utf8'); + expect(text).toContain("import { getProperties } from '../../utils/figma-utils';"); + expect(text).toContain("getProperties(instance, '(Popup)'"); + expect(text).toContain(' { + scaffoldConsumer(); + await generate(opts(), ctx()); + const text = readFileSync(path.join(cwd, 'src/components/dialog/dialog.figma.ts'), 'utf8'); + expect(text).toContain( + "description: { kind: 'string', name: 'description', visibleWhen: '(has description)' }", + ); + expect(text).toContain( + "assistive: { kind: 'instance', name: 'Assistive', visibleWhen: '(has assistive)' }", + ); + expect(text).toContain("action: { kind: 'instance', name: 'Action' }"); + }); + + it('--out 깊이가 다르면 utils 상대 경로가 바뀌고, 디렉터리는 자동 생성된다', async () => { + scaffoldConsumer(); + await generate(opts({ out: 'src/deep/er/x.figma.ts' }), ctx()); + const text = readFileSync(path.join(cwd, 'src/deep/er/x.figma.ts'), 'utf8'); + expect(text).toContain("from '../../utils/figma-utils';"); + }); + + it('--utils 로 유틸 위치를 바꿀 수 있다', async () => { + scaffoldConsumer(); + await generate(opts({ utils: 'src/figma/helpers' }), ctx()); + const text = readFileSync(path.join(cwd, 'src/components/dialog/dialog.figma.ts'), 'utf8'); + expect(text).toContain("from '../../figma/helpers';"); + }); + + it('--out 이 cwd 밖이면 utils import 는 기본 출력 위치 기준이고 Prettier 설정은 cwd 에서 읽는다', async () => { + scaffoldConsumer(); + const outside = mkdtempSync(path.join(os.tmpdir(), 'extract-code-connect-out-')); + try { + const c = ctx(); + const out = path.join(outside, 'x.figma.ts'); + await generate(opts({ out }), c); + const text = readFileSync(out, 'utf8'); + expect(text).toContain("import { getProperties } from '../../utils/figma-utils';"); + expect(c.warn).toHaveBeenCalledWith(expect.stringContaining('outside')); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('figma.config.json 이 없으면 package.json name 을 imports 에 쓴다', async () => { + scaffoldConsumer({ figmaConfig: false }); + await generate(opts(), ctx()); + const text = readFileSync(path.join(cwd, 'src/components/dialog/dialog.figma.ts'), 'utf8'); + expect(text).toContain('import { Dialog } from "@scope/pkg"'); + }); + + it('기존 파일은 --force 없이 덮어쓰지 않는다', async () => { + scaffoldConsumer(); + const out = path.join(cwd, 'src/components/dialog/dialog.figma.ts'); + writeFileSync(out, 'KEEP'); + + await expect(generate(opts(), ctx())).rejects.toThrow(/--force/); + expect(readFileSync(out, 'utf8')).toBe('KEEP'); + + await generate(opts({ force: true }), ctx()); + expect(readFileSync(out, 'utf8')).not.toBe('KEEP'); + }); + + it('REST 경로인데 FIGMA_TOKEN 이 없으면 에러', async () => { + scaffoldConsumer(); + await expect(generate(opts({ fromJson: undefined }), ctx())).rejects.toThrow(/FIGMA_TOKEN/); + }); + + it('.env 의 FIGMA_TOKEN 을 읽는다 (fetch 는 실패하도록 스텁)', async () => { + scaffoldConsumer(); + writeFileSync(path.join(cwd, '.env'), 'FIGMA_TOKEN=test-token\n'); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('nope', { status: 403, statusText: 'Forbidden' })); + + await expect(generate(opts({ fromJson: undefined }), ctx())).rejects.toThrow(/403/); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/v1/files/he4tiAGOKGPl0Fm56ZpJsy/nodes?ids=2337%3A38499'), + expect.objectContaining({ headers: { 'X-FIGMA-TOKEN': 'test-token' } }), + ); + fetchSpy.mockRestore(); + }); +}); diff --git a/scripts/extract-code-connect/src/generate.ts b/scripts/extract-code-connect/src/generate.ts new file mode 100644 index 000000000..f82dfdb01 --- /dev/null +++ b/scripts/extract-code-connect/src/generate.ts @@ -0,0 +1,184 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import prettier from 'prettier'; + +import { fromMcp } from './adapters/mcp'; +import { collectComponentSetIds, fromRest } from './adapters/rest'; +import type { RestComponentSetDoc, RestNodesResponse } from './adapters/rest'; +import type { CliContext } from './cli/context'; +import { extract } from './extract'; +import { parseFigmaUrl } from './figma-url'; +import type { ComponentTree } from './model'; +import { toKebab, toPascal } from './naming'; +import { render } from './render'; + +export interface GenerateOptions { + /** node-id 를 포함한 Figma 디자인 URL */ + url: string; + /** REST 대신 저장된 get_context_for_code_connect JSON 경로 (cwd 기준) */ + fromJson?: string; + /** 출력 경로 (cwd 기준). 기본 `src/components//.figma.ts` */ + out?: string; + /** getProperties 를 export 하는 모듈 (cwd 기준) */ + utils: string; + /** 기존 파일 덮어쓰기 허용 */ + force: boolean; +} + +/** Figma 컴포넌트 → `.figma.ts` 파일 생성. 사용자 오류는 Error throw (호출자가 exit code 로 변환). */ +export async function generate(opts: GenerateOptions, ctx: CliContext): Promise { + const { cwd, env, log, warn } = ctx; + const { fileKey, nodeId } = parseFigmaUrl(opts.url); + + const { name, tree } = opts.fromJson + ? fromMcp(readJson(path.resolve(cwd, opts.fromJson))) + : await loadFromRest(fileKey, nodeId, loadToken(cwd, env)); + + const componentName = toPascal(name); + const kebab = toKebab(name); + const componentDir = path.join(cwd, 'src/components', kebab); + const defaultOutPath = path.join(componentDir, `${kebab}.figma.ts`); + const outPath = opts.out ? path.resolve(cwd, opts.out) : defaultOutPath; + + if (existsSync(outPath) && !opts.force) { + throw new Error(`Refusing to overwrite ${path.relative(cwd, outPath)} (use --force)`); + } + + const blocks = extract(tree, { warn: (m) => warn(`warn: ${m}`) }); + if (blocks.length === 0) { + throw new Error('No parenthesized instances found; nothing to generate'); + } + + // --out 이 소비 패키지 밖이면 utils import 는 기본 출력 위치 기준으로 계산한다. + const importAnchor = isInside(cwd, outPath) ? outPath : defaultOutPath; + if (importAnchor !== outPath) { + warn( + `warn: --out is outside ${cwd}; utils import computed as if written to ${path.relative(cwd, defaultOutPath)}`, + ); + } + + const raw = render({ + blocks, + url: opts.url, + componentName, + kebab, + hasParts: existsSync(path.join(componentDir, 'index.parts.ts')), + utilsImport: relativeImport(importAnchor, path.resolve(cwd, opts.utils)), + packageImportPath: resolvePackageImportPath(cwd), + }); + + const formatted = await format(raw, outPath, cwd, warn); + + mkdirSync(path.dirname(outPath), { recursive: true }); + writeFileSync(outPath, formatted, 'utf8'); + log(`Wrote ${path.relative(cwd, outPath)} (${blocks.length} blocks)`); +} + +/** + * 출력 파일에서 유틸 모듈로 가는 import 지정자. + * 확장자 제거, POSIX 구분자, `./`/`../` 접두 보장. + */ +export function relativeImport(fromFile: string, targetModule: string): string { + const target = targetModule.replace(/\.[cm]?[jt]sx?$/, ''); + let rel = path.relative(path.dirname(fromFile), target).split(path.sep).join('/'); + if (!rel.startsWith('.')) rel = `./${rel}`; + return rel; +} + +/** `/figma.config.json` 의 packageImportPath → 없으면 `/package.json` 의 name. */ +export function resolvePackageImportPath(cwd: string): string { + const figmaConfig = path.join(cwd, 'figma.config.json'); + if (existsSync(figmaConfig)) { + const v = readJson(figmaConfig); + if (isRecord(v) && typeof v.packageImportPath === 'string') return v.packageImportPath; + } + const pkg = path.join(cwd, 'package.json'); + if (existsSync(pkg)) { + const v = readJson(pkg); + if (isRecord(v) && typeof v.name === 'string') return v.name; + } + throw new Error( + `Cannot resolve package name: add packageImportPath to figma.config.json or name to package.json in ${cwd}`, + ); +} + +/** `/.env` 가 있으면 process.env 에 로드(기존 값 우선). FIGMA_TOKEN 없으면 에러. */ +function loadToken(cwd: string, env: NodeJS.ProcessEnv): string { + if (!env.FIGMA_TOKEN) { + const envFile = path.join(cwd, '.env'); + if (existsSync(envFile)) { + process.loadEnvFile(envFile); + if (env !== process.env && process.env.FIGMA_TOKEN) { + env.FIGMA_TOKEN = process.env.FIGMA_TOKEN; + } + } + } + const token = env.FIGMA_TOKEN; + if (!token) throw new Error('FIGMA_TOKEN is not set (or pass --from-json)'); + return token; +} + +async function loadFromRest( + fileKey: string, + nodeId: string, + token: string, +): Promise { + const getNodes = async (ids: string[], depth?: number): Promise => { + const params = new URLSearchParams({ ids: ids.join(',') }); + if (depth) params.set('depth', String(depth)); + const res = await fetch(`https://api.figma.com/v1/files/${fileKey}/nodes?${params}`, { + headers: { 'X-FIGMA-TOKEN': token }, + }); + if (!res.ok) { + throw new Error(`Figma API ${res.status} ${res.statusText} for ids=${ids.join(',')}`); + } + // I/O 경계. 응답 구조는 rest.ts 의 optional 필드 타입이 방어한다. + return (await res.json()) as RestNodesResponse; + }; + + const nodesJson = await getNodes([nodeId]); + const setIds = collectComponentSetIds(nodesJson, nodeId); + + const setDocs: Record = {}; + if (setIds.length > 0) { + const setsJson = await getNodes(setIds, 1); + for (const id of setIds) { + const doc = setsJson.nodes?.[id]?.document; + if (doc) setDocs[id] = doc; + } + } + + return fromRest(nodesJson, nodeId, setDocs); +} + +/** Prettier: 소비 패키지(cwd) 기준 설정. 플러그인 로드 실패 시 플러그인 없이 재시도. */ +async function format( + raw: string, + outPath: string, + cwd: string, + warn: (m: string) => void, +): Promise { + const config = (await prettier.resolveConfig(path.join(cwd, 'package.json'))) ?? {}; + try { + return await prettier.format(raw, { ...config, filepath: outPath }); + } catch (err) { + warn( + `warn: prettier plugins failed (${err instanceof Error ? err.message : String(err)}); formatting without plugins`, + ); + return prettier.format(raw, { ...config, plugins: [], filepath: outPath }); + } +} + +function readJson(file: string): unknown { + return JSON.parse(readFileSync(file, 'utf8')); +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} + +/** `child` 가 `dir` 안(또는 같음)인지. */ +function isInside(dir: string, child: string): boolean { + const rel = path.relative(dir, child); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} diff --git a/scripts/extract-code-connect/src/model.ts b/scripts/extract-code-connect/src/model.ts new file mode 100644 index 000000000..5c885eff4 --- /dev/null +++ b/scripts/extract-code-connect/src/model.ts @@ -0,0 +1,49 @@ +export type PropType = 'TEXT' | 'BOOLEAN' | 'VARIANT' | 'SLOT' | 'INSTANCE_SWAP'; + +/** Figma 컴포넌트 속성. `name` 은 `#id` 접미가 제거된 상태. */ +export interface Prop { + name: string; + type: PropType; + variantOptions?: string[]; + /** + * 이 prop 의 렌더 여부를 게이팅하는 BOOLEAN 컴포넌트 속성의 이름 (stripPropId 처리 후). + * Figma 에서 텍스트 레이어의 `visible` 이 다른 BOOLEAN prop 에 bind 된 경우 채워진다. + * runtime 에서 이 BOOLEAN 이 false 이면 attribute 를 emit 하지 않는다. + */ + visibleWhen?: string; +} + +/** 어댑터가 만드는 정규화 트리. INSTANCE 만 children 에 남긴다. */ +export interface TreeNode { + kind: 'ROOT' | 'INSTANCE'; + name: string; + props: Prop[]; + children: TreeNode[]; + /** + * 이 INSTANCE 의 렌더 여부를 게이팅하는 부모의 BOOLEAN 속성 이름 (stripPropId 처리 후). + * Figma 에서 이 INSTANCE 자체의 `visible` 이 부모의 BOOLEAN prop 에 bind 된 경우 채워진다. + */ + visibleWhen?: string; +} + +/** 어댑터 출력. `name` 은 Figma 컴포넌트 노드 이름. */ +export interface ComponentTree { + name: string; + tree: TreeNode; +} + +/** 소비 패키지 `figma-utils.ts` 의 PropSpec 과 1:1. import 하지 않는다(패키지 경계). */ +export type Spec = + | { kind: 'string' | 'boolean' | 'slot' | 'instance'; name: string; visibleWhen?: string } + | { kind: 'enum'; name: string; options: string[]; visibleWhen?: string }; + +/** + * `const = getProperties(instance, '', entries)` 한 덩어리. + * `todos` 는 지원하지 않는 INSTANCE_SWAP 속성 이름. + */ +export interface Block { + varName: string; + instanceName: string; + entries: Record; + todos: string[]; +} diff --git a/scripts/extract-code-connect/src/naming.test.ts b/scripts/extract-code-connect/src/naming.test.ts new file mode 100644 index 000000000..4d8cddfe9 --- /dev/null +++ b/scripts/extract-code-connect/src/naming.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + isNamedInstance, + isParen, + lowerFirst, + stripParens, + stripPropId, + toKebab, + toPascal, +} from './naming'; + +describe('naming', () => { + it('toPascal', () => { + expect(toPascal('Dialog')).toBe('Dialog'); + expect(toPascal('alert dialog')).toBe('AlertDialog'); + expect(toPascal('alert-dialog')).toBe('AlertDialog'); + expect(toPascal('AlertDialog')).toBe('AlertDialog'); + }); + + it('toKebab', () => { + expect(toKebab('Dialog')).toBe('dialog'); + expect(toKebab('AlertDialog')).toBe('alert-dialog'); + expect(toKebab('Alert Dialog')).toBe('alert-dialog'); + expect(toKebab('alert_dialog')).toBe('alert-dialog'); + }); + + it('lowerFirst', () => { + expect(lowerFirst('Assistive')).toBe('assistive'); + expect(lowerFirst('action')).toBe('action'); + expect(lowerFirst('')).toBe(''); + }); + + it('isParen / stripParens', () => { + expect(isParen('(Popup)')).toBe(true); + expect(isParen('Popup')).toBe(false); + expect(isParen('(has footer)')).toBe(true); + expect(stripParens('(Popup)')).toBe('Popup'); + expect(stripParens('Popup')).toBe('Popup'); + }); + + it('isNamedInstance: 영문자 또는 ( 로 시작해야 한다', () => { + expect(isNamedInstance('Assistive')).toBe(true); + expect(isNamedInstance('(Header)')).toBe(true); + expect(isNamedInstance('💙Button')).toBe(false); + expect(isNamedInstance('🟨Button/SlotLayer')).toBe(false); + expect(isNamedInstance('❤️SlotIcon')).toBe(false); + expect(isNamedInstance('1Thing')).toBe(false); + expect(isNamedInstance('')).toBe(false); + }); + + it('stripPropId', () => { + expect(stripPropId('title#2328:0')).toBe('title'); + expect(stripPropId('(content)#2260:77')).toBe('(content)'); + expect(stripPropId('size')).toBe('size'); + }); +}); diff --git a/scripts/extract-code-connect/src/naming.ts b/scripts/extract-code-connect/src/naming.ts new file mode 100644 index 000000000..fcea609cc --- /dev/null +++ b/scripts/extract-code-connect/src/naming.ts @@ -0,0 +1,42 @@ +/** 'alert dialog' | 'alert-dialog' | 'AlertDialog' → 'AlertDialog' */ +export function toPascal(name: string): string { + return splitWords(name) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(''); +} + +/** 'AlertDialog' | 'Alert Dialog' | 'alert_dialog' → 'alert-dialog' */ +export function toKebab(name: string): string { + return splitWords(name) + .map((w) => w.toLowerCase()) + .join('-'); +} + +export function lowerFirst(s: string): string { + return s ? s.charAt(0).toLowerCase() + s.slice(1) : s; +} + +export function isParen(s: string): boolean { + return s.startsWith('(') && s.endsWith(')'); +} + +export function stripParens(s: string): string { + return isParen(s) ? s.slice(1, -1) : s; +} + +/** 인스턴스 이름이 영문자 또는 '(' 로 시작하면 true. 이모지 접두 core primitive 는 false. */ +export function isNamedInstance(s: string): boolean { + return /^[A-Za-z(]/.test(s); +} + +/** 'title#2328:0' → 'title' */ +export function stripPropId(s: string): string { + return s.replace(/#\d+:\d+$/, ''); +} + +function splitWords(name: string): string[] { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter(Boolean); +} diff --git a/scripts/extract-code-connect/src/render.test.ts b/scripts/extract-code-connect/src/render.test.ts new file mode 100644 index 000000000..bf34dea29 --- /dev/null +++ b/scripts/extract-code-connect/src/render.test.ts @@ -0,0 +1,231 @@ +import prettier from 'prettier'; +import { describe, expect, it } from 'vitest'; + +import type { Block } from './model'; +import { render } from './render'; + +const fmt = (code: string) => + prettier.format(code, { + parser: 'typescript', + singleQuote: true, + tabWidth: 4, + printWidth: 100, + }); + +const dialogBlocks: Block[] = [ + { + varName: 'popup', + instanceName: '(Popup)', + entries: { size: { kind: 'enum', name: 'size', options: ['md', 'lg', 'xl'] } }, + todos: [], + }, + { + varName: 'header', + instanceName: '(Header)', + entries: { + title: { kind: 'string', name: 'title' }, + description: { kind: 'string', name: 'description' }, + }, + todos: [], + }, + { + varName: 'body', + instanceName: '(Body)', + entries: { children: { kind: 'slot', name: '(content)' } }, + todos: [], + }, + { + varName: 'footer', + instanceName: '(Footer)', + entries: { + assistive: { kind: 'instance', name: 'Assistive' }, + action: { kind: 'instance', name: 'Action' }, + }, + todos: [], + }, +]; + +const url = 'https://www.figma.com/design/he4tiAGOKGPl0Fm56ZpJsy/X?node-id=2337-38499&m=dev'; +const base = { + url, + utilsImport: '../../utils/figma-utils', + packageImportPath: '@vapor-ui/composites', +}; + +describe('render', () => { + it('Dialog 블록을 현재 dialog.figma.ts 와 동치인 템플릿으로 렌더한다', async () => { + const out = await fmt( + render({ + ...base, + blocks: dialogBlocks, + componentName: 'Dialog', + kebab: 'dialog', + hasParts: true, + }), + ); + + const expected = await fmt(` +// url=${url} +// source=src/components/dialog/dialog.tsx +// component=Dialog +import figma from 'figma'; + +import { getProperties } from '../../utils/figma-utils'; + +const instance = figma.selectedInstance; + +const popup = getProperties(instance, '(Popup)', { + size: { kind: 'enum', name: 'size', options: { md: 'md', lg: 'lg', xl: 'xl' } }, +}); + +const header = getProperties(instance, '(Header)', { + title: { kind: 'string', name: 'title' }, + description: { kind: 'string', name: 'description' }, +}); + +const body = getProperties(instance, '(Body)', { + children: { kind: 'slot', name: '(content)' }, +}); + +const footer = getProperties(instance, '(Footer)', { + assistive: { kind: 'instance', name: 'Assistive' }, + action: { kind: 'instance', name: 'Action' }, +}); + +export default { + example: figma.code\` + + \${body.children} + + \`, + imports: ['import { Dialog } from "@vapor-ui/composites"'], + id: 'dialog', + metadata: { nestable: false }, +}; +`); + expect(out).toBe(expected); + }); + + it('index.parts.ts 가 없으면 flat 이름, slot 이 없으면 self-closing', async () => { + const blocks: Block[] = [ + { + varName: 'root', + instanceName: '(Root)', + entries: { label: { kind: 'string', name: 'label' } }, + todos: [], + }, + ]; + const out = await fmt( + render({ ...base, blocks, componentName: 'Badge', kebab: 'badge', hasParts: false }), + ); + expect(out).toContain('/); + expect(out).toContain('import { Badge } from "@vapor-ui/composites"'); + expect(out).toContain("id: 'badge'"); + }); + + it('utilsImport 와 packageImportPath 를 그대로 쓴다', async () => { + const blocks: Block[] = [{ varName: 'r', instanceName: '(R)', entries: {}, todos: [] }]; + const out = await fmt( + render({ + blocks, + url, + componentName: 'Button', + kebab: 'button', + hasParts: false, + utilsImport: '../../../utils/figma-utils', + packageImportPath: '@vapor-ui/core', + }), + ); + expect(out).toContain("import { getProperties } from '../../../utils/figma-utils';"); + expect(out).toContain('import { Button } from "@vapor-ui/core"'); + }); + + it('INSTANCE_SWAP todo 는 블록 안 주석으로 남는다', async () => { + const blocks: Block[] = [ + { varName: 'slot', instanceName: '(Slot)', entries: {}, todos: ['Icon'] }, + ]; + const out = await fmt( + render({ ...base, blocks, componentName: 'X', kebab: 'x', hasParts: false }), + ); + expect(out).toContain("// TODO: INSTANCE_SWAP 'Icon' is not supported by getProperties"); + }); + + it('visibleWhen 이 있으면 spec 뒤에 필드로 붙는다', async () => { + const blocks: Block[] = [ + { + varName: 'header', + instanceName: '(Header)', + entries: { + description: { + kind: 'string', + name: 'description', + visibleWhen: '(has description)', + }, + }, + todos: [], + }, + { + varName: 'footer', + instanceName: '(Footer)', + entries: { + assistive: { + kind: 'instance', + name: 'Assistive', + visibleWhen: '(has assistive)', + }, + action: { kind: 'instance', name: 'Action' }, + }, + todos: [], + }, + { + varName: 'popup', + instanceName: '(Popup)', + entries: { + size: { + kind: 'enum', + name: 'size', + options: ['md'], + visibleWhen: '(has size)', + }, + }, + todos: [], + }, + ]; + const out = await fmt( + render({ ...base, blocks, componentName: 'X', kebab: 'x', hasParts: false }), + ); + expect(out).toContain( + "description: { kind: 'string', name: 'description', visibleWhen: '(has description)' }", + ); + expect(out).toContain( + "assistive: { kind: 'instance', name: 'Assistive', visibleWhen: '(has assistive)' }", + ); + expect(out).toContain("action: { kind: 'instance', name: 'Action' }"); + expect(out).toContain( + "size: { kind: 'enum', name: 'size', options: { md: 'md' }, visibleWhen: '(has size)' }", + ); + }); + + it('식별자가 아닌 enum 옵션은 key 를 인용한다', async () => { + const blocks: Block[] = [ + { + varName: 'p', + instanceName: '(P)', + entries: { v: { kind: 'enum', name: 'v', options: ['x-large', 'md'] } }, + todos: [], + }, + ]; + const out = await fmt( + render({ ...base, blocks, componentName: 'X', kebab: 'x', hasParts: false }), + ); + expect(out).toContain("'x-large': 'x-large', md: 'md'"); + }); +}); diff --git a/scripts/extract-code-connect/src/render.ts b/scripts/extract-code-connect/src/render.ts new file mode 100644 index 000000000..fd1094fc3 --- /dev/null +++ b/scripts/extract-code-connect/src/render.ts @@ -0,0 +1,88 @@ +import type { Block, Spec } from './model'; + +const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +const key = (k: string): string => (IDENT.test(k) ? k : q(k)); + +export interface RenderInput { + blocks: Block[]; + /** 입력 URL 그대로. 첫 줄 주석. */ + url: string; + /** Pascal 컴포넌트 이름. */ + componentName: string; + kebab: string; + /** `index.parts.ts` 존재 → ``. */ + hasParts: boolean; + /** `getProperties` import 지정자. 예: '../../utils/figma-utils' */ + utilsImport: string; + /** `imports` 문자열의 패키지명. 예: '@vapor-ui/composites' */ + packageImportPath: string; +} + +/** Block[] → `.figma.ts` 원문. Prettier 는 호출측에서. */ +export function render({ + blocks, + url, + componentName, + kebab, + hasParts, + utilsImport, + packageImportPath, +}: RenderInput): string { + const rootTag = hasParts ? `${componentName}.Root` : componentName; + + const attrs: string[] = []; + const children: string[] = []; + for (const block of blocks) { + for (const [k, spec] of Object.entries(block.entries)) { + (spec.kind === 'slot' ? children : attrs).push(`\${${block.varName}.${k}}`); + } + } + + const openTag = `<${rootTag}\n${attrs.map((a) => ` ${a}`).join('\n')}\n `; + const example = + children.length === 0 + ? `${openTag}/>` + : `${openTag}>\n${children.map((c) => ` ${c}`).join('\n')}\n `; + + return `// url=${url} +// source=src/components/${kebab}/${kebab}.tsx +// component=${componentName} +import figma from 'figma'; + +import { getProperties } from ${q(utilsImport)}; + +const instance = figma.selectedInstance; + +${blocks.map(renderBlock).join('\n\n')} + +export default { + example: figma.code\` + ${example} + \`, + imports: ['import { ${componentName} } from "${packageImportPath}"'], + id: ${q(kebab)}, + metadata: { nestable: false }, +}; +`; +} + +function renderBlock(block: Block): string { + const lines = Object.entries(block.entries).map( + ([k, spec]) => ` ${key(k)}: ${renderSpec(spec)},`, + ); + for (const name of block.todos) { + lines.push(` // TODO: INSTANCE_SWAP ${q(name)} is not supported by getProperties`); + } + return `const ${block.varName} = getProperties(instance, ${q(block.instanceName)}, {\n${lines.join('\n')}\n});`; +} + +function renderSpec(spec: Spec): string { + const gate = spec.visibleWhen ? `, visibleWhen: ${q(spec.visibleWhen)}` : ''; + if (spec.kind === 'enum') { + const options = spec.options.map((o) => `${key(o)}: ${q(o)}`).join(', '); + return `{ kind: 'enum', name: ${q(spec.name)}, options: { ${options} }${gate} }`; + } + return `{ kind: ${q(spec.kind)}, name: ${q(spec.name)}${gate} }`; +} diff --git a/scripts/extract-code-connect/tsconfig.json b/scripts/extract-code-connect/tsconfig.json new file mode 100644 index 000000000..ac10f46c1 --- /dev/null +++ b/scripts/extract-code-connect/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true + }, + "include": ["src/**/*", "tsup.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/extract-code-connect/tsup.config.ts b/scripts/extract-code-connect/tsup.config.ts new file mode 100644 index 000000000..cfd68bc16 --- /dev/null +++ b/scripts/extract-code-connect/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/cli/index.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + sourcemap: true, + splitting: false, + dts: false, +}); diff --git a/scripts/extract-code-connect/vitest.config.ts b/scripts/extract-code-connect/vitest.config.ts new file mode 100644 index 000000000..efe237d07 --- /dev/null +++ b/scripts/extract-code-connect/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});