|
| 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