forked from stenciljs/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtask-generate.ts
More file actions
383 lines (342 loc) · 12.3 KB
/
Copy pathtask-generate.ts
File metadata and controls
383 lines (342 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import { normalizePath, validateComponentTag } from '@utils';
import { join, parse, relative } from 'path';
import type { ValidatedConfig } from '../declarations';
/**
* Task to generate component boilerplate and write it to disk. This task can
* cause the program to exit with an error under various circumstances, such as
* being called in an inappropriate place, being asked to overwrite files that
* already exist, etc.
*
* @param config the user-supplied config, which we need here to access `.sys`.
* @returns a void promise
*/
export const taskGenerate = async (config: ValidatedConfig): Promise<void> => {
if (!config.configPath) {
config.logger.error('Please run this command in your root directory (i. e. the one containing stencil.config.ts).');
return config.sys.exit(1);
}
const absoluteSrcDir = config.srcDir;
if (!absoluteSrcDir) {
config.logger.error(`Stencil's srcDir was not specified.`);
return config.sys.exit(1);
}
const { prompt } = await import('prompts');
const input =
config.flags.unknownArgs.find((arg) => !arg.startsWith('-')) ||
((await prompt({ name: 'tagName', type: 'text', message: 'Component tag name (dash-case):' })).tagName as string);
if (undefined === input) {
// in some shells (e.g. Windows PowerShell), hitting Ctrl+C results in a TypeError printed to the console.
// explicitly return here to avoid printing the error message.
return;
}
const { dir, base: componentName } = parse(input);
const tagError = validateComponentTag(componentName);
if (tagError) {
config.logger.error(tagError);
return config.sys.exit(1);
}
let cssExtension: GeneratableStylingExtension = 'css';
if (!!config.plugins.find((plugin) => plugin.name === 'sass')) {
cssExtension = await chooseSassExtension();
} else if (!!config.plugins.find((plugin) => plugin.name === 'less')) {
cssExtension = 'less';
}
const filesToGenerateExt = await chooseFilesToGenerate(cssExtension);
if (!filesToGenerateExt) {
// in some shells (e.g. Windows PowerShell), hitting Ctrl+C results in a TypeError printed to the console.
// explicitly return here to avoid printing the error message.
return;
}
const extensionsToGenerate: GeneratableExtension[] = ['tsx', ...filesToGenerateExt];
const testFolder = extensionsToGenerate.some(isTest) ? 'test' : '';
const outDir = join(absoluteSrcDir, 'components', dir, componentName);
await config.sys.createDir(normalizePath(join(outDir, testFolder)), { recursive: true });
const filesToGenerate: readonly BoilerplateFile[] = extensionsToGenerate.map((extension) => ({
extension,
path: getFilepathForFile(outDir, componentName, extension),
}));
await checkForOverwrite(filesToGenerate, config);
const writtenFiles = await Promise.all(
filesToGenerate.map((file) =>
getBoilerplateAndWriteFile(
config,
componentName,
extensionsToGenerate.includes('css') ||
extensionsToGenerate.includes('sass') ||
extensionsToGenerate.includes('scss') ||
extensionsToGenerate.includes('less'),
file,
cssExtension,
),
),
).catch((error) => config.logger.error(error));
if (!writtenFiles) {
return config.sys.exit(1);
}
// We use `console.log` here rather than our `config.logger` because we don't want
// our TUI messages to be prefixed with timestamps and so on.
//
// See STENCIL-424 for details.
console.log();
console.log(`${config.logger.gray('$')} stencil generate ${input}`);
console.log();
console.log(config.logger.bold('The following files have been generated:'));
const absoluteRootDir = config.rootDir;
writtenFiles.map((file) => console.log(` - ${relative(absoluteRootDir, file)}`));
};
/**
* Show a checkbox prompt to select the files to be generated.
*
* @param cssExtension the extension of the CSS file to be generated
* @returns a read-only array of `GeneratableExtension`, the extensions that the user has decided
* to generate
*/
const chooseFilesToGenerate = async (cssExtension: string): Promise<ReadonlyArray<GeneratableExtension>> => {
const { prompt } = await import('prompts');
return (
await prompt({
name: 'filesToGenerate',
type: 'multiselect',
message: 'Which additional files do you want to generate?',
choices: [
{ value: cssExtension, title: `Stylesheet (.${cssExtension})`, selected: true },
{ value: 'spec.tsx', title: 'Spec Test (.spec.tsx)', selected: true },
{ value: 'e2e.ts', title: 'E2E Test (.e2e.ts)', selected: true },
],
})
).filesToGenerate;
};
const chooseSassExtension = async () => {
const { prompt } = await import('prompts');
return (
await prompt({
name: 'sassFormat',
type: 'select',
message:
'Which Sass format would you like to use? (More info: https://sass-lang.com/documentation/syntax/#the-indented-syntax)',
choices: [
{ value: 'sass', title: `*.sass Format`, selected: true },
{ value: 'scss', title: '*.scss Format' },
],
})
).sassFormat;
};
/**
* Get a filepath for a file we want to generate!
*
* The filepath for a given file depends on the path, the user-supplied
* component name, the extension, and whether we're inside of a test directory.
*
* @param filePath path to where we're going to generate the component
* @param componentName the user-supplied name for the generated component
* @param extension the file extension
* @returns the full filepath to the component (with a possible `test` directory
* added)
*/
const getFilepathForFile = (filePath: string, componentName: string, extension: GeneratableExtension): string =>
isTest(extension)
? normalizePath(join(filePath, 'test', `${componentName}.${extension}`))
: normalizePath(join(filePath, `${componentName}.${extension}`));
/**
* Get the boilerplate for a file and write it to disk
*
* @param config the current config, needed for file operations
* @param componentName the component name (user-supplied)
* @param withCss are we generating CSS?
* @param file the file we want to write
* @param styleExtension extension used for styles
* @returns a `Promise<string>` which holds the full filepath we've written to,
* used to print out a little summary of our activity to the user.
*/
const getBoilerplateAndWriteFile = async (
config: ValidatedConfig,
componentName: string,
withCss: boolean,
file: BoilerplateFile,
styleExtension: GeneratableStylingExtension,
): Promise<string> => {
const boilerplate = getBoilerplateByExtension(componentName, file.extension, withCss, styleExtension);
await config.sys.writeFile(normalizePath(file.path), boilerplate);
return file.path;
};
/**
* Check to see if any of the files we plan to write already exist and would
* therefore be overwritten if we proceed, because we'd like to not overwrite
* people's code!
*
* This function will check all the filepaths and if it finds any files log an
* error and exit with an error code. If it doesn't find anything it will just
* peacefully return `Promise<void>`.
*
* @param files the files we want to check
* @param config the Config object, used here to get access to `sys.readFile`
*/
const checkForOverwrite = async (files: readonly BoilerplateFile[], config: ValidatedConfig): Promise<void> => {
const alreadyPresent: string[] = [];
await Promise.all(
files.map(async ({ path }) => {
if ((await config.sys.readFile(path)) !== undefined) {
alreadyPresent.push(path);
}
}),
);
if (alreadyPresent.length > 0) {
config.logger.error(
'Generating code would overwrite the following files:',
...alreadyPresent.map((path) => '\t' + normalizePath(path)),
);
await config.sys.exit(1);
}
};
/**
* Check if an extension is for a test
*
* @param extension the extension we want to check
* @returns a boolean indicating whether or not its a test
*/
const isTest = (extension: GeneratableExtension): boolean => {
return extension === 'e2e.ts' || extension === 'spec.tsx';
};
/**
* Get the boilerplate for a file by its extension.
*
* @param tagName the name of the component we're generating
* @param extension the file extension we want boilerplate for (.css, tsx, etc)
* @param withCss a boolean indicating whether we're generating a CSS file
* @param styleExtension extension used for styles
* @returns a string container the file boilerplate for the supplied extension
*/
export const getBoilerplateByExtension = (
tagName: string,
extension: GeneratableExtension,
withCss: boolean,
styleExtension: GeneratableStylingExtension,
): string => {
switch (extension) {
case 'tsx':
return getComponentBoilerplate(tagName, withCss, styleExtension);
case 'css':
case 'less':
case 'sass':
case 'scss':
return getStyleUrlBoilerplate(styleExtension);
case 'spec.tsx':
return getSpecTestBoilerplate(tagName);
case 'e2e.ts':
return getE2eTestBoilerplate(tagName);
default:
throw new Error(`Unkown extension "${extension}".`);
}
};
/**
* Get the boilerplate for a file containing the definition of a component
* @param tagName the name of the tag to give the component
* @param hasStyle designates if the component has an external stylesheet or not
* @param styleExtension extension used for styles
* @returns the contents of a file that defines a component
*/
const getComponentBoilerplate = (
tagName: string,
hasStyle: boolean,
styleExtension: GeneratableStylingExtension,
): string => {
const decorator = [`{`];
decorator.push(` tag: '${tagName}',`);
if (hasStyle) {
decorator.push(` styleUrl: '${tagName}.${styleExtension}',`);
}
decorator.push(` shadow: true,`);
decorator.push(`}`);
return `import { Component, Host, h } from '@stencil/core';
@Component(${decorator.join('\n')})
export class ${toPascalCase(tagName)} {
render() {
return (
<Host>
<slot></slot>
</Host>
);
}
}
`;
};
/**
* Get the boilerplate for style for a generated component
* @param ext extension used for styles
* @returns a boilerplate CSS block
*/
const getStyleUrlBoilerplate = (ext: GeneratableExtension): string =>
ext === 'sass'
? `:host
display: block
`
: `:host {
display: block;
}
`;
/**
* Get the boilerplate for a file containing a spec (unit) test for a component
* @param tagName the name of the tag associated with the component under test
* @returns the contents of a file that unit tests a component
*/
const getSpecTestBoilerplate = (tagName: string): string =>
`import { newSpecPage } from '@stencil/core/testing';
import { ${toPascalCase(tagName)} } from '../${tagName}';
describe('${tagName}', () => {
it('renders', async () => {
const page = await newSpecPage({
components: [${toPascalCase(tagName)}],
html: \`<${tagName}></${tagName}>\`,
});
expect(page.root).toEqualHtml(\`
<${tagName}>
<mock:shadow-root>
<slot></slot>
</mock:shadow-root>
</${tagName}>
\`);
});
});
`;
/**
* Get the boilerplate for a file containing an end-to-end (E2E) test for a component
* @param tagName the name of the tag associated with the component under test
* @returns the contents of a file that E2E tests a component
*/
const getE2eTestBoilerplate = (tagName: string): string =>
`import { newE2EPage } from '@stencil/core/testing';
describe('${tagName}', () => {
it('renders', async () => {
const page = await newE2EPage();
await page.setContent('<${tagName}></${tagName}>');
const element = await page.find('${tagName}');
expect(element).toHaveClass('hydrated');
});
});
`;
/**
* Convert a dash case string to pascal case.
* @param str the string to convert
* @returns the converted input as pascal case
*/
const toPascalCase = (str: string): string =>
str.split('-').reduce((res, part) => res + part[0].toUpperCase() + part.slice(1), '');
/**
* Extensions available to generate.
*/
export type GeneratableExtension = 'tsx' | 'spec.tsx' | 'e2e.ts' | GeneratableStylingExtension;
/**
* Extensions available to generate.
*/
export type GeneratableStylingExtension = 'css' | 'sass' | 'scss' | 'less';
/**
* A little interface to wrap up the info we need to pass around for generating
* and writing boilerplate.
*/
export interface BoilerplateFile {
extension: GeneratableExtension;
/**
* The full path to the file we want to generate.
*/
path: string;
}