@charcoal-ui/icons-cli 5.9.0-beta.0 → 5.9.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.
@@ -0,0 +1,113 @@
1
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import fs from 'fs-extra'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ async function waitForFile(filePath: string) {
8
+ for (let i = 0; i < 100; i += 1) {
9
+ if (await fs.pathExists(filePath)) {
10
+ return
11
+ }
12
+
13
+ await new Promise((resolve) => setTimeout(resolve, 10))
14
+ }
15
+
16
+ throw new Error(`timed out waiting for ${filePath}`)
17
+ }
18
+
19
+ async function runTransformCss(
20
+ sourceDir: string,
21
+ outputDir: string,
22
+ version: 'v1' | 'v2',
23
+ ) {
24
+ const previousVersion = process.env.VERSION
25
+ const previousSourceRootDir = process.env.SOURCE_ROOT_DIR
26
+ const previousOutputRootDir = process.env.OUTPUT_ROOT_DIR
27
+
28
+ process.env.VERSION = version
29
+ process.env.SOURCE_ROOT_DIR = path.relative(import.meta.dirname, sourceDir)
30
+ process.env.OUTPUT_ROOT_DIR = outputDir
31
+
32
+ try {
33
+ vi.resetModules()
34
+ await import('./transformCSS')
35
+ await waitForFile(path.join(outputDir, 'index.story.tsx'))
36
+ } finally {
37
+ if (previousVersion === undefined) {
38
+ delete process.env.VERSION
39
+ } else {
40
+ process.env.VERSION = previousVersion
41
+ }
42
+
43
+ if (previousSourceRootDir === undefined) {
44
+ delete process.env.SOURCE_ROOT_DIR
45
+ } else {
46
+ process.env.SOURCE_ROOT_DIR = previousSourceRootDir
47
+ }
48
+
49
+ if (previousOutputRootDir === undefined) {
50
+ delete process.env.OUTPUT_ROOT_DIR
51
+ } else {
52
+ process.env.OUTPUT_ROOT_DIR = previousOutputRootDir
53
+ }
54
+ }
55
+ }
56
+
57
+ describe('transformCSS regression', () => {
58
+ let workDir: string
59
+
60
+ beforeEach(async () => {
61
+ workDir = await mkdtemp(path.join(tmpdir(), 'icons-cli-transform-css-'))
62
+ })
63
+
64
+ afterEach(async () => {
65
+ await fs.remove(workDir)
66
+ })
67
+
68
+ it('通常のv2アイコン名では既存のclass名を維持する', async () => {
69
+ const sourceDir = path.join(workDir, 'input')
70
+ const outputDir = path.join(workDir, 'output')
71
+
72
+ await fs.ensureDir(path.join(sourceDir, '24', 'regular'))
73
+ await writeFile(
74
+ path.join(sourceDir, '24', 'regular', 'Add.Circle.svg'),
75
+ '<svg />',
76
+ )
77
+
78
+ await runTransformCss(sourceDir, outputDir, 'v2')
79
+
80
+ const css = await readFile(path.join(outputDir, 'index.css'), 'utf8')
81
+
82
+ expect(css).toContain('.charcoal-icon-v2-add-circle')
83
+ })
84
+
85
+ it('危険な文字を含むv2ファイル名でも安全なclass名だけを生成する', async () => {
86
+ const sourceDir = path.join(workDir, 'input')
87
+ const outputDir = path.join(workDir, 'output')
88
+ const safeClassName = 'charcoal-icon-v2-bad-name-16'
89
+
90
+ await fs.ensureDir(path.join(sourceDir, '16', 'regular'))
91
+ await writeFile(
92
+ path.join(sourceDir, '16', 'regular', 'Bad "Name"..svg'),
93
+ '<svg />',
94
+ )
95
+
96
+ await runTransformCss(sourceDir, outputDir, 'v2')
97
+
98
+ const css = await readFile(path.join(outputDir, 'index.css'), 'utf8')
99
+ const html = await readFile(path.join(outputDir, 'index.html'), 'utf8')
100
+ const story = await readFile(
101
+ path.join(outputDir, 'index.story.tsx'),
102
+ 'utf8',
103
+ )
104
+
105
+ expect(css).toContain(`.${safeClassName}`)
106
+ expect(html).toContain(`class="${safeClassName}"`)
107
+ expect(story).toContain(`className="${safeClassName}"`)
108
+
109
+ expect(css).not.toContain('Bad "Name"')
110
+ expect(html).not.toContain('Bad "Name"')
111
+ expect(story).not.toContain('Bad "Name"')
112
+ })
113
+ })
@@ -2,14 +2,51 @@ import { mustBeDefined } from '../utils'
2
2
  import { glob, readFile, writeFile } from 'fs/promises'
3
3
  import { ensureDir } from 'fs-extra'
4
4
  import path from 'path'
5
- import { encodeSvgAsDataUri } from '../utils'
5
+ import {
6
+ createCssClassNameSegment,
7
+ createSvgDataUri,
8
+ serializeJavaScriptValue,
9
+ } from '../codegen'
10
+
11
+ const previewStyles = `:root {
12
+ font-size: 24px;
13
+ }
14
+ .icons {
15
+ display: grid;
16
+ gap: 8px;
17
+ grid-template-columns: repeat(auto-fill, 300px);
18
+ }
19
+ .icons div {
20
+ display: inline-flex;
21
+ gap: 8px;
22
+ align-items: center;
23
+ }
24
+ code {
25
+ font-size: 14px;
26
+ }`
27
+
28
+ function createPreviewItems(
29
+ classNames: string[],
30
+ classAttributeName: 'class' | 'className',
31
+ ) {
32
+ return classNames
33
+ .map(
34
+ (icon) => `
35
+ <div>
36
+ <div ${classAttributeName}="${icon}" aria-label=".${icon}" role="img"></div>
37
+ <code>.${icon}</code>
38
+ </div>`,
39
+ )
40
+ .join('\n')
41
+ }
6
42
 
7
43
  async function transformV2(filePath: string, fileName: string) {
8
44
  const content = await readFile(filePath, 'utf-8')
9
45
  const [size, variant, name] = fileName.split('/')
46
+ const dataUri = createSvgDataUri(content)
10
47
  const cssName = [
11
48
  'charcoal-icon-v2',
12
- name.toLowerCase().replace('.svg', '').replaceAll('.', '-'),
49
+ createCssClassNameSegment(name),
13
50
  ...(variant === 'regular' ? [] : [variant]),
14
51
  ...(size === '24' ? [] : [size]),
15
52
  ].join('-')
@@ -19,9 +56,7 @@ async function transformV2(filePath: string, fileName: string) {
19
56
  display: inline-block;
20
57
  width: 1em;
21
58
  height: 1em;
22
- background: url('data:image/svg+xml;utf8,${encodeSvgAsDataUri(
23
- content,
24
- ).replace("'", "\\'")}');
59
+ background: url("${dataUri}");
25
60
  aspect-ratio: 1/1;
26
61
  }`
27
62
  : `
@@ -29,9 +64,7 @@ async function transformV2(filePath: string, fileName: string) {
29
64
  display: inline-block;
30
65
  width: 1em;
31
66
  height: 1em;
32
- mask-image: url('data:image/svg+xml;utf8,${encodeSvgAsDataUri(
33
- content,
34
- ).replace("'", "\\'")}');
67
+ mask-image: url("${dataUri}");
35
68
  mask-size: 100% 100%;
36
69
  background: currentColor;
37
70
  aspect-ratio: 1/1;
@@ -47,9 +80,10 @@ async function transformV2(filePath: string, fileName: string) {
47
80
  async function transformV1(filePath: string, fileName: string) {
48
81
  const content = await readFile(filePath, 'utf-8')
49
82
  const [size, name] = fileName.split('/')
83
+ const dataUri = createSvgDataUri(content)
50
84
  const cssName = [
51
85
  'charcoal-icon-v1',
52
- name.toLowerCase().replace('.svg', '').replaceAll('.', '-'),
86
+ createCssClassNameSegment(name),
53
87
  ...(size === '24' ? [] : [size]),
54
88
  ].join('-')
55
89
  const css = content.includes('<def')
@@ -58,9 +92,7 @@ async function transformV1(filePath: string, fileName: string) {
58
92
  display: inline-block;
59
93
  width: 1em;
60
94
  height: 1em;
61
- background: url('data:image/svg+xml;utf8,${encodeSvgAsDataUri(
62
- content,
63
- ).replace("'", "\\'")}');
95
+ background: url("${dataUri}");
64
96
  aspect-ratio: 1/1;
65
97
  }`
66
98
  : `
@@ -68,9 +100,7 @@ async function transformV1(filePath: string, fileName: string) {
68
100
  display: inline-block;
69
101
  width: 1em;
70
102
  height: 1em;
71
- mask-image: url('data:image/svg+xml;utf8,${encodeSvgAsDataUri(
72
- content,
73
- ).replace("'", "\\'")}');
103
+ mask-image: url("${dataUri}");
74
104
  mask-size: 100% 100%;
75
105
  background: currentColor;
76
106
  aspect-ratio: 1/1;
@@ -125,38 +155,12 @@ async function main() {
125
155
  classNames = classNames.sort()
126
156
  const html = `
127
157
  <div class="icons">
128
- ${classNames
129
- .map(
130
- (icon) => `
131
- <div>
132
- <div class="${icon}" aria-label=".${icon}" role="img" />
133
- <code>.${icon}</code>
134
- </div>
135
- `,
136
- )
137
- .join('\n')}
158
+ ${createPreviewItems(classNames, 'class')}
138
159
  </div>`
139
160
 
140
161
  await writeFile(
141
162
  path.join(outDir, 'index.html'),
142
- `<link rel="stylesheet" href="./index.css"><style>
143
- :root {
144
- font-size: 24px;
145
- }
146
- .icons {
147
- display: grid;
148
- gap: 8px;
149
- grid-template-columns: repeat(auto-fill, 300px);
150
- }
151
- .icons div {
152
- display: inline-flex;
153
- gap: 8px;
154
- align-items: center;
155
- }
156
- code {
157
- font-size: 14px;
158
- }
159
- </style>${html}`,
163
+ `<link rel="stylesheet" href="./index.css"><style>${previewStyles}</style>${html}`,
160
164
  )
161
165
  await writeFile(
162
166
  path.join(outDir, 'index.story.tsx'),
@@ -175,26 +179,11 @@ export default {
175
179
  render(): JSX.Element {
176
180
  return (
177
181
  <>
178
- <style>{\`${cssContent}\`}</style>
179
- <style>
180
- {\`:root {
181
- font-size: 24px;
182
- }
183
- .icons {
184
- display: grid;
185
- gap: 8px;
186
- grid-template-columns: repeat(auto-fill, 300px);
187
- }
188
- .icons div {
189
- display: inline-flex;
190
- gap: 8px;
191
- align-items: center;
192
- }
193
- code {
194
- font-size: 14px;
195
- }\`}
196
- </style>
197
- ${html.replaceAll('class', 'className')}
182
+ <style>{${serializeJavaScriptValue(cssContent)}}</style>
183
+ <style>{${serializeJavaScriptValue(previewStyles)}}</style>
184
+ <div className="icons">
185
+ ${createPreviewItems(classNames, 'className')}
186
+ </div>
198
187
  </>
199
188
  )
200
189
  },
@@ -0,0 +1,97 @@
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, vi } from 'vitest'
7
+
8
+ async function waitForFile(filePath: string) {
9
+ for (let i = 0; i < 100; i += 1) {
10
+ if (await fs.pathExists(filePath)) {
11
+ return
12
+ }
13
+
14
+ await new Promise((resolve) => setTimeout(resolve, 10))
15
+ }
16
+
17
+ throw new Error(`timed out waiting for ${filePath}`)
18
+ }
19
+
20
+ async function runTransformDataUri(sourceDir: string, outputDir: string) {
21
+ const previousSourceRootDir = process.env.SOURCE_ROOT_DIR
22
+ const previousOutputRootDir = process.env.OUTPUT_ROOT_DIR
23
+
24
+ process.env.SOURCE_ROOT_DIR = path.relative(import.meta.dirname, sourceDir)
25
+ process.env.OUTPUT_ROOT_DIR = outputDir
26
+
27
+ try {
28
+ vi.resetModules()
29
+ await import('./transformDataUri')
30
+ await waitForFile(path.join(outputDir, 'index.d.ts'))
31
+ } finally {
32
+ if (previousSourceRootDir === undefined) {
33
+ delete process.env.SOURCE_ROOT_DIR
34
+ } else {
35
+ process.env.SOURCE_ROOT_DIR = previousSourceRootDir
36
+ }
37
+
38
+ if (previousOutputRootDir === undefined) {
39
+ delete process.env.OUTPUT_ROOT_DIR
40
+ } else {
41
+ process.env.OUTPUT_ROOT_DIR = previousOutputRootDir
42
+ }
43
+ }
44
+ }
45
+
46
+ describe('transformDataUri regression', () => {
47
+ let workDir: string
48
+
49
+ beforeEach(async () => {
50
+ workDir = await mkdtemp(
51
+ path.join(tmpdir(), 'icons-cli-transform-data-uri-'),
52
+ )
53
+ })
54
+
55
+ afterEach(async () => {
56
+ await fs.remove(workDir)
57
+ })
58
+
59
+ it('危険なキー名を含んでもCJS出力が壊れない', async () => {
60
+ const sourceDir = path.join(workDir, 'input')
61
+ const outputDir = path.join(workDir, 'output')
62
+ const iconName = String.raw`16/Bad'"Name\Icon`
63
+
64
+ await fs.ensureDir(path.join(sourceDir, '16'))
65
+ await writeFile(path.join(sourceDir, `${iconName}.svg`), '<svg />')
66
+
67
+ await runTransformDataUri(sourceDir, outputDir)
68
+
69
+ const cjs = await readFile(path.join(outputDir, 'index.cjs'), 'utf8')
70
+ const context = {
71
+ module: { exports: {} as Record<string, unknown> },
72
+ }
73
+
74
+ const script = new vm.Script(cjs)
75
+ script.runInNewContext(context)
76
+
77
+ expect(Object.keys(context.module.exports)).toEqual([iconName])
78
+ })
79
+
80
+ it('危険なSVG文字列でもdata URIを生で埋め込まない', async () => {
81
+ const sourceDir = path.join(workDir, 'input')
82
+ const outputDir = path.join(workDir, 'output')
83
+ const svgContent =
84
+ '<svg><text></style><script>alert("xss")</script></text></svg>'
85
+
86
+ await fs.ensureDir(path.join(sourceDir, '16'))
87
+ await writeFile(path.join(sourceDir, '16', 'Add.svg'), svgContent)
88
+
89
+ await runTransformDataUri(sourceDir, outputDir)
90
+
91
+ const mjs = await readFile(path.join(outputDir, 'index.mjs'), 'utf8')
92
+
93
+ expect(mjs).not.toContain('</style>')
94
+ expect(mjs).not.toContain('<script>')
95
+ expect(mjs).toContain('%3Csvg%3E')
96
+ })
97
+ })
@@ -1,7 +1,8 @@
1
1
  import { glob, readFile, writeFile } from 'fs/promises'
2
2
  import { ensureDir } from 'fs-extra'
3
3
  import path from 'path'
4
- import { encodeSvgAsDataUri, mustBeDefined } from '../utils'
4
+ import { createSvgDataUri, serializeJavaScriptValue } from '../codegen'
5
+ import { mustBeDefined } from '../utils'
5
6
 
6
7
  async function main() {
7
8
  mustBeDefined(process.env.SOURCE_ROOT_DIR, 'SOURCE_ROOT_DIR')
@@ -20,48 +21,39 @@ async function main() {
20
21
 
21
22
  return {
22
23
  iconName: fileName.replace('.svg', ''),
23
- uri: `data:image/svg+xml;utf8,${encodeSvgAsDataUri(content).replace(
24
- "'",
25
- "\\'",
26
- )}`,
24
+ uri: createSvgDataUri(content),
27
25
  isSetCurrentcolor: !content.includes('<def'),
28
26
  }
29
27
  }),
30
28
  )
31
29
 
32
- const js = `/** This file is auto generated. DO NOT EDIT BY HAND. */
33
-
34
- export default {
35
- ${dataUris
36
- .map(
37
- (it) =>
38
- `'${it.iconName}': {uri: '${it.uri}', isSetCurrentcolor: ${
39
- it.isSetCurrentcolor ? 'true' : 'false'
40
- }}`,
30
+ const dataUriMap = Object.fromEntries(
31
+ dataUris.map(({ iconName, uri, isSetCurrentcolor }) => [
32
+ iconName,
33
+ { uri, isSetCurrentcolor },
34
+ ]),
41
35
  )
42
- .join(',\n')}
43
- }`
36
+
37
+ const js = `/** This file is auto generated. DO NOT EDIT BY HAND. */
38
+
39
+ export default ${serializeJavaScriptValue(dataUriMap)}
40
+ `
44
41
  await writeFile(path.join(outDir, 'index.mjs'), js)
45
42
 
46
43
  const cjs = `/** This file is auto generated. DO NOT EDIT BY HAND. */
47
-
48
- module.exports = {
49
- ${dataUris
50
- .map(
51
- (it) =>
52
- `'${it.iconName}': {uri: '${it.uri}', isSetCurrentcolor: ${
53
- it.isSetCurrentcolor ? 'true' : 'false'
54
- }}`,
55
- )
56
- .join(',\n')}
57
- }`
44
+
45
+ module.exports = ${serializeJavaScriptValue(dataUriMap)}
46
+ `
58
47
  await writeFile(path.join(outDir, 'index.cjs'), cjs)
59
48
 
60
49
  const dts = `/** This file is auto generated. DO NOT EDIT BY HAND. */
61
50
 
62
51
  declare var _default: {
63
52
  ${dataUris
64
- .map((it) => `'${it.iconName}': { uri: string, isSetCurrentcolor: boolean }`)
53
+ .map(
54
+ (it) =>
55
+ `${serializeJavaScriptValue(it.iconName)}: { uri: string, isSetCurrentcolor: boolean }`,
56
+ )
65
57
  .join(';\n')}}
66
58
  export default _default;
67
59
  `