Skip to content

Commit d42c8f2

Browse files
authored
Merge pull request #8 from forumone/component-generator
Add component generator script
2 parents e78018c + 95dd78d commit d42c8f2

File tree

4 files changed

+1211
-2
lines changed

4 files changed

+1211
-2
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ npm run tsc
9292

9393
Runs `tsc --noEmit`, which will compile the TypeScript code without emitting files. This acts as a TS error check in your CLI. This is useful to catch TS errors that you might miss during development. For more information, see the [TypeScript Compiler (tsc) documentation](https://www.typescriptlang.org/docs/handbook/compiler-options.html).
9494

95+
### Scaffold new component
96+
97+
```bash
98+
npm run component
99+
```
100+
Runs the `lib/component.js` script, which will scaffold a new React or Emotion component,
101+
with the option to include a Storybook story file as well.
102+
95103
## Husky
96104

97105
This project uses [Husky](https://typicode.github.io/husky/#/) to check code on git commits. By default, it is setup to use the `npm test` script which runs `lint` and `tsc` (TypeScript) checks against the codebase. This check occurs on `git commit` attempts. This helps developers catch errors _before_ pushing branches and creating PRs, quickening the overall dev worklow.

lib/component.js

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/* eslint-env node */
2+
/* eslint-disable no-console, @typescript-eslint/no-var-requires */
3+
4+
const fs = require('fs');
5+
const { writeFile, lstat, readdir } = require('fs/promises');
6+
const inquirer = require('inquirer');
7+
const mkdirp = require('mkdirp');
8+
const path = require('path');
9+
10+
/**
11+
* Creates the machine name from a human-readable name.
12+
* @param {string} name - The human-readable name
13+
* @return {string} - The machine name
14+
*/
15+
function machineName(name) {
16+
return name
17+
.split(/[\s-_]/)
18+
.map(piece => `${piece.charAt(0).toUpperCase()}${piece.slice(1)}`)
19+
.join('');
20+
}
21+
22+
/**
23+
* Creates a human name from a machine name.
24+
* @param {string} name - The machine name
25+
* @return {string} - The human-readable name
26+
*/
27+
function humanName(name) {
28+
const words = name
29+
.split(/[\s-_]/)
30+
.map(word => word.charAt(0).toUpperCase() + word.slice(1));
31+
return words.join(' ');
32+
}
33+
34+
function makeEmotionFile(componentName, location) {
35+
const fileName = 'index.tsx';
36+
const filePath = path.join(location, fileName);
37+
const fileContent = `import styled from '@emotion/styled;'
38+
39+
const ${componentName} = styled('div')\`\`;
40+
41+
export default ${componentName};
42+
`;
43+
return writeFile(filePath, fileContent, err =>
44+
err ? console.error(err) : null,
45+
);
46+
}
47+
48+
function makeReactFile(componentName, location) {
49+
const fileName = 'index.tsx';
50+
const filePath = path.join(location, fileName);
51+
const fileContent = `function ${componentName}(): JSX.Element {
52+
return <div />;
53+
}
54+
55+
export default ${componentName};
56+
`;
57+
return writeFile(filePath, fileContent, err =>
58+
err ? console.error(err) : null,
59+
);
60+
}
61+
62+
function makeStorybookFile(componentName, componentTitle, location, directory) {
63+
const fileName = 'index.stories.tsx';
64+
const filePath = path.join(location, fileName);
65+
const fileContent = `import { Meta, Story } from '@storybook/react';
66+
import ${componentName} from '.';
67+
68+
const settings: Meta = {
69+
title: '${machineName(directory)}/${componentTitle}',
70+
component: ${componentName},
71+
};
72+
73+
const Template: Story = args => <${componentName} {...args} />;
74+
const _${componentName} = Template.bind({});
75+
76+
export default settings;
77+
export { _${componentName} as ${componentName} };
78+
`;
79+
return writeFile(filePath, fileContent, err =>
80+
err ? console.error(err) : null,
81+
);
82+
}
83+
84+
/**
85+
* Checks whether the source directory is an accessible directory.
86+
* @param {string} source - Source path
87+
* @return {Promise<boolean>} - True if source is an accessible directory
88+
*/
89+
const isDirectory = async source => {
90+
const stats = await lstat(source);
91+
return stats.isDirectory();
92+
};
93+
94+
/**
95+
* Get available component directories.
96+
* @param {string} source - Source path
97+
* @return {Promise<T[]>} - Array of component directory paths
98+
*/
99+
const getDirectories = async source => {
100+
const directoryFiles = await readdir(source);
101+
const directoryPaths = directoryFiles.map(name => path.join(source, name));
102+
const isDirectoryResults = await Promise.all(directoryPaths.map(isDirectory));
103+
return directoryPaths.filter((value, index) => isDirectoryResults[index]);
104+
};
105+
106+
/**
107+
* Get the machine name from user input.
108+
* @return {Promise<string>} - Machine name of new component
109+
*/
110+
async function getMachineName() {
111+
const questions = [
112+
{
113+
type: 'input',
114+
name: 'componentName',
115+
message: 'What is the name of your component?',
116+
filter: machineName,
117+
},
118+
];
119+
const { componentName } = await inquirer.prompt(questions);
120+
return componentName.trim();
121+
}
122+
123+
/**
124+
* Get additional details about the component from user input.
125+
* @param {string} componentName - The machine name of the component
126+
* @param {string[]} patternDir - Array of available directories
127+
* @return {Promise<*>} - User responses
128+
*/
129+
async function getComponentDetails(componentName, patternDir) {
130+
const defaultComponentTitle = humanName(componentName);
131+
const detailedQuestions = [
132+
{
133+
type: 'input',
134+
name: 'componentTitle',
135+
message: 'What is the human-readable title of your component?',
136+
default: defaultComponentTitle,
137+
},
138+
{
139+
type: 'list',
140+
name: 'componentFolder',
141+
message: 'Component Location',
142+
choices: patternDir.map(item => path.basename(item)),
143+
},
144+
{
145+
type: 'input',
146+
name: 'componentFolderSub',
147+
message: 'Include subfolder or leave blank',
148+
},
149+
{
150+
type: 'confirm',
151+
name: 'useEmotion',
152+
message: 'Create an Emotion styled component?',
153+
default: false,
154+
},
155+
{
156+
type: 'confirm',
157+
name: 'useStorybook',
158+
message: 'Create a Storybook story?',
159+
default: true,
160+
},
161+
];
162+
return inquirer.prompt(detailedQuestions);
163+
}
164+
165+
/**
166+
* Create all files for a new component.
167+
* @param {string} componentName - Component machine name
168+
* @param {string} componentTitle - Component human-readable name
169+
* @param {string} location - Directory path for new component
170+
* @param {string} directory - Name of the directory
171+
* @param {boolean} useEmotion - Whether to create an Emotion component
172+
* @param {boolean} useStorybook - Whether to create a Storybook story
173+
* @return {Promise<void>}
174+
*/
175+
async function createComponent(
176+
componentName,
177+
componentTitle,
178+
location,
179+
directory,
180+
useEmotion,
181+
useStorybook,
182+
) {
183+
if (fs.existsSync(location)) {
184+
console.log('Component directory already exists');
185+
} else {
186+
try {
187+
await mkdirp(location);
188+
} catch (err) {
189+
console.error(err);
190+
}
191+
192+
const filesArray = [];
193+
if (useEmotion) {
194+
filesArray.push(makeEmotionFile(componentName, location));
195+
} else {
196+
filesArray.push(makeReactFile(componentName, location));
197+
}
198+
if (useStorybook) {
199+
filesArray.push(
200+
makeStorybookFile(componentName, componentTitle, location, directory),
201+
);
202+
}
203+
const success = await Promise.all(filesArray);
204+
if (success) {
205+
console.log(`${componentTitle} created`);
206+
}
207+
}
208+
}
209+
210+
/**
211+
* Initialize a new component
212+
* @return {Promise<void>}
213+
*/
214+
async function init() {
215+
const patternSrc = path.join(process.cwd(), 'source');
216+
const patternDir = await getDirectories(patternSrc);
217+
const componentName = await getMachineName();
218+
const {
219+
componentTitle,
220+
componentFolder,
221+
componentFolderSub,
222+
useEmotion,
223+
useStorybook,
224+
} = await getComponentDetails(componentName, patternDir);
225+
const componentLocation = path.join(
226+
patternSrc,
227+
componentFolder,
228+
machineName(componentFolderSub),
229+
machineName(componentName),
230+
);
231+
const output = `---
232+
Component Name: ${componentName}
233+
Component Title: ${componentTitle}
234+
Component Location: ${componentLocation}
235+
Component Type: ${useEmotion ? 'Emotion' : 'React'}
236+
Include Story?: ${useStorybook ? 'Yes' : 'No'}
237+
`;
238+
console.log(output);
239+
const confirmQuestion = [
240+
{
241+
type: 'confirm',
242+
name: 'confirm',
243+
message: 'Is this what you want?',
244+
},
245+
];
246+
const { confirm } = await inquirer.prompt(confirmQuestion);
247+
if (confirm) {
248+
await createComponent(
249+
componentName,
250+
componentTitle,
251+
componentLocation,
252+
componentFolder,
253+
useEmotion,
254+
useStorybook,
255+
);
256+
} else {
257+
console.log('Component cancelled');
258+
}
259+
}
260+
261+
init().catch(err => {
262+
console.error(err);
263+
});

0 commit comments

Comments
 (0)