@charcoal-ui/icons-cli 5.9.0-beta.0 → 5.9.0-beta.1

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.
@@ -0,0 +1,218 @@
1
+ import path from 'node:path'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import fs from 'fs-extra'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { FigmaFileClient } from './FigmaFileClient'
7
+
8
+ const { mockFile, mockFileImages, mockGotGet } = vi.hoisted(() => {
9
+ return {
10
+ mockFile: vi.fn(),
11
+ mockFileImages: vi.fn(),
12
+ mockGotGet: vi.fn(),
13
+ }
14
+ })
15
+
16
+ vi.mock('figma-js', () => {
17
+ return {
18
+ Client: vi.fn(() => ({
19
+ file: mockFile,
20
+ fileImages: mockFileImages,
21
+ })),
22
+ }
23
+ })
24
+
25
+ vi.mock('got', () => {
26
+ return {
27
+ default: {
28
+ get: mockGotGet,
29
+ },
30
+ }
31
+ })
32
+
33
+ function createV2FileResponse({
34
+ setName,
35
+ componentName,
36
+ }: {
37
+ setName: string
38
+ componentName: string
39
+ }) {
40
+ return {
41
+ data: {
42
+ document: {
43
+ children: [
44
+ {
45
+ type: 'CANVAS',
46
+ name: 'Icons 一覧',
47
+ children: [
48
+ {
49
+ type: 'FRAME',
50
+ name: 'Frame',
51
+ children: [
52
+ {
53
+ type: 'COMPONENT_SET',
54
+ id: 'set-1',
55
+ name: setName,
56
+ children: [
57
+ {
58
+ type: 'COMPONENT',
59
+ id: 'component-1',
60
+ name: componentName,
61
+ },
62
+ ],
63
+ },
64
+ ],
65
+ },
66
+ ],
67
+ },
68
+ ],
69
+ },
70
+ },
71
+ }
72
+ }
73
+
74
+ describe('FigmaFileClient path traversal regression', () => {
75
+ let workDir: string
76
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>
77
+
78
+ beforeEach(async () => {
79
+ workDir = await fs.mkdtemp(path.join(tmpdir(), 'icons-cli-'))
80
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
81
+ await fs.remove(path.join(tmpdir(), 'escaped'))
82
+
83
+ mockGotGet.mockResolvedValue({
84
+ body: '<svg><path /></svg>',
85
+ })
86
+ })
87
+
88
+ afterEach(async () => {
89
+ consoleLogSpy.mockRestore()
90
+ vi.clearAllMocks()
91
+ await fs.remove(path.join(tmpdir(), 'escaped'))
92
+ await fs.remove(workDir)
93
+ })
94
+
95
+ it('v2 variant に ../ を含んでも OUTPUT_ROOT_DIR の外へ書き込まない', async () => {
96
+ mockFile.mockResolvedValue(
97
+ createV2FileResponse({
98
+ setName: 'alert',
99
+ componentName: 'size=../../escaped,color=default',
100
+ }),
101
+ )
102
+ mockFileImages.mockResolvedValue({
103
+ data: {
104
+ images: {
105
+ 'component-1': 'https://example.com/icon.svg',
106
+ },
107
+ },
108
+ })
109
+
110
+ const outputRootDir = path.join(workDir, 'out')
111
+ const outsidePath = path.join(tmpdir(), 'escaped', 'default', 'alert.svg')
112
+
113
+ const client = new FigmaFileClient(
114
+ 'https://www.figma.com/file/FILE_ID/Test',
115
+ 'token',
116
+ 'svg',
117
+ 'v2',
118
+ )
119
+
120
+ await client.loadSvg(outputRootDir).catch((error) => error)
121
+
122
+ await expect(fs.pathExists(outsidePath)).resolves.toBe(false)
123
+ })
124
+
125
+ it('component set name に ../ を含んでも OUTPUT_ROOT_DIR の外へ書き込まない', async () => {
126
+ mockFile.mockResolvedValue(
127
+ createV2FileResponse({
128
+ setName: '../../pwned',
129
+ componentName: 'size=16',
130
+ }),
131
+ )
132
+ mockFileImages.mockResolvedValue({
133
+ data: {
134
+ images: {
135
+ 'component-1': 'https://example.com/icon.svg',
136
+ },
137
+ },
138
+ })
139
+
140
+ const outputRootDir = path.join(workDir, 'out')
141
+ const outsidePath = path.join(workDir, 'pwned.svg')
142
+
143
+ const client = new FigmaFileClient(
144
+ 'https://www.figma.com/file/FILE_ID/Test',
145
+ 'token',
146
+ 'svg',
147
+ 'v2',
148
+ )
149
+
150
+ await client.loadSvg(outputRootDir).catch((error) => error)
151
+
152
+ await expect(fs.pathExists(outsidePath)).resolves.toBe(false)
153
+ })
154
+
155
+ it('正常な v2 名称なら想定ディレクトリに export される', async () => {
156
+ mockFile.mockResolvedValue(
157
+ createV2FileResponse({
158
+ setName: 'alert',
159
+ componentName: 'size=16,color=default',
160
+ }),
161
+ )
162
+ mockFileImages.mockResolvedValue({
163
+ data: {
164
+ images: {
165
+ 'component-1': 'https://example.com/icon.svg',
166
+ },
167
+ },
168
+ })
169
+
170
+ const outputRootDir = path.join(workDir, 'out')
171
+ const outputFile = path.join(outputRootDir, '16', 'default', 'alert.svg')
172
+
173
+ const client = new FigmaFileClient(
174
+ 'https://www.figma.com/file/FILE_ID/Test',
175
+ 'token',
176
+ 'svg',
177
+ 'v2',
178
+ )
179
+
180
+ await client.loadSvg(outputRootDir)
181
+
182
+ await expect(fs.pathExists(outputFile)).resolves.toBe(true)
183
+ await expect(readFile(outputFile, 'utf8')).resolves.toBe(
184
+ '<svg><path /></svg>',
185
+ )
186
+ })
187
+
188
+ it('/design から始まる URL でも fileId を取得できる', async () => {
189
+ mockFile.mockResolvedValue(
190
+ createV2FileResponse({
191
+ setName: 'alert',
192
+ componentName: 'size=16,color=default',
193
+ }),
194
+ )
195
+ mockFileImages.mockResolvedValue({
196
+ data: {
197
+ images: {
198
+ 'component-1': 'https://example.com/icon.svg',
199
+ },
200
+ },
201
+ })
202
+
203
+ const outputRootDir = path.join(workDir, 'out')
204
+ const outputFile = path.join(outputRootDir, '16', 'default', 'alert.svg')
205
+
206
+ const client = new FigmaFileClient(
207
+ 'https://www.figma.com/design/FILE_ID/Test?node-id=1-2',
208
+ 'token',
209
+ 'svg',
210
+ 'v2',
211
+ )
212
+
213
+ await client.loadSvg(outputRootDir)
214
+
215
+ expect(mockFile).toHaveBeenCalledWith('FILE_ID')
216
+ await expect(fs.pathExists(outputFile)).resolves.toBe(true)
217
+ })
218
+ })
@@ -9,7 +9,10 @@ import { concurrently } from '../concurrently'
9
9
 
10
10
  const DRY_RUN = Boolean(process.env.DRY_RUN)
11
11
 
12
- const matchPath = match<{ fileId: string; name: string }>('/file/:fileId/:name')
12
+ const matchPath = match<{ fileId: string; name: string }>([
13
+ '/file/:fileId/:name',
14
+ '/design/:fileId/:name',
15
+ ])
13
16
 
14
17
  function extractParams(url: string): { fileId: string; nodeId?: string } {
15
18
  const { pathname, searchParams } = new URL(url)
@@ -43,8 +46,26 @@ function parseV2IconName(name: string) {
43
46
  .toLowerCase()
44
47
  }
45
48
 
49
+ function resolveOutputPath(outputDir: string, filename: string) {
50
+ const normalizedOutputDir = path.resolve(outputDir)
51
+ const fullname = path.resolve(normalizedOutputDir, filename)
52
+ const relativePath = path.relative(normalizedOutputDir, fullname)
53
+
54
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
55
+ return null
56
+ }
57
+
58
+ return fullname
59
+ }
60
+
46
61
  type ExportFormat = 'svg' | 'pdf'
47
62
 
63
+ function sleep(ms: number) {
64
+ return new Promise((resolve) => {
65
+ setTimeout(resolve, ms)
66
+ })
67
+ }
68
+
48
69
  interface Component {
49
70
  id: string
50
71
  name: string
@@ -60,8 +81,10 @@ export class FigmaFileClient {
60
81
  private readonly exportFormat: ExportFormat
61
82
  private readonly client: Figma.ClientInterface
62
83
  private readonly layoutVersion: FigmaIconFileLayoutVersion
84
+ private readonly requestSleepMs?: number
63
85
 
64
86
  private components: Record<string, Component> = {}
87
+ private hasRequested = false
65
88
 
66
89
  static async runFromCli(
67
90
  url: string,
@@ -69,8 +92,15 @@ export class FigmaFileClient {
69
92
  outputRootDir: string,
70
93
  exportFormat: ExportFormat,
71
94
  layoutVersion: FigmaIconFileLayoutVersion = 'v1',
95
+ requestSleepMs?: number,
72
96
  ) {
73
- const client = new this(url, token, exportFormat, layoutVersion)
97
+ const client = new this(
98
+ url,
99
+ token,
100
+ exportFormat,
101
+ layoutVersion,
102
+ requestSleepMs,
103
+ )
74
104
 
75
105
  const outputDir = path.join(process.cwd(), outputRootDir, exportFormat)
76
106
 
@@ -87,6 +117,7 @@ export class FigmaFileClient {
87
117
  personalAccessToken: string,
88
118
  exportFormat: ExportFormat,
89
119
  layoutVersion: FigmaIconFileLayoutVersion,
120
+ requestSleepMs?: number,
90
121
  ) {
91
122
  this.client = Figma.Client({
92
123
  personalAccessToken,
@@ -98,6 +129,7 @@ export class FigmaFileClient {
98
129
 
99
130
  this.exportFormat = exportFormat
100
131
  this.layoutVersion = layoutVersion
132
+ this.requestSleepMs = requestSleepMs
101
133
  }
102
134
 
103
135
  async loadSvg(outputDir: string) {
@@ -140,6 +172,7 @@ export class FigmaFileClient {
140
172
  private async loadImageUrls() {
141
173
  console.log('Getting export urls')
142
174
 
175
+ await this.sleepBeforeRequest()
143
176
  const { data } = await this.client.fileImages(this.fileId, {
144
177
  format: this.exportFormat,
145
178
  ids: Object.keys(this.components),
@@ -153,46 +186,82 @@ export class FigmaFileClient {
153
186
  }
154
187
 
155
188
  private async downloadImages(outputDir: string) {
156
- return concurrently(
157
- Object.values(this.components).map((component) => async () => {
158
- if (component.image === undefined) {
159
- return
160
- }
189
+ const components = Object.values(this.components)
190
+ const shouldSleepBetweenRequests = (this.requestSleepMs ?? 0) > 0
161
191
 
162
- const filename = component.variant
163
- ? `${parseV2IconName(component.variant)}/${component.name}.${
164
- this.exportFormat
165
- }`
166
- : `${filenamify(component.name)}.${this.exportFormat}`
167
- const fullname = path.join(outputDir, filename)
168
- const dirname = path.dirname(fullname)
169
-
170
- if (DRY_RUN) {
171
- console.log(`[DRY_RUN] skip: ${filename} => ✅ writing...`)
172
- return
173
- }
192
+ const downloadImage = async (component: Component) => {
193
+ if (component.image === undefined) {
194
+ return
195
+ }
196
+
197
+ const filename = component.variant
198
+ ? `${parseV2IconName(component.variant)}/${component.name}.${this.exportFormat}`
199
+ : `${filenamify(component.name)}.${this.exportFormat}`
200
+ const fullname = resolveOutputPath(outputDir, filename)
174
201
 
175
- const response = await got.get(
176
- component.image,
177
- this.exportFormat == 'pdf' ? { responseType: 'buffer' } : {},
178
- )
202
+ if (fullname === null) {
203
+ console.log(`skip invalid output path: ${filename}`)
204
+ return
205
+ }
206
+
207
+ const dirname = path.dirname(fullname)
179
208
 
180
- await ensureDir(dirname)
209
+ if (DRY_RUN) {
210
+ console.log(`[DRY_RUN] skip: ${filename} => ✅ writing...`)
211
+ return
212
+ }
181
213
 
182
- console.log(`found: ${filename} => ✅ writing...`)
183
- await writeFile(fullname, response.body, 'utf8')
184
- }),
214
+ await this.sleepBeforeRequest()
215
+ const response = await got.get(
216
+ component.image,
217
+ this.exportFormat == 'pdf' ? { responseType: 'buffer' } : {},
218
+ )
219
+
220
+ await ensureDir(dirname)
221
+
222
+ console.log(`found: ${filename} => ✅ writing...`)
223
+ await writeFile(fullname, response.body, 'utf8')
224
+ }
225
+
226
+ // sleep指定時は直列実行
227
+ if (shouldSleepBetweenRequests) {
228
+ for (const component of components) {
229
+ await downloadImage(component)
230
+ }
231
+
232
+ return
233
+ }
234
+
235
+ // sleep未指定時は並列実行
236
+ return concurrently(
237
+ components.map((component) => async () => downloadImage(component)),
185
238
  )
186
239
  }
187
240
 
188
241
  private async getFile() {
189
242
  console.log('Processing response')
190
243
 
244
+ await this.sleepBeforeRequest()
191
245
  const { data } = await this.client.file(this.fileId.toString())
192
246
 
193
247
  return data
194
248
  }
195
249
 
250
+ private async sleepBeforeRequest() {
251
+ const requestSleepMs = this.requestSleepMs ?? 0
252
+
253
+ if (requestSleepMs <= 0) {
254
+ return
255
+ }
256
+
257
+ if (this.hasRequested) {
258
+ console.log(`Sleeping ${requestSleepMs}ms before next export request`)
259
+ await sleep(requestSleepMs)
260
+ }
261
+
262
+ this.hasRequested = true
263
+ }
264
+
196
265
  private findComponentsRecursively(child: Figma.Node) {
197
266
  if (child.type === 'COMPONENT') {
198
267
  const { name, id } = child
@@ -0,0 +1,68 @@
1
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import vm from 'node:vm'
5
+ import fs from 'fs-extra'
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
+ import { generateIconSource } from './generateSource'
8
+
9
+ async function importEsmFromSource(source: string) {
10
+ return import(
11
+ `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`
12
+ )
13
+ }
14
+
15
+ describe('generateSource regression', () => {
16
+ let workDir: string
17
+
18
+ beforeEach(async () => {
19
+ workDir = await mkdtemp(path.join(tmpdir(), 'icons-cli-generate-source-'))
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await fs.remove(workDir)
24
+ })
25
+
26
+ it('クォートやバックスラッシュを含むSVGでも埋め込みモジュールが壊れない', async () => {
27
+ const outputDir = path.join(workDir, 'icons')
28
+ const svgRoot = path.join(outputDir, 'svg', '16')
29
+ const svgContent = `<svg><text>'"\\"</text></svg>\n`
30
+
31
+ await fs.ensureDir(svgRoot)
32
+ await writeFile(path.join(svgRoot, 'Add.svg'), svgContent)
33
+
34
+ await generateIconSource(outputDir)
35
+
36
+ const generatedModule = await readFile(
37
+ path.join(outputDir, 'src', '16', 'Add.js'),
38
+ 'utf8',
39
+ )
40
+ const imported = await importEsmFromSource(generatedModule)
41
+
42
+ expect(imported.default).toBe(`<svg><text>'"\\"</text></svg>`)
43
+ })
44
+
45
+ it('危険なキー名を含んでもCJSエントリポイントが壊れない', async () => {
46
+ const outputDir = path.join(workDir, 'icons')
47
+ const svgRoot = path.join(outputDir, 'svg', '16')
48
+ const iconName = String.raw`Bad'"Name\Icon`
49
+
50
+ await fs.ensureDir(svgRoot)
51
+ await writeFile(path.join(svgRoot, `${iconName}.svg`), '<svg />')
52
+
53
+ await generateIconSource(outputDir)
54
+
55
+ const entrypoint = await readFile(
56
+ path.join(outputDir, 'src', 'index.cjs'),
57
+ 'utf8',
58
+ )
59
+ const context = {
60
+ module: { exports: {} as Record<string, unknown> },
61
+ }
62
+
63
+ const script = new vm.Script(entrypoint)
64
+ script.runInNewContext(context)
65
+
66
+ expect(Object.keys(context.module.exports)).toEqual([`16/${iconName}`])
67
+ })
68
+ })
@@ -1,43 +1,52 @@
1
1
  import path from 'path'
2
2
  import { glob } from 'node:fs/promises'
3
3
  import fs from 'fs-extra'
4
+ import { serializeJavaScriptValue } from './codegen'
4
5
 
5
- const generateIconSvgEmbeddedSource = (svgString: string) => {
6
+ export const generateIconSvgEmbeddedSource = (svgString: string) => {
6
7
  const str = svgString.replace(/\r?\n/g, '')
7
8
 
8
9
  return `/** This file is auto generated. DO NOT EDIT BY HAND. */
9
- export default '${str}'
10
+ export default ${serializeJavaScriptValue(str)}
10
11
  `
11
12
  }
12
13
 
13
- const generateMjsEntrypoint = (
14
+ export const generateMjsEntrypoint = (
14
15
  icons: string[],
15
16
  ) => `/** This file is auto generated. DO NOT EDIT BY HAND. */
16
17
 
17
18
  export default {
18
19
  ${icons
19
- .map((it) => ` '${it}': () => import('./${it}.js').then(m => m.default)`)
20
+ .map(
21
+ (it) =>
22
+ ` ${serializeJavaScriptValue(it)}: () => import(${serializeJavaScriptValue(`./${it}.js`)}).then(m => m.default)`,
23
+ )
20
24
  .join(',\n')}
21
25
  }
22
26
  `
23
27
 
24
- const generateCjsEntrypoint = (
28
+ export const generateCjsEntrypoint = (
25
29
  icons: string[],
26
30
  ) => `/** This file is auto generated. DO NOT EDIT BY HAND. */
27
31
 
28
32
  module.exports = {
29
33
  ${icons
30
- .map((it) => ` '${it}': () => import('./${it}.js').then(m => m.default)`)
34
+ .map(
35
+ (it) =>
36
+ ` ${serializeJavaScriptValue(it)}: () => import(${serializeJavaScriptValue(`./${it}.js`)}).then(m => m.default)`,
37
+ )
31
38
  .join(',\n')}
32
39
  }
33
40
  `
34
41
 
35
- const generateTypeDefinitionEntrypoint = (
42
+ export const generateTypeDefinitionEntrypoint = (
36
43
  icons: string[],
37
44
  ) => `/** This file is auto generated. DO NOt EDIT BY HAND. */
38
45
 
39
46
  declare var _default: {
40
- ${icons.map((it) => ` '${it}': () => Promise<string>`).join(';\n')}
47
+ ${icons
48
+ .map((it) => ` ${serializeJavaScriptValue(it)}: () => Promise<string>`)
49
+ .join(';\n')}
41
50
  };
42
51
  export default _default;
43
52
  `
@@ -3,20 +3,25 @@ import path from 'path'
3
3
  import { execp } from './utils'
4
4
 
5
5
  /**
6
- * dir 内で変更があったファイル情報を for await で回せるようにするやつ
6
+ * dirs 内で変更があったファイル情報を for await で回せるようにするやつ
7
7
  */
8
- export async function* getChangedFiles(dir: string) {
9
- if (!existsSync(dir))
10
- throw new Error(`icons-cli: target directory not found (${dir})`)
8
+ export async function* getChangedFiles(dirs: string[]) {
9
+ for (const dir of dirs) {
10
+ if (!existsSync(dir))
11
+ throw new Error(`icons-cli: target directory not found (${dir})`)
12
+ }
11
13
  const gitStatus = await collectGitStatus()
12
14
  for (const [relativePath, status] of gitStatus) {
13
- const fullpath = path.resolve(process.cwd(), '../../', relativePath)
14
- if (!fullpath.startsWith(`${dir}/`)) {
15
+ const fullpath = path.resolve(process.cwd(), relativePath)
16
+ if (!dirs.some((dir) => fullpath.startsWith(`${dir}/`))) {
15
17
  continue
16
18
  }
17
- if (!existsSync(fullpath))
18
- throw new Error(`icons-cli: could not load svg (${fullpath})`)
19
- const content = await fs.readFile(fullpath, { encoding: 'utf-8' })
19
+ if (status !== 'deleted' && !existsSync(fullpath))
20
+ throw new Error(`icons-cli: could not find file (${fullpath})`)
21
+ const content =
22
+ status === 'deleted'
23
+ ? ''
24
+ : await fs.readFile(fullpath, { encoding: 'utf-8' })
20
25
  yield { relativePath, content, status }
21
26
  }
22
27
  }
@@ -26,7 +31,7 @@ async function collectGitStatus() {
26
31
  /**
27
32
  * @see https://git-scm.com/docs/git-status#_porcelain_format_version_1
28
33
  */
29
- (await execp(`git status --porcelain`)).split('\n').map((s) => {
34
+ (await execp(`git status --porcelain -uall`)).split('\n').map((s) => {
30
35
  return [
31
36
  s.slice(3),
32
37
  s.startsWith(' M')
package/src/index.ts CHANGED
@@ -47,18 +47,27 @@ void yargs
47
47
  default: 'v1',
48
48
  describe: 'Figma icon file layout version',
49
49
  },
50
+ sleepMs: {
51
+ type: 'number',
52
+ describe: 'Sleep duration between export requests in milliseconds',
53
+ },
50
54
  },
51
- async ({ format, layout }) => {
55
+ async ({ format, layout, sleepMs }) => {
52
56
  mustBeDefined(FIGMA_FILE_URL, 'FIGMA_FILE_URL')
53
57
  mustBeDefined(FIGMA_TOKEN, 'FIGMA_TOKEN')
54
58
  mustBeDefined(OUTPUT_ROOT_DIR, 'OUTPUT_ROOT_DIR')
55
59
 
60
+ if (sleepMs !== undefined && sleepMs < 0) {
61
+ throw new TypeError('sleepMs must be 0 or greater.')
62
+ }
63
+
56
64
  await FigmaFileClient.runFromCli(
57
65
  FIGMA_FILE_URL,
58
66
  FIGMA_TOKEN,
59
67
  OUTPUT_ROOT_DIR,
60
68
  format as 'svg' | 'pdf',
61
69
  layout as 'v1' | 'v2',
70
+ sleepMs,
62
71
  )
63
72
  },
64
73
  )