@charcoal-ui/icons-cli 5.8.1 → 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.
- package/dist/index.js +74 -23
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/GitHubClient.ts +1 -1
- package/src/GitlabClient.ts +1 -1
- package/src/codegen.test.ts +86 -0
- package/src/codegen.ts +28 -0
- package/src/figma/FigmaFileClient.test.ts +218 -0
- package/src/figma/FigmaFileClient.ts +96 -27
- package/src/generateSource.test.ts +68 -0
- package/src/generateSource.ts +17 -8
- package/src/getChangedFiles.ts +15 -10
- package/src/index.ts +10 -1
- package/src/utils.ts +10 -0
- package/src/v2/transformCSS.test.ts +113 -0
- package/src/v2/transformCSS.ts +52 -67
- package/src/v2/transformDataUri.test.ts +97 -0
- package/src/v2/transformDataUri.ts +19 -25
|
@@ -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 }>(
|
|
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(
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
209
|
+
if (DRY_RUN) {
|
|
210
|
+
console.log(`[DRY_RUN] skip: ${filename} => ✅ writing...`)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
181
213
|
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
})
|
package/src/generateSource.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
|
47
|
+
${icons
|
|
48
|
+
.map((it) => ` ${serializeJavaScriptValue(it)}: () => Promise<string>`)
|
|
49
|
+
.join(';\n')}
|
|
41
50
|
};
|
|
42
51
|
export default _default;
|
|
43
52
|
`
|
package/src/getChangedFiles.ts
CHANGED
|
@@ -3,20 +3,25 @@ import path from 'path'
|
|
|
3
3
|
import { execp } from './utils'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* dirs 内で変更があったファイル情報を for await で回せるようにするやつ
|
|
7
7
|
*/
|
|
8
|
-
export async function* getChangedFiles(
|
|
9
|
-
|
|
10
|
-
|
|
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(),
|
|
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
|
|
19
|
-
const content =
|
|
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
|
)
|
package/src/utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exec } from 'child_process'
|
|
2
|
+
import { escape } from 'querystring'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* FIXME: util.promisify を使うと node-libs-browser に入っている方が使われてしまい、壊れる
|
|
@@ -22,3 +23,12 @@ export function mustBeDefined<T>(
|
|
|
22
23
|
throw new TypeError(`${name} must be defined.`)
|
|
23
24
|
}
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* escapeだけでは ( ) が残るので、それも追加でエスケープする
|
|
29
|
+
* @param svg SVG string
|
|
30
|
+
* @returns SVG string encoded as data URI
|
|
31
|
+
*/
|
|
32
|
+
export function encodeSvgAsDataUri(svg: string): string {
|
|
33
|
+
return escape(svg).replaceAll('(', '%28').replaceAll(')', '%29')
|
|
34
|
+
}
|