Skip to content

Commit 576e8cf

Browse files
committed
release: v1.0.0
1 parent 61c4f03 commit 576e8cf

16 files changed

+3020
-0
lines changed

.eslintignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
bin/
3+
lib/
4+
dist/

.eslintrc.json

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"root": true,
3+
"env": {
4+
"node": true,
5+
"es2024": true
6+
},
7+
"extends": ["eslint:recommended"],
8+
"parserOptions": {
9+
"ecmaVersion": "latest",
10+
"sourceType": "module"
11+
}
12+
13+
}

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.DS_Store
2+
node_modules
3+
token.json

.npmrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
registry=https://registry.npmjs.org/

.prettierrc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"singleQuote": true
3+
}

README.md

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
### 使用场景
2+
3+
UI 把项目切图单独放在 Figma 文件某个 node 下,可以理解成切图单独放一块,该脚本通过 file key 和 node id 匹配并解析内容后批量
4+
下载到本地,图片以对应的 node 名称命名。目前默认解析为切图的 node 类型是["FRAME", "COMPONENT"],可通过 figmaImgTypes 参数修改。
5+
6+
注意:由于存在即使是完全相同的 Node 节点,每次获取到的图片 url 也不同的限制,所以无法通过判断 url 来更新已下载的本地图片。
7+
如果想重复下载或者更新图片,请删除自动生成的 metadata.json 文件。
8+
9+
### 从 figma 获取 token
10+
11+
[token 如何获取?](https://www.figma.com/developers/api#access-tokens)
12+
13+
### 安装
14+
15+
```bash
16+
npm install fig-save -D
17+
18+
```
19+
20+
### 使用
21+
22+
```js
23+
import { saveImgs } from 'fig-save';
24+
// 或者
25+
import saveImgs from 'fig-save';
26+
27+
// https://www.figma.com/file/:key/:title?node-id=:id
28+
saveImgs(key, id, options);
29+
```
30+
31+
### Options
32+
33+
| 名字 | 默认 | 描述 |
34+
| :-----------: | :--------------------: | :----------------------------: |
35+
| scale | 1 | 在 0.01 到 4 之间 |
36+
| format | png | jpg, png, svg, or pdf |
37+
| saveDir | process.cwd()下的 imgs | 图片保存目录 |
38+
| concurrency | 5 | 并发下载数量 |
39+
| figmaImgTypes | ["FRAME", "COMPONENT"] | Figma 节点转图片下载的类型范围 |
40+
41+
[其他图片参数,详见官方文档](https://www.figma.com/developers/api#get-images-endpoint)

dist/index.js

+304
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import path from 'path';
2+
import picocolors from 'picocolors';
3+
import { merge } from 'webpack-merge';
4+
import { mkdirsSync, pathExistsSync, readJsonSync, outputJsonSync, removeSync } from 'fs-extra/esm';
5+
import fs from 'fs';
6+
import ora from 'ora';
7+
import cliProgress from 'cli-progress';
8+
import https from 'https';
9+
import * as Figma from 'figma-api';
10+
import findCacheDirectory from 'find-cache-dir';
11+
import inquirer from 'inquirer';
12+
import { readPackage } from 'read-pkg';
13+
14+
const promisePool = async (functions, n) => {
15+
const concurrency = Math.min(n, functions.length);
16+
const replicatedFunctions = [...functions];
17+
const result = await Promise.all(
18+
Array(concurrency)
19+
.fill(0)
20+
.map(async () => {
21+
const result = [];
22+
while (replicatedFunctions.length) {
23+
try {
24+
const res = await replicatedFunctions.shift()();
25+
result.push({
26+
status: 'fulfilled',
27+
value: res,
28+
});
29+
} catch {
30+
result.push({
31+
status: 'rejected',
32+
});
33+
}
34+
}
35+
return result;
36+
}),
37+
);
38+
return result.flat();
39+
};
40+
const showLoading = async (promise, options) => {
41+
const spinner = ora(options).start();
42+
try {
43+
return await promise;
44+
} catch (e) {
45+
return Promise.reject(e);
46+
} finally {
47+
spinner.stop();
48+
}
49+
};
50+
51+
const useProgressBar = () => {
52+
const progressBar = new cliProgress.SingleBar({
53+
format: ' {bar} | {percentage}% | {value}/{total}',
54+
barCompleteChar: '\u2588',
55+
barIncompleteChar: '\u2591',
56+
stopOnComplete: true,
57+
clearOnComplete: true,
58+
});
59+
return { progressBar };
60+
};
61+
62+
const EVENTS = {
63+
DATA: 'data',
64+
END: 'end',
65+
ERROR: 'error',
66+
};
67+
const FIGMA_API_TYPE = {
68+
GET_FILE_NODES: 'getFileNodes',
69+
GET_IMAGE: 'getImage',
70+
};
71+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
72+
const CURRENT_ROOT = path.resolve(__dirname, '..');
73+
const DEFAULT_DOWNLOAD_OPTIONS = {
74+
format: 'png',
75+
scale: 1,
76+
figmaImgTypes: ['FRAME', 'COMPONENT'],
77+
concurrency: 5,
78+
saveDir: path.resolve(process.cwd(), './imgs'),
79+
};
80+
const TOKEN_FILE_NAME = 'token.json';
81+
const METADATA_FILE_NAME = 'metadata.json';
82+
83+
const useDownload = ({ id, url, saveDir, filename, onComplete }) =>
84+
new Promise((resolve, reject) => {
85+
https.get(url, (res) => {
86+
mkdirsSync(saveDir);
87+
const fileStream = fs.createWriteStream(`${saveDir}/${filename}`);
88+
res.pipe(fileStream);
89+
res.on(EVENTS.END, () => {
90+
onComplete();
91+
resolve(id);
92+
});
93+
res.on(EVENTS.ERROR, () => {
94+
reject();
95+
});
96+
});
97+
});
98+
99+
let figmaApi$1 = null;
100+
const getTokenFilePath = async () => {
101+
const { name } = await readPackage({ cwd: CURRENT_ROOT });
102+
const thunk = findCacheDirectory({ name, create: true, thunk: true });
103+
return thunk(TOKEN_FILE_NAME);
104+
};
105+
const getToken = async () => {
106+
const tokenPath = await getTokenFilePath();
107+
try {
108+
const token = pathExistsSync(tokenPath) && readJsonSync(tokenPath);
109+
if (!token) throw new Error();
110+
return token;
111+
} catch (e) {
112+
const { token } = await inquirer.prompt({
113+
type: 'input',
114+
name: 'token',
115+
message: 'Please Enter Your Figma Token',
116+
});
117+
outputJsonSync(tokenPath, token);
118+
return token;
119+
}
120+
};
121+
const deleteToken = async () => {
122+
const tokenPath = await getTokenFilePath();
123+
removeSync(tokenPath);
124+
};
125+
const handleError = (err) => {
126+
if (err.isAxiosError) {
127+
const resData = err?.response?.data || {};
128+
if (resData.status === 403) {
129+
deleteToken();
130+
}
131+
console.log(`\n${picocolors.red(resData.err)}`);
132+
}
133+
};
134+
const useFigmaApi = async () => {
135+
if (!figmaApi$1) {
136+
const token = await getToken();
137+
const _figmaAPi = new Figma.Api({
138+
personalAccessToken: token,
139+
});
140+
figmaApi$1 = async (type, ...rest) => {
141+
try {
142+
return await _figmaAPi[type](...rest);
143+
} catch (e) {
144+
handleError(e);
145+
return Promise.reject(e);
146+
}
147+
};
148+
}
149+
return figmaApi$1;
150+
};
151+
152+
let figmaApi;
153+
let mergedOptions = {};
154+
const getMetadataFilePath = () =>
155+
path.join(mergedOptions.saveDir, METADATA_FILE_NAME);
156+
const getImgUrlsInfo = async (key, id) => {
157+
const fileInfo = await showLoading(
158+
figmaApi(FIGMA_API_TYPE.GET_FILE_NODES, key, [id]),
159+
{
160+
suffixText: '🐢',
161+
text: 'Waiting for Figma to parse the file...',
162+
},
163+
);
164+
const children = fileInfo.nodes[id].document.children;
165+
const imgNamesMap = children
166+
.filter((child) => mergedOptions.figmaImgTypes.includes(child.type))
167+
.reduce((acc, cur) => {
168+
acc[cur.id] = cur.name;
169+
return acc;
170+
}, {});
171+
const { err, images: imgUrls } = await showLoading(
172+
figmaApi(FIGMA_API_TYPE.GET_IMAGE, key, {
173+
ids: Object.keys(imgNamesMap),
174+
...mergedOptions,
175+
}),
176+
{
177+
suffixText: '🐰',
178+
text: 'Waiting for Figma to match the images...',
179+
},
180+
);
181+
if (err) {
182+
throw err;
183+
}
184+
const imgUrlsInfo = Object.keys(imgUrls).reduce((acc, id) => {
185+
acc[id] = {
186+
url: imgUrls[id],
187+
name: imgNamesMap[id],
188+
};
189+
return acc;
190+
}, {});
191+
return imgUrlsInfo;
192+
};
193+
const handleSucceedResult = (succeedCollections, totalImgUrlsInfo) => {
194+
const metadata = succeedCollections.reduce((acc, item) => {
195+
acc[item.value] = {
196+
shouldDownload: false,
197+
url: totalImgUrlsInfo[item.value].url,
198+
name: totalImgUrlsInfo[item.value].name,
199+
};
200+
return acc;
201+
}, {});
202+
const metadataFilePath = getMetadataFilePath();
203+
const existedMetadata =
204+
(pathExistsSync(metadataFilePath) && readJsonSync(metadataFilePath)) || {};
205+
const mergedMetadata = merge(existedMetadata, metadata);
206+
outputJsonSync(metadataFilePath, mergedMetadata);
207+
};
208+
const handleFailedResult = (succeedCollections, totalImgUrlsInfo) => {
209+
const succeedIdsMap = succeedCollections.reduce((acc, item) => {
210+
acc[item.value] = true;
211+
return acc;
212+
}, {});
213+
const failedCollections = Object.keys(totalImgUrlsInfo)
214+
.filter((id) => !succeedIdsMap[id])
215+
.map((id) => ({
216+
url: totalImgUrlsInfo[id].url,
217+
name: totalImgUrlsInfo[id].name,
218+
}));
219+
if (!failedCollections.length) {
220+
return;
221+
}
222+
console.log(
223+
picocolors.bold(
224+
picocolors.white(
225+
`Something Failed.Perhaps you can download it manually at the following URLs 🔧`,
226+
),
227+
),
228+
);
229+
failedCollections.forEach((info) => {
230+
console.log(
231+
picocolors.bold(picocolors.red(`${info.name}`)),
232+
'🔗🔗🔗',
233+
picocolors.red(`${info.url}`),
234+
);
235+
});
236+
};
237+
const handleResult = (result, totalImgUrlsInfo) => {
238+
const succeedCollections = result.filter(
239+
(item) => item.status === 'fulfilled',
240+
);
241+
handleSucceedResult(succeedCollections, totalImgUrlsInfo);
242+
handleFailedResult(succeedCollections, totalImgUrlsInfo);
243+
};
244+
const shouldDownload = ([id]) => {
245+
const metadata =
246+
(pathExistsSync(getMetadataFilePath()) &&
247+
readJsonSync(getMetadataFilePath())) ||
248+
{};
249+
const curInfo = metadata[id];
250+
if (!curInfo) {
251+
return true;
252+
}
253+
const saveDir = mergedOptions.saveDir;
254+
const filename = `${curInfo.name}.${mergedOptions.format}`;
255+
const filePath = `${saveDir}/${filename}`;
256+
if (!pathExistsSync(filePath) && shouldDownload) {
257+
return true;
258+
}
259+
return false;
260+
};
261+
const saveImgs = async (key, id, options = {}) => {
262+
key = decodeURIComponent(key);
263+
id = decodeURIComponent(id);
264+
try {
265+
mergedOptions = merge(DEFAULT_DOWNLOAD_OPTIONS, options);
266+
figmaApi = await useFigmaApi();
267+
const imgUrlsInfo = await getImgUrlsInfo(key, id);
268+
const shouldDownloadImgUrls =
269+
Object.entries(imgUrlsInfo).filter(shouldDownload);
270+
if (!shouldDownloadImgUrls.length) {
271+
console.log('\n' + picocolors.green('Done🎉'));
272+
return;
273+
}
274+
const { progressBar } = useProgressBar(shouldDownloadImgUrls.length);
275+
progressBar.start(shouldDownloadImgUrls.length, 0);
276+
const promises = shouldDownloadImgUrls.map(([id, { url, name }]) => () => {
277+
const saveDir = mergedOptions.saveDir;
278+
const filename = `${name}.${mergedOptions.format}`;
279+
return useDownload({
280+
id,
281+
url,
282+
saveDir,
283+
filename,
284+
onComplete: progressBar.increment.bind(progressBar),
285+
});
286+
});
287+
const result = await promisePool(promises, mergedOptions.concurrency);
288+
const shouldDownloadImgUrlsInfo = shouldDownloadImgUrls.reduce(
289+
(acc, [id, value]) => {
290+
acc[id] = value;
291+
return acc;
292+
},
293+
{},
294+
);
295+
handleResult(result, shouldDownloadImgUrlsInfo);
296+
console.log('\n' + picocolors.green('Done🎉'));
297+
} catch (err) {
298+
if (!err.isAxiosError) {
299+
console.log(err);
300+
}
301+
}
302+
};
303+
304+
export { saveImgs as default, saveImgs };

0 commit comments

Comments
 (0)