byuckchon-frontend-cli 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -5
- package/bin/index.js +9 -1
- package/package.json +1 -1
- package/src/ai/systemPrompt.js +19 -1
- package/src/ai/tools.js +138 -0
- package/src/commands/adopt.js +7 -0
- package/src/commands/chat.js +4 -1
- package/src/commands/config.js +24 -0
- package/src/config/index.js +9 -0
- package/src/figma/api.js +117 -0
- package/src/figma/simplify.js +180 -0
- package/src/figma/url.js +41 -0
package/README.md
CHANGED
|
@@ -91,6 +91,68 @@ bc init
|
|
|
91
91
|
> 안전망은 git diff. 매 작업 후 `git status` / `git diff` 로 확인하고, 마음에 안 들면 `git checkout .` 으로 되돌리세요.
|
|
92
92
|
> 다음 버전에서 per-file 승인(`y/n/v`) 옵션 추가 예정.
|
|
93
93
|
|
|
94
|
+
### Figma 연동 — 디자인 → 코드 (v1.6+)
|
|
95
|
+
|
|
96
|
+
채팅 안에서 모델이 직접 Figma REST API 를 호출해서 디자인 정보를 읽고 컴포넌트/페이지를 만듭니다.
|
|
97
|
+
|
|
98
|
+
#### 사용자가 한 번만 하는 셋업
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# 1) Figma → Settings → Personal access tokens → "Generate new token"
|
|
102
|
+
# Read 권한만 있으면 충분 (file 읽기 / image export 둘 다 read 로 됨)
|
|
103
|
+
|
|
104
|
+
# 2) 토큰을 .env 에 박기 (gitignore 됨)
|
|
105
|
+
echo "FIGMA_TOKEN=figd_xxxxxxxxxxxxxxxx" >> .env
|
|
106
|
+
|
|
107
|
+
# 3) bc.config.json 의 design.figma 에 파일/노드 URL 박기 (bc adopt 시점에 입력하거나 직접 편집)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`bc.config.json` 예시:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"design": {
|
|
115
|
+
"figma": "https://www.figma.com/design/ABC123/Marketd-Admin?node-id=2-105",
|
|
116
|
+
"figmaTokenEnv": "FIGMA_TOKEN"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### 디자이너 협업이 필요한 부분
|
|
122
|
+
|
|
123
|
+
| 디자이너 측 작업 | 왜 필요? |
|
|
124
|
+
| --------------------------------------- | --------------------------------------------------------- |
|
|
125
|
+
| 프레임/컴포넌트에 **의미 있는 이름** | `Frame 21` 이 아니라 `Card/Product/Sold-out` 처럼 의미별로 — AI 가 이름으로 컴포넌트 이름과 variant 를 추론합니다. |
|
|
126
|
+
| **Auto layout** 적용 | 안 쓰면 픽셀 좌표만 떨어져 `position: absolute` 코드가 나옵니다. Auto layout 이면 자동으로 `flex`/`gap` 변환. |
|
|
127
|
+
| **로컬 스타일** 등록 (color/text) | "Brand/Primary" 같은 스타일을 등록해두면 `fetch_figma_styles` 로 디자인 토큰을 일괄 추출해서 Tailwind 테마로 바로 박을 수 있어요. |
|
|
128
|
+
| **Components** 화 (♦ 마름모 아이콘) | 반복 UI 가 component 면 모델이 "이거 디자인 시스템 컴포넌트구나" 인식 → 코드에서도 재사용 컴포넌트를 만듭니다. |
|
|
129
|
+
| frame 별로 **"Copy link to selection"** | 일반 share link 는 파일 전체. 특정 frame URL 을 받아야 AI 가 그것만 정확히 가져옵니다. |
|
|
130
|
+
|
|
131
|
+
#### 채팅에서 쓰는 법
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
you › 새 멤버 카드 컴포넌트 만들어줘. 디자인은 https://www.figma.com/design/.../?node-id=12-34 이거 참고해서.
|
|
135
|
+
|
|
136
|
+
🔧 fetch_figma("https://www.figma.com/design/.../?node-id=12-34")
|
|
137
|
+
🔧 list_files("src/components/**/Card*")
|
|
138
|
+
🔧 read_file("src/components/Card/ProductCard.tsx")
|
|
139
|
+
🆕 생성 src/components/Card/MemberCard/MemberCard.tsx (52 lines)
|
|
140
|
+
🆕 생성 src/components/Card/MemberCard/index.ts (3 lines)
|
|
141
|
+
bc › Auto layout 이 row 였고 padding 12/16 이었어요. MemberCard 만들었습니다.
|
|
142
|
+
기존 ProductCard 와 같은 폴더 컨벤션을 따랐어요.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
내장 Figma 툴:
|
|
146
|
+
|
|
147
|
+
| 툴 | 동작 |
|
|
148
|
+
| --------------------- | ------------------------------------------------------------- |
|
|
149
|
+
| `fetch_figma` | 노드 트리 (autoLayout / fills / text / size / children) 가져오기 |
|
|
150
|
+
| `fetch_figma_image` | 프레임을 PNG/JPG/SVG 로 export — public asset 으로 저장도 가능 |
|
|
151
|
+
| `fetch_figma_styles` | 파일의 컬러/타이포 토큰 목록 → 디자인 토큰 generator 만들 때 |
|
|
152
|
+
|
|
153
|
+
> Figma 응답은 자동으로 압축됩니다 (자식 60개, 깊이 8 까지). 너무 큰 프레임은 더 작은
|
|
154
|
+
> 자식 frame URL 을 줘서 분할 정복하세요.
|
|
155
|
+
|
|
94
156
|
### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
|
|
95
157
|
|
|
96
158
|
`bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
|
|
@@ -208,11 +270,25 @@ Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
|
|
|
208
270
|
3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
|
|
209
271
|
- 사전에 `brew install pngpaste` 한 번 필요.
|
|
210
272
|
|
|
211
|
-
### 한글 입력이
|
|
273
|
+
### 한글 입력이 자꾸 씹힐 때 (v1.6+)
|
|
274
|
+
|
|
275
|
+
`ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
|
|
276
|
+
빠뜨려지는 경우가 있습니다 — ink-text-input 의 알려진 한계입니다.
|
|
277
|
+
|
|
278
|
+
**가장 확실한 해결**: 입력 모드를 plain(readline) 으로 영구 전환
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
bc config set-ui plain # 글로벌로 plain 모드 고정
|
|
282
|
+
# 한글 입력 안정, 모든 기본 기능 동작 (RAG, OpenAPI, Figma 툴 호출까지)
|
|
283
|
+
# 단, ink 전용 기능 일부 미지원: 슬래시 자동완성 메뉴, 인라인 이미지 첨부
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
ink 로 다시 돌아오려면:
|
|
287
|
+
```bash
|
|
288
|
+
bc config set-ui ink
|
|
289
|
+
```
|
|
212
290
|
|
|
213
|
-
|
|
214
|
-
v1.4 부터는 ink 시작 후 커서를 강제로 다시 켜고 가짜 커서를 끄는 방식으로 수정되어 정상 동작해야 합니다.
|
|
215
|
-
혹시 그래도 문제가 보이면 `bc chat --plain` 으로 readline 모드를 쓸 수 있습니다 (TUI 기능은 일부 제한).
|
|
291
|
+
일회성으로 plain 만 쓰고 싶으면 `bc chat --plain`.
|
|
216
292
|
|
|
217
293
|
### `bc config` — 설정
|
|
218
294
|
|
|
@@ -224,6 +300,8 @@ bc config set-key anthropic # 키 안전 입력 (가려짐)
|
|
|
224
300
|
bc config set-key anthropic sk-ant-... # 직접 지정
|
|
225
301
|
bc config set-gateway https://ai.example.com # 사내 게이트웨이 모드
|
|
226
302
|
bc config set-gateway # 게이트웨이 해제 (BYOK 모드)
|
|
303
|
+
bc config set-ui plain # 한글 IME 안정 모드
|
|
304
|
+
bc config set-ui ink # 풀 TUI 복귀
|
|
227
305
|
```
|
|
228
306
|
|
|
229
307
|
## 설정 위치
|
|
@@ -263,7 +341,8 @@ bc config set-gateway # 게이트웨이 해제 (BYOK 모
|
|
|
263
341
|
- [x] Phase 3c-1: chat 시작 시 인덱스 자동 빌드, OpenAPI 자동 fetch+캐시+시스템 프롬프트 주입
|
|
264
342
|
- [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
|
|
265
343
|
- [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
|
|
266
|
-
- [
|
|
344
|
+
- [x] v1.6.0 — Figma 툴 (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
|
|
345
|
+
- [ ] v1.7.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
|
|
267
346
|
- [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
|
|
268
347
|
- [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
|
|
269
348
|
|
package/bin/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
configSetModelCommand,
|
|
18
18
|
configSetKeyCommand,
|
|
19
19
|
configSetGatewayCommand,
|
|
20
|
+
configSetUiCommand,
|
|
20
21
|
} from '../src/commands/config.js';
|
|
21
22
|
|
|
22
23
|
// 프로젝트 .env 가 있으면 자동 로드 (ANTHROPIC_API_KEY, OPENAI_API_KEY 등).
|
|
@@ -27,7 +28,7 @@ const program = new Command();
|
|
|
27
28
|
program
|
|
28
29
|
.name('bc')
|
|
29
30
|
.description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
|
|
30
|
-
.version('1.
|
|
31
|
+
.version('1.6.0');
|
|
31
32
|
|
|
32
33
|
program
|
|
33
34
|
.command('init')
|
|
@@ -128,6 +129,13 @@ cfg
|
|
|
128
129
|
await configSetGatewayCommand(url);
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
cfg
|
|
133
|
+
.command('set-ui <mode>')
|
|
134
|
+
.description('chat 입력 모드: ink (풀 TUI) | plain (readline — 한글 IME 안정)')
|
|
135
|
+
.action(async (mode) => {
|
|
136
|
+
await configSetUiCommand(mode);
|
|
137
|
+
});
|
|
138
|
+
|
|
131
139
|
program.exitOverride((err) => {
|
|
132
140
|
if (
|
|
133
141
|
err.code === 'commander.help' ||
|
package/package.json
CHANGED
package/src/ai/systemPrompt.js
CHANGED
|
@@ -28,6 +28,18 @@ export function buildSystemPrompt({ effective, paths, project }) {
|
|
|
28
28
|
' - 자동 생성된 `*.gen.ts` 가 있다면 거기서 타입을 import 해서 재정의를 피한다.',
|
|
29
29
|
' 3) 마지막으로 **만든 파일 목록과 다음 액션(어디서 import 하면 되는지 등)** 을 한국어로 짧게 요약.',
|
|
30
30
|
'',
|
|
31
|
+
'## Figma 작업 (디자인 → 코드)',
|
|
32
|
+
'사용자가 Figma 링크를 던지거나 "디자인대로 만들어줘" 같은 요청을 하면:',
|
|
33
|
+
' 1) `fetch_figma(url)` 로 디자인 트리를 받는다. 노드의 name, autoLayout, fills, text, size 를 학습.',
|
|
34
|
+
' 2) 필요하면 `fetch_figma_styles(url)` 로 컬러/타이포 토큰을 받아 Tailwind config 또는 theme 변수에 반영.',
|
|
35
|
+
' 3) `list_files` 로 기존 UI 컴포넌트 폴더 구조를 보고, 같은 컨벤션 따라 `write_file` 로 생성.',
|
|
36
|
+
' 4) Figma `INSTANCE` (= 디자인 시스템 컴포넌트) 가 보이면 기존 코드의 동일 컴포넌트를 ',
|
|
37
|
+
' `search_code` 로 찾아 재사용한다. 없으면 컴포넌트부터 생성.',
|
|
38
|
+
' 5) 픽셀 좌표(absoluteBoundingBox) 보다 **autoLayout** 우선. autoLayout 이 있으면',
|
|
39
|
+
' `flex direction={row|col} gap-x` 패턴으로 짠다. 없으면 디자이너에게 ',
|
|
40
|
+
' "Auto layout 으로 정리해달라" 고 요청하라고 안내.',
|
|
41
|
+
' 6) 색은 가능하면 fills 의 raw rgba 대신 Tailwind 색 이름이나 디자인 토큰을 사용.',
|
|
42
|
+
'',
|
|
31
43
|
'"코드 짜줘" 라는 표현은 채팅창에 코드 블록을 출력하라는 의미가 **아니다**.',
|
|
32
44
|
'항상 툴을 사용해 실제 파일을 만들어라. 채팅에는 진행 상황과 결과 요약만 짧게 적는다.',
|
|
33
45
|
'',
|
|
@@ -57,7 +69,13 @@ export function buildSystemPrompt({ effective, paths, project }) {
|
|
|
57
69
|
}\``,
|
|
58
70
|
);
|
|
59
71
|
}
|
|
60
|
-
if (project?.design?.figma)
|
|
72
|
+
if (project?.design?.figma) {
|
|
73
|
+
meta.push(`- Figma: ${project.design.figma}`);
|
|
74
|
+
meta.push(
|
|
75
|
+
' (Figma 작업 요청을 받으면 fetch_figma 툴로 디자인을 먼저 읽고, ' +
|
|
76
|
+
'필요하면 fetch_figma_styles 로 토큰을 가져와 코드를 짠다.)',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
61
79
|
if (project?.api?.openapi) meta.push(`- OpenAPI: ${project.api.openapi}`);
|
|
62
80
|
if (project?.api?.baseUrl) meta.push(`- API base URL: ${project.api.baseUrl}`);
|
|
63
81
|
if (effective?.model) meta.push(`- 사용 모델: ${effective.model}`);
|
package/src/ai/tools.js
CHANGED
|
@@ -5,6 +5,13 @@ import fg from 'fast-glob';
|
|
|
5
5
|
import { tool } from 'ai';
|
|
6
6
|
|
|
7
7
|
import { searchIndex } from '../indexer/search.js';
|
|
8
|
+
import {
|
|
9
|
+
fetchFromUrl as fetchFigmaFromUrl,
|
|
10
|
+
fetchImageUrls as fetchFigmaImageUrls,
|
|
11
|
+
fetchStyles as fetchFigmaStyles,
|
|
12
|
+
} from '../figma/api.js';
|
|
13
|
+
import { simplifyFetchNodes } from '../figma/simplify.js';
|
|
14
|
+
import { parseFigmaUrl } from '../figma/url.js';
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* Agentic chat 용 툴 정의.
|
|
@@ -164,6 +171,91 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
164
171
|
return { ok: true, path: rel, action: 'edited' };
|
|
165
172
|
}
|
|
166
173
|
|
|
174
|
+
// ─────────── Figma 툴 ───────────
|
|
175
|
+
|
|
176
|
+
async function fetchFigma({ url, depth = 4 }) {
|
|
177
|
+
try {
|
|
178
|
+
const result = await fetchFigmaFromUrl({ url, effective, depth });
|
|
179
|
+
if (result.kind === 'file') {
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
kind: 'file_summary',
|
|
183
|
+
file: result.summary.name,
|
|
184
|
+
pages: result.summary.pages,
|
|
185
|
+
hint:
|
|
186
|
+
'node-id 가 없는 파일 링크입니다. 디자이너에게 특정 frame 의 ' +
|
|
187
|
+
'"Copy link to selection" 을 받아오면 더 정확한 코드 생성 가능.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const simple = simplifyFetchNodes(result.raw);
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
kind: 'nodes',
|
|
194
|
+
fileKey: result.fileKey,
|
|
195
|
+
nodeId: result.nodeId,
|
|
196
|
+
documents: simple.documents,
|
|
197
|
+
components: simple.components,
|
|
198
|
+
styles: simple.styles,
|
|
199
|
+
};
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return { ok: false, error: err?.message ?? String(err), status: err?.status };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function fetchFigmaImage({ url, format = 'png', scale = 2, savePath }) {
|
|
206
|
+
try {
|
|
207
|
+
const parsed = parseFigmaUrl(url);
|
|
208
|
+
if (!parsed?.nodeId) {
|
|
209
|
+
return { ok: false, error: 'node-id 가 있는 frame 링크가 필요합니다.' };
|
|
210
|
+
}
|
|
211
|
+
const images = await fetchFigmaImageUrls({
|
|
212
|
+
fileKey: parsed.fileKey,
|
|
213
|
+
nodeIds: [parsed.nodeId],
|
|
214
|
+
format,
|
|
215
|
+
scale,
|
|
216
|
+
effective,
|
|
217
|
+
});
|
|
218
|
+
const imageUrl = images[parsed.nodeId];
|
|
219
|
+
if (!imageUrl) {
|
|
220
|
+
return { ok: false, error: 'Figma 가 이미지 URL 을 돌려주지 않음' };
|
|
221
|
+
}
|
|
222
|
+
if (savePath) {
|
|
223
|
+
const { abs, rel } = safePath(savePath);
|
|
224
|
+
const res = await fetch(imageUrl);
|
|
225
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
226
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
227
|
+
await fs.writeFile(abs, buf);
|
|
228
|
+
onEvent({ kind: 'write_created', path: rel, bytes: buf.byteLength });
|
|
229
|
+
return { ok: true, savedTo: rel, bytes: buf.byteLength, format };
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, url: imageUrl, format, expiresInSeconds: 60 * 60 * 24 * 14 };
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function fetchFigmaStylesTool({ url }) {
|
|
238
|
+
try {
|
|
239
|
+
const parsed = parseFigmaUrl(url);
|
|
240
|
+
if (!parsed) return { ok: false, error: 'Figma URL 형식이 아닙니다.' };
|
|
241
|
+
const styles = await fetchFigmaStyles({ fileKey: parsed.fileKey, effective });
|
|
242
|
+
// 모델이 디자인 토큰을 만들 때 쓸 수 있도록 styleType 별로 그룹.
|
|
243
|
+
const grouped = {};
|
|
244
|
+
for (const s of styles) {
|
|
245
|
+
const t = s.style_type ?? s.styleType ?? 'OTHER';
|
|
246
|
+
(grouped[t] ??= []).push({
|
|
247
|
+
name: s.name,
|
|
248
|
+
description: s.description ?? '',
|
|
249
|
+
key: s.key,
|
|
250
|
+
nodeId: s.node_id ?? s.nodeId,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, fileKey: parsed.fileKey, total: styles.length, grouped };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
167
259
|
return {
|
|
168
260
|
read_file: tool({
|
|
169
261
|
description:
|
|
@@ -232,5 +324,51 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
|
|
|
232
324
|
},
|
|
233
325
|
execute: editFile,
|
|
234
326
|
}),
|
|
327
|
+
fetch_figma: tool({
|
|
328
|
+
description:
|
|
329
|
+
'Figma 노드 트리(컴포넌트/프레임/페이지) 를 읽는다. URL 에 node-id 가 있으면 그 frame 의 ' +
|
|
330
|
+
'간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
|
|
331
|
+
'없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
|
|
332
|
+
'디자인 의도를 학습한 뒤 코드를 짠다.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: 'object',
|
|
335
|
+
properties: {
|
|
336
|
+
url: { type: 'string', description: 'Figma share/copy link' },
|
|
337
|
+
depth: { type: 'number', default: 4, description: '노드 트리 탐색 깊이 (1-8)' },
|
|
338
|
+
},
|
|
339
|
+
required: ['url'],
|
|
340
|
+
additionalProperties: false,
|
|
341
|
+
},
|
|
342
|
+
execute: fetchFigma,
|
|
343
|
+
}),
|
|
344
|
+
fetch_figma_image: tool({
|
|
345
|
+
description:
|
|
346
|
+
'Figma 프레임을 PNG/JPG/SVG 이미지로 export. savePath 를 주면 프로젝트 폴더 안에 파일로 저장 ' +
|
|
347
|
+
'(스토리북 배경, public asset 등). 안 주면 임시 URL 만 반환.',
|
|
348
|
+
inputSchema: {
|
|
349
|
+
type: 'object',
|
|
350
|
+
properties: {
|
|
351
|
+
url: { type: 'string' },
|
|
352
|
+
format: { type: 'string', enum: ['png', 'jpg', 'svg', 'pdf'], default: 'png' },
|
|
353
|
+
scale: { type: 'number', default: 2 },
|
|
354
|
+
savePath: { type: 'string' },
|
|
355
|
+
},
|
|
356
|
+
required: ['url'],
|
|
357
|
+
additionalProperties: false,
|
|
358
|
+
},
|
|
359
|
+
execute: fetchFigmaImage,
|
|
360
|
+
}),
|
|
361
|
+
fetch_figma_styles: tool({
|
|
362
|
+
description:
|
|
363
|
+
'Figma 파일의 로컬 스타일(컬러/타이포/이펙트 토큰) 목록을 가져온다. 디자인 토큰 추출 / ' +
|
|
364
|
+
'Tailwind 테마 설정 / theme.ts 생성 시 사용.',
|
|
365
|
+
inputSchema: {
|
|
366
|
+
type: 'object',
|
|
367
|
+
properties: { url: { type: 'string' } },
|
|
368
|
+
required: ['url'],
|
|
369
|
+
additionalProperties: false,
|
|
370
|
+
},
|
|
371
|
+
execute: fetchFigmaStylesTool,
|
|
372
|
+
}),
|
|
235
373
|
};
|
|
236
374
|
}
|
package/src/commands/adopt.js
CHANGED
|
@@ -141,6 +141,13 @@ export async function adoptCommand(opts = {}) {
|
|
|
141
141
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
142
142
|
console.log(chalk.dim(' bc config set-key anthropic # API 키 등록'));
|
|
143
143
|
}
|
|
144
|
+
if (next.design.figma && !process.env.FIGMA_TOKEN) {
|
|
145
|
+
console.log(
|
|
146
|
+
chalk.dim(
|
|
147
|
+
' .env 에 FIGMA_TOKEN=figd-... 추가 # https://www.figma.com/settings 에서 발급',
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
144
151
|
console.log(chalk.dim(' bc chat # 이 프로젝트 컨텍스트로 대화'));
|
|
145
152
|
console.log();
|
|
146
153
|
}
|
package/src/commands/chat.js
CHANGED
|
@@ -133,8 +133,11 @@ export async function chatCommand(opts = {}) {
|
|
|
133
133
|
await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
|
|
134
134
|
|
|
135
135
|
// ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
|
|
136
|
+
// - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
|
|
137
|
+
// - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
|
|
136
138
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
137
|
-
|
|
139
|
+
const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
|
|
140
|
+
if (!isTTY || wantPlain) {
|
|
138
141
|
return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
|
|
139
142
|
}
|
|
140
143
|
|
package/src/commands/config.js
CHANGED
|
@@ -51,6 +51,12 @@ export async function configShowCommand() {
|
|
|
51
51
|
console.log(
|
|
52
52
|
` ${chalk.dim('요청 확인')} ${eff.effective.limits.confirmAtTokens.toLocaleString()} tokens`,
|
|
53
53
|
);
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(chalk.bold(' UI'));
|
|
56
|
+
console.log(
|
|
57
|
+
` ${chalk.dim('chat 입력 모드')} ${eff.global?.ui?.mode ?? 'ink'} ` +
|
|
58
|
+
chalk.dim('(plain 으로 두면 한글 IME 안정. bc config set-ui plain)'),
|
|
59
|
+
);
|
|
54
60
|
|
|
55
61
|
if (eff.paths.projectFile) {
|
|
56
62
|
console.log();
|
|
@@ -130,6 +136,24 @@ export async function configSetKeyCommand(provider, key) {
|
|
|
130
136
|
console.log(chalk.dim(` 파일: ${CONFIG_PATHS.globalFile} (chmod 600)\n`));
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
export async function configSetUiCommand(mode) {
|
|
140
|
+
const allowed = ['ink', 'plain'];
|
|
141
|
+
if (!allowed.includes(mode)) {
|
|
142
|
+
console.error(chalk.red(`사용법: bc config set-ui <${allowed.join('|')}>`));
|
|
143
|
+
console.error(
|
|
144
|
+
chalk.dim(
|
|
145
|
+
' ink = 풀 TUI (기본). 한글 IME 가 종종 씹히는 환경에서는 plain 권장.\n' +
|
|
146
|
+
' plain = readline 폴백. 한글 입력 안정, 슬래시 명령/이미지 미지원.\n',
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
const global = await loadGlobalConfig();
|
|
152
|
+
global.ui = { ...(global.ui ?? {}), mode };
|
|
153
|
+
await saveGlobalConfig(global);
|
|
154
|
+
console.log(chalk.green(`\n ✓ chat 입력 모드를 '${mode}' 로 저장했습니다.\n`));
|
|
155
|
+
}
|
|
156
|
+
|
|
133
157
|
export async function configSetGatewayCommand(url) {
|
|
134
158
|
const global = await loadGlobalConfig();
|
|
135
159
|
global.ai.gateway = url && url.trim() ? url.trim() : null;
|
package/src/config/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const DEFAULT_GLOBAL = {
|
|
|
20
20
|
apiKeys: {
|
|
21
21
|
// anthropic: 'sk-ant-...',
|
|
22
22
|
// openai: 'sk-...',
|
|
23
|
+
// figma: 'figd-...' // 통상 .env(FIGMA_TOKEN) 로 둠
|
|
23
24
|
},
|
|
24
25
|
/** 사내 게이트웨이를 쓰는 경우 base URL. 비우면 BYOK 모드. */
|
|
25
26
|
gateway: null,
|
|
@@ -30,6 +31,14 @@ const DEFAULT_GLOBAL = {
|
|
|
30
31
|
/** 한 요청이 이 토큰을 넘으면 사용자에게 확인. */
|
|
31
32
|
confirmAtTokens: 12_000,
|
|
32
33
|
},
|
|
34
|
+
ui: {
|
|
35
|
+
/**
|
|
36
|
+
* "ink" | "plain"
|
|
37
|
+
* 한글 IME 가 ink 에서 글자가 씹히면 "plain" 으로 두면 항상 readline 모드로 진입.
|
|
38
|
+
* --plain 플래그를 매번 안 쳐도 됨.
|
|
39
|
+
*/
|
|
40
|
+
mode: 'ink',
|
|
41
|
+
},
|
|
33
42
|
};
|
|
34
43
|
|
|
35
44
|
/**
|
package/src/figma/api.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { parseFigmaUrl } from './url.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Figma REST API 클라이언트.
|
|
5
|
+
*
|
|
6
|
+
* 인증: Personal Access Token 을 `X-Figma-Token` 헤더로 보냄.
|
|
7
|
+
* 토큰은 https://www.figma.com/settings 에서 "Personal access tokens" 로 발급.
|
|
8
|
+
*
|
|
9
|
+
* effective.figmaToken (또는 process.env[figmaTokenEnv]) 에서 키를 가져옴.
|
|
10
|
+
*/
|
|
11
|
+
const BASE = 'https://api.figma.com/v1';
|
|
12
|
+
|
|
13
|
+
export class FigmaError extends Error {
|
|
14
|
+
constructor(message, { status, body } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tokenFromEnv(effective) {
|
|
22
|
+
// 1) bc.config.json design.figmaTokenEnv 로 지정한 환경변수
|
|
23
|
+
const envName = effective?.design?.figmaTokenEnv ?? 'FIGMA_TOKEN';
|
|
24
|
+
return process.env[envName] ?? process.env.FIGMA_TOKEN ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function call(pathAndQuery, { token }) {
|
|
28
|
+
if (!token) {
|
|
29
|
+
throw new FigmaError(
|
|
30
|
+
'Figma 토큰이 없습니다. https://www.figma.com/settings 에서 Personal access token 을 발급받고 ' +
|
|
31
|
+
'`.env` 에 `FIGMA_TOKEN=figd_...` 로 등록하세요.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const res = await fetch(BASE + pathAndQuery, {
|
|
35
|
+
headers: { 'X-Figma-Token': token },
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
let body = null;
|
|
39
|
+
try {
|
|
40
|
+
body = await res.text();
|
|
41
|
+
} catch {
|
|
42
|
+
/* noop */
|
|
43
|
+
}
|
|
44
|
+
throw new FigmaError(`Figma API ${res.status} ${res.statusText}: ${pathAndQuery}`, {
|
|
45
|
+
status: res.status,
|
|
46
|
+
body,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return res.json();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 파일의 상위 메타 (페이지 목록만 — 노드 트리는 안 가져옴). */
|
|
53
|
+
export async function fetchFileSummary({ fileKey, effective }) {
|
|
54
|
+
const token = tokenFromEnv(effective);
|
|
55
|
+
const data = await call(`/files/${fileKey}?depth=1`, { token });
|
|
56
|
+
return {
|
|
57
|
+
fileKey,
|
|
58
|
+
name: data.name,
|
|
59
|
+
lastModified: data.lastModified,
|
|
60
|
+
pages:
|
|
61
|
+
data.document?.children?.map((c) => ({
|
|
62
|
+
id: c.id,
|
|
63
|
+
name: c.name,
|
|
64
|
+
type: c.type,
|
|
65
|
+
})) ?? [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 특정 노드들의 상세 트리를 가져옴 (가장 자주 쓰는 API). */
|
|
70
|
+
export async function fetchNodes({ fileKey, nodeIds, effective, depth }) {
|
|
71
|
+
const token = tokenFromEnv(effective);
|
|
72
|
+
const ids = (Array.isArray(nodeIds) ? nodeIds : [nodeIds])
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map(encodeURIComponent)
|
|
75
|
+
.join(',');
|
|
76
|
+
const depthQ = depth ? `&depth=${depth}` : '';
|
|
77
|
+
const data = await call(`/files/${fileKey}/nodes?ids=${ids}${depthQ}`, { token });
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 노드를 이미지(PNG/JPG/SVG)로 export 하는 임시 URL 을 받아옴 */
|
|
82
|
+
export async function fetchImageUrls({ fileKey, nodeIds, format = 'png', scale = 2, effective }) {
|
|
83
|
+
const token = tokenFromEnv(effective);
|
|
84
|
+
const ids = nodeIds.map(encodeURIComponent).join(',');
|
|
85
|
+
const data = await call(
|
|
86
|
+
`/images/${fileKey}?ids=${ids}&format=${format}&scale=${scale}`,
|
|
87
|
+
{ token },
|
|
88
|
+
);
|
|
89
|
+
// 응답: { images: { "1:2": "https://...", ... } }
|
|
90
|
+
return data.images ?? {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 파일에 정의된 로컬 스타일 (color/typography/effect/grid) */
|
|
94
|
+
export async function fetchStyles({ fileKey, effective }) {
|
|
95
|
+
const token = tokenFromEnv(effective);
|
|
96
|
+
const data = await call(`/files/${fileKey}/styles`, { token });
|
|
97
|
+
return data.meta?.styles ?? data.styles ?? [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** URL 한 줄로 시작하는 헬퍼 — chat 의 툴에서 가장 흔히 쓰임. */
|
|
101
|
+
export async function fetchFromUrl({ url, effective, depth }) {
|
|
102
|
+
const parsed = parseFigmaUrl(url);
|
|
103
|
+
if (!parsed) {
|
|
104
|
+
throw new FigmaError(`Figma URL 형식이 아닙니다: ${url}`);
|
|
105
|
+
}
|
|
106
|
+
if (!parsed.nodeId) {
|
|
107
|
+
// node-id 없으면 파일 요약만
|
|
108
|
+
return { kind: 'file', summary: await fetchFileSummary({ fileKey: parsed.fileKey, effective }) };
|
|
109
|
+
}
|
|
110
|
+
const data = await fetchNodes({
|
|
111
|
+
fileKey: parsed.fileKey,
|
|
112
|
+
nodeIds: [parsed.nodeId],
|
|
113
|
+
effective,
|
|
114
|
+
depth,
|
|
115
|
+
});
|
|
116
|
+
return { kind: 'nodes', fileKey: parsed.fileKey, nodeId: parsed.nodeId, raw: data };
|
|
117
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma 노드 트리를 LLM 이 읽기 좋게 압축한다.
|
|
3
|
+
*
|
|
4
|
+
* Figma 원 응답은 노드 하나에 수십 KB 도 흔하다. 그대로 모델에 넣으면 토큰이 폭발하고
|
|
5
|
+
* 모델도 중요한 게 뭔지 못 찾는다. 다음 정보만 남긴다:
|
|
6
|
+
* - 이름 (= 디자이너의 의도. 컴포넌트/페이지 명명 규칙)
|
|
7
|
+
* - 타입 (FRAME, COMPONENT, INSTANCE, TEXT, RECTANGLE, ...)
|
|
8
|
+
* - 위치/크기 (필요한 경우만)
|
|
9
|
+
* - autoLayout (있으면 flex/gap 변환에 핵심)
|
|
10
|
+
* - fills (색)
|
|
11
|
+
* - strokes
|
|
12
|
+
* - effects (shadow)
|
|
13
|
+
* - text 의 경우 글자 + 폰트 사양
|
|
14
|
+
* - cornerRadius, padding 같은 자주 쓰는 박스 속성
|
|
15
|
+
* - 자식들 (재귀)
|
|
16
|
+
*
|
|
17
|
+
* 모델은 이 압축본을 받아서 React/Tailwind/styled JSX 를 생성한다.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAX_CHILDREN = 60;
|
|
21
|
+
const MAX_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
function pickColor(paint) {
|
|
24
|
+
if (!paint || paint.visible === false) return null;
|
|
25
|
+
if (paint.type === 'SOLID' && paint.color) {
|
|
26
|
+
const { r, g, b } = paint.color;
|
|
27
|
+
const a = paint.opacity ?? paint.color.a ?? 1;
|
|
28
|
+
return {
|
|
29
|
+
type: 'solid',
|
|
30
|
+
rgba: `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${Number(a.toFixed(3))})`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (paint.type?.startsWith('GRADIENT')) {
|
|
34
|
+
return {
|
|
35
|
+
type: 'gradient',
|
|
36
|
+
kind: paint.type,
|
|
37
|
+
stops:
|
|
38
|
+
paint.gradientStops?.map((s) => ({
|
|
39
|
+
position: s.position,
|
|
40
|
+
rgba:
|
|
41
|
+
s.color &&
|
|
42
|
+
`rgba(${Math.round(s.color.r * 255)}, ${Math.round(s.color.g * 255)}, ${Math.round(s.color.b * 255)}, ${Number((s.color.a ?? 1).toFixed(3))})`,
|
|
43
|
+
})) ?? [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (paint.type === 'IMAGE') {
|
|
47
|
+
return { type: 'image', scaleMode: paint.scaleMode };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function describeAutoLayout(node) {
|
|
53
|
+
if (!node.layoutMode || node.layoutMode === 'NONE') return null;
|
|
54
|
+
return {
|
|
55
|
+
direction: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column',
|
|
56
|
+
gap: node.itemSpacing ?? 0,
|
|
57
|
+
padding: {
|
|
58
|
+
top: node.paddingTop ?? 0,
|
|
59
|
+
right: node.paddingRight ?? 0,
|
|
60
|
+
bottom: node.paddingBottom ?? 0,
|
|
61
|
+
left: node.paddingLeft ?? 0,
|
|
62
|
+
},
|
|
63
|
+
alignItems: node.counterAxisAlignItems,
|
|
64
|
+
justifyContent: node.primaryAxisAlignItems,
|
|
65
|
+
wrap: node.layoutWrap === 'WRAP',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function describeText(node) {
|
|
70
|
+
if (node.type !== 'TEXT') return null;
|
|
71
|
+
const s = node.style ?? {};
|
|
72
|
+
return {
|
|
73
|
+
characters: node.characters ?? '',
|
|
74
|
+
fontFamily: s.fontFamily,
|
|
75
|
+
fontSize: s.fontSize,
|
|
76
|
+
fontWeight: s.fontWeight,
|
|
77
|
+
lineHeight: s.lineHeightPx,
|
|
78
|
+
letterSpacing: s.letterSpacing,
|
|
79
|
+
textAlign: s.textAlignHorizontal?.toLowerCase(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function simplifyNode(node, depth = 0) {
|
|
84
|
+
if (!node) return null;
|
|
85
|
+
const out = {
|
|
86
|
+
id: node.id,
|
|
87
|
+
name: node.name,
|
|
88
|
+
type: node.type,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (node.absoluteBoundingBox) {
|
|
92
|
+
out.size = {
|
|
93
|
+
w: Math.round(node.absoluteBoundingBox.width),
|
|
94
|
+
h: Math.round(node.absoluteBoundingBox.height),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const auto = describeAutoLayout(node);
|
|
99
|
+
if (auto) out.autoLayout = auto;
|
|
100
|
+
|
|
101
|
+
if (node.fills?.length) {
|
|
102
|
+
const fills = node.fills.map(pickColor).filter(Boolean);
|
|
103
|
+
if (fills.length) out.fills = fills;
|
|
104
|
+
}
|
|
105
|
+
if (node.strokes?.length) {
|
|
106
|
+
const strokes = node.strokes.map(pickColor).filter(Boolean);
|
|
107
|
+
if (strokes.length) {
|
|
108
|
+
out.strokes = strokes;
|
|
109
|
+
out.strokeWeight = node.strokeWeight;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (node.cornerRadius != null) out.cornerRadius = node.cornerRadius;
|
|
113
|
+
if (node.rectangleCornerRadii) out.cornerRadii = node.rectangleCornerRadii;
|
|
114
|
+
if (node.effects?.length) {
|
|
115
|
+
out.effects = node.effects.map((e) => ({
|
|
116
|
+
type: e.type,
|
|
117
|
+
radius: e.radius,
|
|
118
|
+
offset: e.offset,
|
|
119
|
+
color: e.color &&
|
|
120
|
+
`rgba(${Math.round(e.color.r * 255)}, ${Math.round(e.color.g * 255)}, ${Math.round(e.color.b * 255)}, ${Number((e.color.a ?? 1).toFixed(3))})`,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
if (node.opacity != null && node.opacity < 1) out.opacity = node.opacity;
|
|
124
|
+
|
|
125
|
+
const text = describeText(node);
|
|
126
|
+
if (text) out.text = text;
|
|
127
|
+
|
|
128
|
+
// Component / Instance — 디자인 시스템의 신호. AI 가 재사용 결정하는 단서.
|
|
129
|
+
if (node.type === 'INSTANCE' && node.componentId) {
|
|
130
|
+
out.componentRef = node.componentId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (node.children?.length && depth < MAX_DEPTH) {
|
|
134
|
+
const kids = node.children.slice(0, MAX_CHILDREN);
|
|
135
|
+
const truncated = node.children.length > MAX_CHILDREN;
|
|
136
|
+
out.children = kids
|
|
137
|
+
.map((c) => simplifyNode(c, depth + 1))
|
|
138
|
+
.filter(Boolean);
|
|
139
|
+
if (truncated) out.childrenTruncated = node.children.length - MAX_CHILDREN;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Figma `fetchNodes` 응답을 받아서 LLM 친화적으로 압축.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} fetchNodesResponse - Figma API `/v1/files/.../nodes` 결과
|
|
149
|
+
* @returns {{ documents: Array<simpleNode>, components: Record, styles: Record }}
|
|
150
|
+
*/
|
|
151
|
+
export function simplifyFetchNodes(fetchNodesResponse) {
|
|
152
|
+
const out = { documents: [], components: {}, styles: {} };
|
|
153
|
+
const nodes = fetchNodesResponse?.nodes ?? {};
|
|
154
|
+
for (const [id, payload] of Object.entries(nodes)) {
|
|
155
|
+
if (!payload?.document) continue;
|
|
156
|
+
out.documents.push({
|
|
157
|
+
requestedId: id,
|
|
158
|
+
...simplifyNode(payload.document, 0),
|
|
159
|
+
});
|
|
160
|
+
if (payload.components) {
|
|
161
|
+
Object.assign(out.components, payload.components);
|
|
162
|
+
}
|
|
163
|
+
if (payload.styles) {
|
|
164
|
+
Object.assign(out.styles, payload.styles);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 사람 눈으로 보기 좋은 한 줄 요약 (디버깅/UI 표시용) */
|
|
171
|
+
export function quickSummary(simple) {
|
|
172
|
+
if (!simple) return '';
|
|
173
|
+
const parts = [simple.name + ' [' + simple.type + ']'];
|
|
174
|
+
if (simple.size) parts.push(`${simple.size.w}×${simple.size.h}`);
|
|
175
|
+
if (simple.autoLayout) parts.push('auto-' + simple.autoLayout.direction);
|
|
176
|
+
if (simple.children?.length) parts.push(`children=${simple.children.length}`);
|
|
177
|
+
return parts.join(' · ');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { simplifyNode };
|
package/src/figma/url.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma URL/링크 파서.
|
|
3
|
+
*
|
|
4
|
+
* 지원하는 URL 형태:
|
|
5
|
+
* https://www.figma.com/file/{fileKey}/... (구버전 share link)
|
|
6
|
+
* https://www.figma.com/design/{fileKey}/... (신버전, 2023+)
|
|
7
|
+
* https://www.figma.com/proto/{fileKey}/... (프로토타입)
|
|
8
|
+
* ...?node-id=123-456 또는 ...?node-id=123%3A456 (특정 노드)
|
|
9
|
+
*
|
|
10
|
+
* Figma 내부 노드 ID 는 "123:456" 인데 URL 에서는 보통 "123-456" 또는 인코딩됨.
|
|
11
|
+
* API 호출 시엔 "123:456" 으로 다시 변환해야 함.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const URL_RE = /figma\.com\/(?:file|design|proto)\/([A-Za-z0-9]+)/;
|
|
15
|
+
|
|
16
|
+
export function parseFigmaUrl(input) {
|
|
17
|
+
if (!input || typeof input !== 'string') return null;
|
|
18
|
+
const m = input.match(URL_RE);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
const fileKey = m[1];
|
|
21
|
+
|
|
22
|
+
// node-id 추출
|
|
23
|
+
let nodeId = null;
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(input);
|
|
26
|
+
const raw = u.searchParams.get('node-id');
|
|
27
|
+
if (raw) {
|
|
28
|
+
// "123-456" → "123:456", "123%3A456" → "123:456"
|
|
29
|
+
nodeId = decodeURIComponent(raw).replace(/-/g, ':');
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
/* not a valid URL — fileKey 만 있으면 그것대로 ok */
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { fileKey, nodeId };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 디자이너가 도면에서 "Copy link" 한 결과인지 (= node-id 있음) */
|
|
39
|
+
export function hasNode(parsed) {
|
|
40
|
+
return !!parsed?.nodeId;
|
|
41
|
+
}
|