├── CHANGELOG.md ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.js ├── copyDeclarations.js ├── defaultConfig.cjs ├── docs ├── arrival.png ├── createcomponentexample.png ├── kontur.png ├── reactcci.xml └── webstormIntegration.md ├── index.ts ├── jest.config.js ├── package.json ├── readme-example.gif ├── rollup.config.mjs ├── src ├── buildComponent.ts ├── checkComponentExistence.ts ├── checkConfig.ts ├── constants.ts ├── generateFiles.ts ├── getFinalAgreement.ts ├── getModuleRootPath.ts ├── getProjectRootPath.ts ├── getQuestionsSettings.ts ├── getTemplate.ts ├── getTemplateFile.ts ├── getTemplateNamesToCreate.ts ├── getTemplateNamesToUpdate.ts ├── helpers.ts ├── initialize.ts ├── parseDestinationPath.ts ├── processAfterGeneration.ts ├── processCommandLineFlags.ts ├── setComponentNames.ts ├── setComponentTemplate.ts ├── setConfig.ts ├── setPath.ts ├── setProject.ts └── types.ts ├── templates ├── class.tmp ├── fc.tmp ├── index.tmp ├── stories.tmp └── test.tmp ├── tests ├── generateFiles.test.ts ├── getFinalAgreement.test.ts ├── getProjectRootPath.test.ts ├── getQuestionsSettings.test.ts ├── getTemplateFile.test.ts ├── getTemplateNamesToCreate.test.ts ├── helpers.test.ts ├── initialize.test.ts ├── setComponentNames.test.ts ├── setComponentTemplate.test.ts ├── setPath.test.ts ├── setProject.test.ts └── testUtils.ts ├── tsconfig.json └── yarn.lock / CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.11.1 2 | * add links to website 3 | 4 | ## 1.11.0 5 | * add checkExistenceOnCreate flag 6 | 7 | ## 1.10.0 8 | * updated default templates file extensions 9 | 10 | ## 1.9.0 11 | * refactoring to make project more stable and strict 12 | 13 | ## 1.8.4 14 | * update templates and config to support "processFileAndFolderName" param 15 | 16 | ## 1.8.3 17 | * add new stringToCase method to placeholder function 18 | 19 | ## 1.8.2 20 | * fix processing commander options 21 | 22 | ## 1.8.1 23 | * fix check files to update with different case 24 | * update dependencies 25 | * update imports in templates 26 | 27 | ## 1.8.0 28 | * fixed resolving placeholders. Avoid unnecessary executions 29 | * afterCreation filepath resolving fix 30 | * new placeholder variables (filePrefix, folderName) 31 | * fixed xml-settings for webstorm 32 | * fixed filename processing for multiple placeholder uses 33 | * fixed processing for snake_case and dash-case 34 | * new command-line flag for vs-rcci 35 | 36 | ## 1.7.6 37 | * New string types for processFileAndFolderName parameter 38 | 39 | ## 1.7.4 40 | * File type selection by command line flag 41 | 42 | ## 1.7.3 43 | * Fix root destination parsing issue 44 | 45 | ## 1.7.0 46 | * Added smart destination path parsing 47 | * Added `--skip-search`, `-s` flags to turn of interactive selection with `--dest` flag 48 | * Added `--files`, `-f` flags to set optional files to skip interactive selection step 49 | * Added `--sls` flag to skip last step like `skipFinalStep` config 50 | 51 | ## 1.6.0 52 | * Removed initialization flag with initialization on first run 53 | 54 | ## 1.5.3 55 | * Added files and folder name processing feature 56 | 57 | ## 1.5.2 58 | * Added template command line flag 59 | 60 | ## 1.5.0 61 | * Added update component feature 62 | 63 | ## 1.4.5 64 | * New improved path search 65 | 66 | ## 1.4.1 67 | * Added join function for placeholders 68 | 69 | ## 1.4.0 70 | * Added multi-component creation feature 71 | 72 | ## 1.3.1 73 | * Added rcci shortcut 74 | 75 | ## 1.3.0 76 | * Added extended template placeholders functionality 77 | 78 | ## 1.2.8 79 | * Fixed template folder name replacing 80 | 81 | ## 1.2.1 82 | * Fixed shorting POSIX paths 83 | 84 | ## 1.2.0 85 | * Added multi-template feature 86 | 87 | ## 1.1.5 88 | * Added template subfolder feature 89 | 90 | ## 1.1.4 91 | * Fixed POSIX path resolving in initialize mode 92 | 93 | ## 1.1.3 94 | * Fixed POSIX path resolving 95 | 96 | ## 1.1.2 97 | * Fixed folderPath processing 98 | 99 | ## 1.1.1 100 | * Fixed afterCreation script check 101 | 102 | ## 1.1.0 103 | * Added configuration mode 104 | 105 | ## 1.0.6 106 | * Fixed size of package 107 | 108 | ## 1.0.5 109 | * Fixed folder path cutter 110 | 111 | ## 1.0.4 112 | * Added folder search feature 113 | * `dist` flag renamed to `dest` 114 | 115 | ## 1.0.3 116 | * Added `name` and `project` command line flags 117 | * Replaced `chalk` to `kleur` because kleur is already used inside prompts 118 | 119 | ## 1.0.2 120 | * Added option to skip final step 121 | 122 | ## 1.0.0 123 | Features: 124 | * interactive creation 125 | * dynamic templates 126 | * afterCreation scripts 127 | * path flag to skip selecting step 128 | * multi-project mode 129 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:promise/recommended', 11 | 'plugin:import/errors', 12 | 'plugin:import/warnings', 13 | 'plugin:import/typescript', 14 | 'prettier', 15 | 'prettier/@typescript-eslint' 16 | ], 17 | parser: '@typescript-eslint/parser', 18 | parserOptions: { 19 | ecmaFeatures: { 20 | jsx: true 21 | }, 22 | ecmaVersion: 2019, 23 | sourceType: 'module' 24 | }, 25 | plugins: ['prettier', '@typescript-eslint', 'promise', 'import'], 26 | settings: { 27 | react: { 28 | version: 'detect' 29 | }, 30 | 'import/resolver': { 31 | typescript: {} 32 | } 33 | }, 34 | rules: { 35 | 'import/order': [ 36 | 'warn', 37 | { 38 | groups: ['external', 'internal', 'unknown', 'builtin', 'parent', 'sibling', 'index'], 39 | 'newlines-between': 'always' 40 | } 41 | ], 42 | 'import/default': 'off', 43 | 'import/first': 'warn', 44 | 'import/no-named-as-default': 'off', 45 | 'import/no-named-as-default-member': 'off', 46 | 'import/named': 'off', 47 | 'import/namespace': 'off', 48 | 'prettier/prettier': 'warn', 49 | '@typescript-eslint/explicit-function-return-type': 'off', 50 | '@typescript-eslint/explicit-module-boundary-types': 'off', 51 | '@typescript-eslint/array-type': 'warn', 52 | '@typescript-eslint/explicit-member-accessibility': [ 53 | 'warn', 54 | { 55 | overrides: { 56 | constructors: 'off' 57 | } 58 | } 59 | ], 60 | '@typescript-eslint/prefer-for-of': 'warn', 61 | '@typescript-eslint/prefer-function-type': 'warn', 62 | '@typescript-eslint/ban-types': 'off', 63 | '@typescript-eslint/ban-ts-comment': 'off', 64 | 'no-trailing-spaces': 'warn', 65 | 'prefer-const': 'warn', 66 | 'comma-dangle': ['warn', 'never'], 67 | curly: 'warn', 68 | 'dot-notation': 'warn', 69 | 'no-var': 'warn', 70 | 'prefer-object-spread': 'warn', 71 | 'prefer-template': 'warn', 72 | 'promise/catch-or-return': 'warn', 73 | 'promise/always-return': 'off', 74 | radix: 'warn', 75 | yoda: 'warn' 76 | }, 77 | overrides: [ 78 | { 79 | files: ['*.js'], 80 | rules: { 81 | 'no-undef': 'off' 82 | } 83 | } 84 | ] 85 | }; 86 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.5.1] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: yarn --frozen-lockfile 28 | - run: yarn typecheck 29 | - run: yarn test 30 | - run: yarn build 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | build 4 | coverage 5 | rcci.config.js 6 | rcci.config.cjs -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "semi": true, 5 | "jsxBracketSameLine": false, 6 | "printWidth": 120, 7 | "trailingComma": "none", 8 | "overrides": [ 9 | { 10 | "files": "*.scss", 11 | "options": { 12 | "singleQuote": false 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2017 Alibaba Group Holding Limited and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React create component interactive CLI 2 | CLI to create or edit **React** or **React Native** components with your own structure 3 | 4 | - **Plug and play**: Instant config for CRA with typescript 5 | - **Easy to config**: interactive configuration and simple templating 6 | - **Great documentation**: [More info on our website](https://kamenskih.gitbook.io/reactcci/) 7 | - **Works with anything**: Typescript, Storybook, Redux, Jest, ... 8 | - **Lightweight**: *< 40kb + 3 small libraries* 9 | - **Fast search**: very fast selecting place for new component by dynamic search 10 | - **A lot by one command**: ton of files by multi-creation feature 11 | - **Created for React**: better than **plop**, **hygen** or any scaffolding library, because it's for React 12 | - **Multiplatform**: Works on MacOS, Windows, and Linux. 13 | - ⭐ [**VSCode Extension**](https://marketplace.visualstudio.com/items?itemName=KamenskikhDmitriy.vs-rcci) ⭐ 14 | - ⭐ [**Webstorm integration**](https://github.com/coolassassin/reactcci/blob/master/docs/webstormIntegration.md) ⭐ 15 | 16 | ![Example](https://raw.githubusercontent.com/coolassassin/reactcci/master/readme-example.gif) 17 | 18 | [![Tests Status](https://github.com/coolassassin/reactcci/workflows/tests/badge.svg)](https://github.com/coolassassin/reactcci) 19 | [![Build Status](https://img.shields.io/npm/dm/reactcci.svg?style=flat)](https://www.npmjs.com/package/reactcci) 20 | [![Version](https://img.shields.io/npm/v/reactcci.svg?style=flat)](https://www.npmjs.com/package/reactcci) 21 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fcoolassassin%2Freactcci.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fcoolassassin%2Freactcci?ref=badge_shield) 22 | 23 | ## Used by 24 | |[![Arrival](https://raw.githubusercontent.com/coolassassin/reactcci/master/docs/arrival.png)](https://arrival.com)|[![Arrival](https://raw.githubusercontent.com/coolassassin/reactcci/master/docs/kontur.png)](https://kontur.ru)| 25 | |---|---| 26 | 27 | ## Quick Overview 28 | Via yarn and interactive mode 29 | ``` 30 | $ yarn add -D reactcci 31 | $ yarn rcci 32 | ``` 33 | or via npm and flags 34 | ``` 35 | $ npm i -D reactcci 36 | $ npx rcci --name Header Body Footer --dest src/App 37 | ``` 38 | 39 | ## Installation 40 | To install via npm: 41 | ```npm install --save-dev reactcci``` 42 | 43 | To install via yarn: 44 | ```yarn add --dev reactcci``` 45 | 46 | ## Config 47 | On first run CLI will ask you about automatic configuration. Just run a command: 48 | `npx rcci` 49 | This command creates file `rcci.config.js` and template folder with basic template set. 50 | 51 | Config file contains next parameters: 52 | - `folderPath` 53 | Root directory to place you components, or array of paths 54 | Default: `src/` 55 | - `templatesFolder` 56 | Path to your own components templates 57 | Default: `templates` 58 | - `multiProject` 59 | Allows you to set up config for mono-repository with several projects 60 | Default: `false` 61 | - `skipFinalStep` 62 | Allows you to switch off last checking step 63 | Default: `false` 64 | - `checkExistenceOnCreate` 65 | Allows you to switch on check component existence on create to avoid replacing 66 | Default: `false` 67 | - `templates` 68 | Object with structure of your component 69 | - `processFileAndFolderName` 70 | String or function which allows you to remap your component name to folder-name and file-name prefix 71 | Default: `PascalCase` 72 | Types: 73 | - `camelCase` 74 | - `PascalCase` 75 | - `snake_case` 76 | - `dash-case` 77 | - `(name: string, parts: string[], isFolder: boolean) => string` 78 | 79 | Example: 80 | ```javascript 81 | { 82 | ...config, 83 | processFileAndFolderName: (name, parts, isFolder) => 84 | isFolder ? name : parts.map((part) => part.toLowerCase()).join('-') 85 | } 86 | ``` 87 | - `placeholders` 88 | List of placeholders which you can use to build your own component template 89 | Default: 90 | `#NAME#` for a component name 91 | `#STYLE#` for css-module import 92 | `#STORY_PATH#` for storybook title 93 | - `afterCreation` 94 | Object with scripts to process you file after creation 95 | Default: `undefined` 96 | Example: 97 | ```javascript 98 | { 99 | ...config, 100 | afterCreation: { 101 | prettier: { 102 | extensions: ['.ts', '.tsx'], // optional 103 | cmd: 'prettier --write [filepath]' 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | ## Placeholders 110 | Each placeholder is a function which get some data to build your own placeholder. 111 | During the multiple component creation all functions will be called for every single component. 112 | Example: 113 | ```javascript 114 | placeholders: { 115 | NAME: ({ componentName }) => componentName 116 | } 117 | ``` 118 | After this set up you are able to use this placeholder by `#NAME#` inside your template files. 119 | Below, you can see the list of all available data and functions to create a new one. 120 | 121 | | Field | Description | 122 | |---|---| 123 | | `project` | Project name in multy-project mode | 124 | | `componentName`,
`objectName` | Name of the component or another object in multi-template mode | 125 | | `objectType` | Type of object which was selected by user. It is `component` by default. | 126 | | `pathToObject` | path to objects folder
Example: `src/components` | 127 | | `destinationFolder` | relative path to folder of object which is being created
Example: `App/Header/Logo` | 128 | | `objectFolder` | Absolute path to your object (component) folder | 129 | | `relativeObjectFolder` | Relative path to your object (component) folder | 130 | | `filePrefix` | processed object(component) name for filename | 131 | | `folderName` | processed object(component) name for folder | 132 | | `files` | Object of files which is being created | 133 | | `getRelativePath(to: string)` | Function to get relative path to any another path
Example: `../../src/helpers` | 134 | | `join(...parts: string)` | Function to get joined parts of path.
Example:
`join(project, destinationFolder, componentName)` => `Project/Footer/Email` | 135 | | `stringToCase(str: string, toCase: string)` | Function to map any string to case.
Example:
`stringToCase('dash-case-string', 'PascalCase')` => `DashCaseString` | 136 | 137 | 138 | ## Arguments 139 | - `--update`, `-u` 140 | To activate update mode to add new files to component or to replace existing 141 | - `--dest`, `-d` 142 | To set absolute or destination path to create or update component 143 | - `--skip-search`, `-s` 144 | To skip interactive folder selection to use `dest` argument as is 145 | - `--name`, `-n` 146 | To set component name or names divided by space to create several 147 | - `--files`, `-f` 148 | To set files to create or update, instead interactive selection 149 | Example: `--files style test component[1]` 150 | Will create optional files like `style` and `test` and will select second type of component by index 151 | - `--project`, `-p` 152 | To set project in multi-project mode 153 | - `--sls` 154 | To skip last agreement step like `skipFinalStep` in config file 155 | 156 | ## Multi-template 157 | If you need to generate something else, not components only, you are able to set up array of templates. 158 | Example bellow: 159 | ```javascript 160 | { 161 | ...config, 162 | templates: [ 163 | { 164 | name: 'component', // will be added to default folderPath folder 165 | files: { 166 | index: { 167 | name: 'index.ts', 168 | file: 'component.ts' 169 | } 170 | } 171 | }, 172 | { 173 | name: 'service', 174 | folderPath: 'services/', // will be added by the specific path 175 | files: { 176 | index: { 177 | name: 'index.ts', 178 | file: 'service.ts' 179 | } 180 | } 181 | } 182 | ] 183 | } 184 | ``` 185 | 186 | ## License 187 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fcoolassassin%2Freactcci.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fcoolassassin%2Freactcci?ref=badge_large) 188 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | if (api) { 3 | api.cache(true); 4 | } 5 | 6 | return { 7 | presets: ['@babel/preset-typescript', '@babel/preset-env'] 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /copyDeclarations.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | fs.mkdirSync(path.resolve(__dirname, 'build'), { recursive: true }); 5 | fs.copyFile(path.resolve(__dirname, 'src/types.ts'), path.resolve(__dirname, 'build/types.d.ts'), (err) => { 6 | if (err) { 7 | console.error('Error, during copying the declarations file'); 8 | } 9 | }); -------------------------------------------------------------------------------- /defaultConfig.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type import("reactcci/build/types").Config 3 | */ 4 | module.exports = { 5 | multiProject: false /* Enable searching projects with component folder path */, 6 | skipFinalStep: false /* Toggle final step agreement */, 7 | checkExistenceOnCreate: false /* Enable check folder for components which can be replaced */, 8 | folderPath: 'src/' /* Destination path or array of paths to create components */, 9 | templatesFolder: 'templates' /* Folder with templates */, 10 | templates: [ 11 | { 12 | name: 'component', 13 | files: { 14 | /* Component folder structure declaration */ 15 | index: { 16 | name: 'index.ts', 17 | file: 'index.tmp' 18 | }, 19 | component: { 20 | name: '[name].tsx', 21 | file: [ 22 | { name: 'fc.tmp', description: 'Functional component' }, 23 | { name: 'class.tmp', description: 'Class component' } 24 | ] 25 | }, 26 | style: { 27 | name: '[name].module.css', 28 | optional: true 29 | }, 30 | stories: { 31 | name: '[name].stories.tsx', 32 | file: 'stories.tmp', 33 | optional: true, 34 | default: false 35 | }, 36 | test: { 37 | name: '[name].test.tsx' /*'__tests__/[name].test.tsx' to put tests into subfolder*/, 38 | file: 'test.tmp', 39 | optional: true, 40 | default: false 41 | } 42 | } 43 | } 44 | ], 45 | placeholders: { 46 | /* Template placeholders */ 47 | NAME: ({ componentName }) => componentName, 48 | COMPONENT_FILE_PREFIX: ({ filePrefix }) => filePrefix, 49 | STYLE: ({ files }) => (files.style ? `import styles from './${files.style.name}';\n` : ''), 50 | STORY_PATH: ({ join, project, destinationFolder, componentName }) => 51 | join(project, destinationFolder, componentName) 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /docs/arrival.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolassassin/reactcci/58a6b2d2e006f2efcef7534847441e0b2c0ee0ba/docs/arrival.png -------------------------------------------------------------------------------- /docs/createcomponentexample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolassassin/reactcci/58a6b2d2e006f2efcef7534847441e0b2c0ee0ba/docs/createcomponentexample.png -------------------------------------------------------------------------------- /docs/kontur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolassassin/reactcci/58a6b2d2e006f2efcef7534847441e0b2c0ee0ba/docs/kontur.png -------------------------------------------------------------------------------- /docs/reactcci.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/webstormIntegration.md: -------------------------------------------------------------------------------- 1 | # Webstorm integration 2 | Using [reactcci](https://github.com/coolassassin/reactcci) with Webstorm or any another product by [JetBrains](https://www.jetbrains.com) you are able to create [External Tools](https://www.jetbrains.com/help/webstorm/configuring-third-party-tools.html#web-browsers) to make scaffolding with reactcci more easier. 3 | ## Quick setup 4 | You can set up configuration manually, but you can download settings file instead. 5 | Open folder according to your operating system: 6 | - **macOS**: *~/Library/Application Support/JetBrains/[product][version]/tools* 7 | - **Linux**: *~/.config/JetBrains/[product][version]/tools* 8 | - **Windows**: *%APPDATA%/JetBrains/[product][version]/tools* 9 | 10 | For more information read [the article](https://www.jetbrains.com/help/webstorm/configuring-project-and-ide-settings.html#restore-defaults). 11 | 12 | After that, download [**config file**](https://github.com/coolassassin/reactcci/raw/master/docs/reactcci.xml) and place in this folder like this: 13 | *.../JetBrains/Webstorm2021.3/tools/reactcci.xml* 14 | 15 | After that click on components' folder with right mouse button and in new "reactcci" menu choose what you need 16 | 17 | ## Manual setup 18 | Make few simple steps to set up a new tool: 19 | 1. Open Settings in menu `File -> Settings` (`Ctrl+Alt+S`, `⌘+Alt+S`) 20 | 2. Open External Tools menu `Tools -> External Tools` 21 | 3. Press the plus button ➕ 22 | 4. Give the name for you new tool. For example "Create new component". 23 | 5. Fill the `Program` field with `node` 24 | 6. Fill the `Working directory` field with next command: `$ProjectFileDir$` 25 | 7. The last step is filling `Arguments` field. How to make necessary command will be described further. 26 | 27 | ## Arguments 28 | ### Create component 29 | Simple example for creating component looks like this: 30 | `./node_modules/reactcci/build/cli.js --dest "$FileDir$" --name $Prompt$ --files no -s --sls` 31 | Looks a bit complex but let's try to figure what is going on here. 32 | 1. First part is the path to main script of CLI: 33 | `./node_modules/reactcci/build/cli.js` 34 | 2. Next part is `--dest "$FileDir$"` 35 | `--dest` means destination, you can make it shorter via `-d` 36 | `$FileDir$` means current directory in project explorer, we need quotes here because path can contain spaces. 37 | 3. The third argument is name of you component or components `--name` 38 | `$Prompt$` opens window with text field to type there your component name or several divided by space 39 | 4. The fourth is `--files`. We place here `no`, it means that we don't need any optional files. 40 | 5. Next is `-s`. It is shortcut for `--skip-search`. It helps us to select exact folder in second argument without further selecting. 41 | 6. The last one is `--sls`. It means "skip last step". It is analogue for config flag `skipFinalStep` 42 | 43 | So the result will be look like this: 44 | ![Example](https://raw.githubusercontent.com/coolassassin/reactcci/master/docs/createcomponentexample.png) 45 | 46 | Now, you can click on any folder in your project by right button and in context menu will be able next command: `External Tools -> Create new component`. 47 | Click and enjoy the magic ✨ 48 | 49 | ### Create component with optional files 50 | If you want to be able to create component with style file or with tests file you can modify the `--files` flag. 51 | You can type any files divided by space. 52 | For example: `--files style stories test` 53 | 54 | ### Update existing component 55 | Creating component is not the one feature for reactcci. You can update component by adding styles file for example. 56 | Copy you external tool and name it somehow like that: `Add styles` 57 | Then to set the update mode, we need add just on a new flag: `--update` or short variant `-u`. 58 | More than that, in this case we don't need `--name` flag because our folder is the name of our component. 59 | So, our arguments will be look like that: 60 | `./node_modules/reactcci/build/cli.js --dest "$FileDir$" --files styles --update --skip-search --sls` 61 | Easy way to try this is to find an existing component, right-click on it and `External Tools -> Add styles`. 62 | Few moments and we have new file ✨ 63 | 64 | ## Bonus 65 | After all this you are able to add hot key for this. Open Settings again: 66 | `File -> Settings` (`Ctrl+Alt+S`, `⌘+Alt+S`) 67 | Then open `Keymap -> External tools -> External tools -> [your tool]`, right click and then add keyboard or mouse shortcut. 68 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import kleur from 'kleur'; 4 | 5 | import { checkComponentExistence } from './src/checkComponentExistence'; 6 | import { setPath } from './src/setPath'; 7 | import { setConfig } from './src/setConfig'; 8 | import { setProject } from './src/setProject'; 9 | import { initialize } from './src/initialize'; 10 | import { checkConfig } from './src/checkConfig'; 11 | import { buildComponent } from './src/buildComponent'; 12 | import { processCommandLineFlags } from './src/processCommandLineFlags'; 13 | import { getModuleRootPath } from './src/getModuleRootPath'; 14 | import { setComponentTemplate } from './src/setComponentTemplate'; 15 | import { parseDestinationPath } from './src/parseDestinationPath'; 16 | 17 | (async () => { 18 | try { 19 | const root = process.cwd(); 20 | const moduleRoot = getModuleRootPath(); 21 | const commandLineFlags = processCommandLineFlags(); 22 | 23 | await initialize({ root, moduleRoot, commandLineFlags }); 24 | 25 | let config = await setConfig({ root }); 26 | await checkConfig({ config }); 27 | const { config: newConfig, templateName } = await setComponentTemplate({ commandLineFlags, config }); 28 | config = newConfig; 29 | 30 | const parsedPath = await parseDestinationPath({ 31 | root, 32 | commandLineFlags, 33 | config 34 | }); 35 | const project = await setProject({ project: parsedPath.project, root, commandLineFlags, config, templateName }); 36 | 37 | const { componentNames, resultPath, projectRootPath } = await setPath({ 38 | root, 39 | commandLineFlags, 40 | config, 41 | project, 42 | templateName, 43 | resultPathInput: parsedPath.resultPath, 44 | projectRootPathInput: parsedPath.projectRootPath 45 | }); 46 | 47 | await checkComponentExistence({ componentNames, commandLineFlags, resultPath, projectRootPath, config }); 48 | 49 | await buildComponent({ 50 | root, 51 | moduleRoot, 52 | commandLineFlags, 53 | config, 54 | project, 55 | templateName, 56 | componentNames, 57 | resultPath, 58 | projectRootPath 59 | }); 60 | } catch (e) { 61 | console.error(kleur.red('Unexpected error'), e); 62 | process.exit(); 63 | } 64 | })(); 65 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | testPathIgnorePatterns: ['/templates'] 3 | }; 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactcci", 3 | "version": "1.14.0", 4 | "type": "commonjs", 5 | "description": "React create component CLI", 6 | "author": "Kamenskikh Dmitrii ", 7 | "license": "MIT", 8 | "main": "index.ts", 9 | "repository": "git@github.com:coolassassin/reactcci.git", 10 | "homepage": "https://kamenskih.gitbook.io/reactcci", 11 | "bugs": { 12 | "url": "https://github.com/coolassassin/reactcci/issues", 13 | "email": "coolstreetassassin@gmail.com" 14 | }, 15 | "bin": { 16 | "reactcci": "./build/cli.js", 17 | "rcci": "./build/cli.js" 18 | }, 19 | "scripts": { 20 | "start": "babel-node -x .ts index.ts", 21 | "build": "rollup -c && node copyDeclarations.js", 22 | "typecheck": "tsc --noEmit", 23 | "run-build": "node build/cli.js", 24 | "prettier": "prettier --write src/*.ts index.ts", 25 | "test": "jest", 26 | "coverage": "yarn test --coverage --collectCoverageFrom=./src/**" 27 | }, 28 | "dependencies": { 29 | "commander": "^11.1.0", 30 | "kleur": "^4.1.5", 31 | "prompts": "^2.4.2" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.23.7", 35 | "@babel/node": "^7.22.19", 36 | "@babel/preset-env": "^7.23.7", 37 | "@babel/preset-typescript": "^7.23.3", 38 | "@rollup/plugin-babel": "^6.0.4", 39 | "@rollup/plugin-commonjs": "^25.0.7", 40 | "@rollup/plugin-node-resolve": "^15.2.3", 41 | "@types/jest": "^29.5.11", 42 | "@types/mock-fs": "^4.13.4", 43 | "@types/node": "^20.10.6", 44 | "@types/prompts": "^2.4.9", 45 | "@typescript-eslint/eslint-plugin": "^6.16.0", 46 | "@typescript-eslint/parser": "^6.16.0", 47 | "babel-jest": "^29.7.0", 48 | "babelrc-rollup": "^3.0.0", 49 | "eslint": "^8.56.0", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-import-resolver-typescript": "^3.6.1", 52 | "eslint-plugin-import": "^2.29.1", 53 | "eslint-plugin-prettier": "^5.1.2", 54 | "eslint-plugin-promise": "^6.1.1", 55 | "jest": "^29.7.0", 56 | "mock-fs": "^5.2.0", 57 | "prettier": "^3.1.1", 58 | "rollup": "^4.9.2", 59 | "rollup-plugin-hashbang": "^3.0.0", 60 | "rollup-plugin-terser": "^7.0.2", 61 | "typescript": "^5.3.3" 62 | }, 63 | "files": [ 64 | "defaultConfig.cjs", 65 | "package.json", 66 | "templates", 67 | "build", 68 | "README.md" 69 | ], 70 | "publishConfig": { 71 | "registry": "https://registry.npmjs.org/" 72 | }, 73 | "browserslist": [ 74 | "node 18" 75 | ], 76 | "keywords": [ 77 | "react", 78 | "component", 79 | "create", 80 | "cli", 81 | "generate", 82 | "configurable", 83 | "gen", 84 | "scaffolding", 85 | "make", 86 | "tool", 87 | "dev", 88 | "build", 89 | "create react component", 90 | "rcci" 91 | ] 92 | } 93 | -------------------------------------------------------------------------------- /readme-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coolassassin/reactcci/58a6b2d2e006f2efcef7534847441e0b2c0ee0ba/readme-example.gif -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import hashbang from 'rollup-plugin-hashbang'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import cjs from '@rollup/plugin-commonjs'; 6 | 7 | const extensions = ['.ts', '.js']; 8 | 9 | export default { 10 | input: 'index.ts', 11 | output: { 12 | file: './build/cli.js', 13 | format: 'cjs', 14 | inlineDynamicImports: true 15 | }, 16 | plugins: [ 17 | resolve({ extensions }), 18 | babel({ extensions: ['.ts', '.js'], exclude: './node_modules/**' }), 19 | cjs(), 20 | // We call default, because hashbang plugin has broken export 21 | hashbang.default(), 22 | terser() 23 | ], 24 | external: ['path', 'fs', 'child_process', 'kleur', 'prompts', 'commander'], 25 | }; 26 | -------------------------------------------------------------------------------- /src/buildComponent.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | import path from 'path'; 4 | 5 | import { getTemplateNamesToCreate } from './getTemplateNamesToCreate'; 6 | import { generateFiles } from './generateFiles'; 7 | import { getTemplateFile } from './getTemplateFile'; 8 | import { getFinalAgreement } from './getFinalAgreement'; 9 | import { processAfterGeneration } from './processAfterGeneration'; 10 | import { CommandLineFlags, ComponentFileList, Config, FilesList, Project, TemplateDescriptionObject } from './types'; 11 | import { capitalizeName, generateFileName, getIsFileAlreadyExists, writeToConsole } from './helpers'; 12 | import { getTemplateNamesToUpdate } from './getTemplateNamesToUpdate'; 13 | 14 | type Properties = { 15 | root: string; 16 | moduleRoot: string; 17 | commandLineFlags: CommandLineFlags; 18 | config: Config; 19 | project: Project; 20 | templateName: string; 21 | componentNames: string[]; 22 | projectRootPath: string; 23 | resultPath: string; 24 | }; 25 | 26 | export const buildComponent = async (properties: Properties) => { 27 | const { commandLineFlags, config, project, templateName, componentNames, projectRootPath, resultPath } = properties; 28 | const { processFileAndFolderName } = config; 29 | 30 | const templateNames = commandLineFlags.update 31 | ? await getTemplateNamesToUpdate(properties) 32 | : await getTemplateNamesToCreate(properties); 33 | 34 | const fileList: FilesList = {}; 35 | 36 | for (const [templateName, { name, file }] of Object.entries(config.templates as TemplateDescriptionObject)) { 37 | const isTemplateSelected = templateNames.includes(templateName); 38 | if (Array.isArray(file) && isTemplateSelected) { 39 | const selectedFile = await getTemplateFile({ commandLineFlags, name: templateName, files: file }); 40 | fileList[templateName] = { 41 | name, 42 | file: selectedFile.name, 43 | type: selectedFile.description, 44 | selected: isTemplateSelected 45 | }; 46 | } else { 47 | fileList[templateName] = { name, file: file as string, selected: isTemplateSelected }; 48 | } 49 | } 50 | 51 | const componentFileList: ComponentFileList = {}; 52 | 53 | for (const componentName of componentNames) { 54 | componentFileList[componentName] = Object.fromEntries( 55 | Object.entries(fileList) 56 | .filter(([, fileObject]) => { 57 | return ( 58 | fileObject.selected || 59 | getIsFileAlreadyExists({ 60 | ...properties, 61 | fileNameTemplate: fileObject.name, 62 | objectName: componentName, 63 | processFileAndFolderName 64 | }) 65 | ); 66 | }) 67 | .map(([tmpName, fileObject]) => [ 68 | tmpName, 69 | { 70 | ...fileObject, 71 | name: generateFileName({ 72 | fileNameTemplate: fileObject.name, 73 | objectName: componentName, 74 | processFileAndFolderName 75 | }) 76 | } 77 | ]) 78 | ); 79 | } 80 | 81 | if (!config.skipFinalStep) { 82 | for (const componentName of componentNames) { 83 | if (commandLineFlags.update) { 84 | writeToConsole(`\nUpdating ${templateName} ${kleur.yellow(componentName)}`); 85 | } else { 86 | writeToConsole(`\nCreating ${templateName} ${kleur.yellow(componentName)}`); 87 | } 88 | writeToConsole( 89 | `Files:\n${Object.entries(componentFileList[componentName]) 90 | .filter(([, options]) => options.selected) 91 | .map( 92 | ([tmp, options]) => 93 | `- ${tmp}${options.type ? ` (${kleur.yellow(options.type)})` : ''}${kleur.gray( 94 | ` - ${options.name}` 95 | )}` 96 | ) 97 | .join('\n')}` 98 | ); 99 | } 100 | writeToConsole(`\nFolder: ${kleur.yellow(path.join(project, projectRootPath, resultPath))}`); 101 | } 102 | 103 | if (config.skipFinalStep || commandLineFlags.sls || (await getFinalAgreement())) { 104 | await generateFiles({ 105 | ...properties, 106 | componentFileList 107 | }); 108 | await processAfterGeneration({ 109 | ...properties, 110 | componentFileList 111 | }); 112 | const verb = componentNames.length > 1 ? 's are ' : ` is `; 113 | const action = commandLineFlags.update ? 'updated' : 'created'; 114 | writeToConsole(kleur.green(`\n${capitalizeName(templateName)}${verb}${action}!!! \\(•◡ •)/ `)); 115 | } else { 116 | writeToConsole("No? Let's build another one! (◉ ◡ ◉ )"); 117 | } 118 | }; 119 | -------------------------------------------------------------------------------- /src/checkComponentExistence.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | import { getQuestionsSettings } from './getQuestionsSettings'; 7 | import { CommandLineFlags, Config } from './types'; 8 | 9 | type Properties = { 10 | componentNames: string[]; 11 | projectRootPath: string; 12 | resultPath: string; 13 | commandLineFlags: CommandLineFlags; 14 | config: Config; 15 | }; 16 | 17 | export const checkComponentExistence = async ({ 18 | componentNames, 19 | projectRootPath, 20 | resultPath, 21 | commandLineFlags: { update }, 22 | config: { checkExistenceOnCreate } 23 | }: Properties) => { 24 | if (!checkExistenceOnCreate || update) { 25 | return; 26 | } 27 | const components = componentNames.map((name) => ({ 28 | name, 29 | exist: fs.existsSync(path.resolve(projectRootPath, resultPath, name)) 30 | })); 31 | const existComponentCount = components.reduce((acc, { exist }) => acc + (exist ? 1 : 0), 0); 32 | 33 | if (existComponentCount <= 0) { 34 | return; 35 | } 36 | 37 | const { agree } = await Prompt( 38 | { 39 | type: 'toggle', 40 | name: 'agree', 41 | message: `Component${existComponentCount > 1 ? 's' : ''} ${components 42 | .filter((t) => t.exist) 43 | .map((t) => t.name) 44 | .join(', ')} ${existComponentCount > 1 ? 'are' : 'is'} already exist. Do you want to replace?`, 45 | initial: false, 46 | active: 'Yes', 47 | inactive: 'No' 48 | }, 49 | getQuestionsSettings() 50 | ); 51 | 52 | if (!agree) { 53 | process.exit(); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/checkConfig.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | import { Config, TypingCases } from './types'; 4 | 5 | type Properties = { 6 | config: Config; 7 | }; 8 | 9 | export const checkConfig = async ({ config: { templates, afterCreation, processFileAndFolderName } }: Properties) => { 10 | const stopProgram = () => { 11 | process.exit(1); 12 | }; 13 | 14 | if (Array.isArray(templates)) { 15 | if (templates.some((tmp) => !tmp.name)) { 16 | console.error(kleur.red(`Template name must be declared`)); 17 | stopProgram(); 18 | } 19 | if (templates.some((tmp) => templates.filter((t) => t.name === tmp.name).length > 1)) { 20 | console.error(kleur.red(`Template name must be unique, please revise config file`)); 21 | stopProgram(); 22 | } 23 | } 24 | 25 | if (processFileAndFolderName && typeof processFileAndFolderName !== 'function') { 26 | const cases: TypingCases[] = ['camelCase', 'PascalCase', 'snake_case', 'dash-case']; 27 | 28 | if (!cases.some((c) => c === processFileAndFolderName)) { 29 | console.error( 30 | kleur.red( 31 | `Unknown config type in "processFileAndFolderName" field: ${kleur.yellow(processFileAndFolderName)}` 32 | ) 33 | ); 34 | console.error(`Available cases:\n- ${cases.join('\n- ')}`); 35 | stopProgram(); 36 | } 37 | } 38 | 39 | if (afterCreation) { 40 | for (const [type, command] of Object.entries(afterCreation)) { 41 | if (!command.cmd) { 42 | console.error(kleur.red(`Undeclared "cmd" option for afterCreation script ${kleur.yellow(type)}`)); 43 | stopProgram(); 44 | } 45 | if (command.extensions && !Array.isArray(command.extensions)) { 46 | console.error( 47 | kleur.red(`The option "extension" for afterCreation script ${kleur.yellow(type)} must be an array`) 48 | ); 49 | stopProgram(); 50 | } 51 | if (!command.cmd.includes('[filepath]')) { 52 | console.error(kleur.red(`Wrong "cmd" option for afterCreation script ${kleur.yellow(type)}`)); 53 | stopProgram(); 54 | } 55 | } 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const OLD_CONFIG_FILE_NAME = 'rcci.config.js'; 2 | export const CONFIG_FILE_NAME = 'rcci.config.cjs'; 3 | export const MODULE_NAME = 'reactcci'; 4 | 5 | export const TEMPLATE_NAMES_SELECTING_INSTRUCTIONS = 6 | '\nOptions:\n' + 7 | ' ↑/↓: Select file\n' + 8 | ' ←/→/[Space]: Check/uncheck file\n' + 9 | ' a: Select all\n' + 10 | ' Enter/Return: End settings\n'; 11 | -------------------------------------------------------------------------------- /src/generateFiles.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { getTemplate } from './getTemplate'; 5 | import { getRelativePath, processPath, processObjectName, mapNameToCase, getIsItemExists } from './helpers'; 6 | import { ComponentFileList, Config, Project, templatePlaceholdersData, TypingCases } from './types'; 7 | 8 | const isTypingCase = (value: string): value is TypingCases => { 9 | return (['camelCase', 'PascalCase', 'snake_case', 'dash-case'] satisfies TypingCases[]).includes(value as any); 10 | }; 11 | 12 | type Properties = { 13 | root: string; 14 | moduleRoot: string; 15 | config: Config; 16 | project: Project; 17 | componentNames: string[]; 18 | templateName: string; 19 | projectRootPath: string; 20 | resultPath: string; 21 | componentFileList: ComponentFileList; 22 | }; 23 | 24 | export const generateFiles = async ({ 25 | root, 26 | moduleRoot, 27 | config, 28 | project, 29 | templateName, 30 | componentNames, 31 | projectRootPath, 32 | resultPath, 33 | componentFileList 34 | }: Properties) => { 35 | const { processFileAndFolderName } = config; 36 | 37 | for (const componentName of componentNames) { 38 | const fileList = componentFileList[componentName]; 39 | const folder = path.join( 40 | root, 41 | project, 42 | projectRootPath, 43 | resultPath, 44 | processObjectName({ name: componentName, isFolder: true, processFileAndFolderName }) 45 | ); 46 | 47 | if (!(await getIsItemExists(folder))) { 48 | await fs.promises.mkdir(folder); 49 | } 50 | 51 | const objectFolder = processPath(path.resolve(root, project, projectRootPath, resultPath, componentName)); 52 | 53 | const dataForTemplate: templatePlaceholdersData = { 54 | project, 55 | componentName, // backward compatibility name 56 | objectName: componentName, 57 | objectType: templateName, 58 | pathToObject: processPath(path.join(project, projectRootPath)), 59 | destinationFolder: processPath(resultPath), 60 | objectFolder, 61 | relativeObjectFolder: processPath(path.join(project, projectRootPath, resultPath, componentName)), 62 | filePrefix: processObjectName({ name: componentName, isFolder: false, processFileAndFolderName }), 63 | folderName: processObjectName({ name: componentName, isFolder: true, processFileAndFolderName }), 64 | files: fileList, 65 | getRelativePath: (to: string) => getRelativePath({ root, from: objectFolder, to }), 66 | join: (...parts: string[]) => processPath(path.join(...parts)), 67 | stringToCase: (str: string, toCase: string) => { 68 | if (isTypingCase(toCase)) { 69 | return mapNameToCase(str, toCase); 70 | } 71 | throw new Error('Unknown case'); 72 | } 73 | }; 74 | 75 | for (const fileOptions of Object.values(fileList)) { 76 | if (!fileOptions.selected) { 77 | continue; 78 | } 79 | const pathParts = path 80 | .join(fileOptions.name) 81 | .split(path.sep) 82 | .filter((part) => part); 83 | const fileName = pathParts[pathParts.length - 1]; 84 | const subFolders = pathParts.slice(0, pathParts.length - 1); 85 | if (subFolders.length > 0) { 86 | for (let index = 0; index < subFolders.length; index++) { 87 | const currentFolder = path.join(folder, ...subFolders.slice(0, index + 1)); 88 | if (!fs.existsSync(currentFolder)) { 89 | await fs.promises.mkdir(currentFolder); 90 | } 91 | } 92 | dataForTemplate.getRelativePath = (to: string) => 93 | getRelativePath({ root, from: path.resolve(objectFolder, subFolders.join('/')), to }); 94 | } 95 | const template = fileOptions.file 96 | ? (await getTemplate({ 97 | root, 98 | moduleRoot, 99 | fileName: fileOptions.file, 100 | insertionData: dataForTemplate, 101 | config 102 | })) ?? '' 103 | : ''; 104 | await fs.promises.writeFile(path.join(folder, ...subFolders, fileName), template); 105 | } 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /src/getFinalAgreement.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | 3 | import { getQuestionsSettings } from './getQuestionsSettings'; 4 | 5 | export const getFinalAgreement = async () => { 6 | const { agree } = await Prompt( 7 | { 8 | type: 'toggle', 9 | name: 'agree', 10 | message: 'Is everything correct?', 11 | initial: true, 12 | active: 'Yes', 13 | inactive: 'No' 14 | }, 15 | getQuestionsSettings() 16 | ); 17 | 18 | return agree; 19 | }; 20 | -------------------------------------------------------------------------------- /src/getModuleRootPath.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { MODULE_NAME } from './constants'; 4 | 5 | export const getModuleRootPath = () => { 6 | const executableFilePathParts = process.argv[1].split(path.sep); 7 | const nodeModulesFolderIndex = executableFilePathParts.findIndex((part) => part === 'node_modules'); 8 | if (nodeModulesFolderIndex === -1) { 9 | return executableFilePathParts.slice(0, executableFilePathParts.length - 2).join(path.sep); 10 | } 11 | return [...executableFilePathParts.slice(0, nodeModulesFolderIndex + 1), MODULE_NAME].join(path.sep); 12 | }; 13 | -------------------------------------------------------------------------------- /src/getProjectRootPath.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | 3 | import { getQuestionsSettings } from './getQuestionsSettings'; 4 | 5 | export const getProjectRootPath = async (paths: string[]): Promise => { 6 | const { path } = await Prompt( 7 | { 8 | type: 'select', 9 | name: 'path', 10 | message: `Select path to create component`, 11 | hint: 'Select using arrows and press Enter', 12 | choices: paths.map( 13 | (path): Prompt.Choice => ({ 14 | title: path.split('/').reverse().find(Boolean) ?? '', 15 | value: path, 16 | description: path 17 | }) 18 | ), 19 | initial: 0 20 | }, 21 | getQuestionsSettings() 22 | ); 23 | 24 | return path; 25 | }; 26 | -------------------------------------------------------------------------------- /src/getQuestionsSettings.ts: -------------------------------------------------------------------------------- 1 | export const getQuestionsSettings = () => { 2 | return { 3 | onCancel: () => { 4 | process.exit(); 5 | } 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/getTemplate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { Config, templatePlaceholdersData } from './types'; 5 | 6 | type Properties = { 7 | root: string; 8 | moduleRoot: string; 9 | fileName: string; 10 | insertionData: templatePlaceholdersData; 11 | config: Config; 12 | }; 13 | 14 | export const getTemplate = async ({ 15 | root, 16 | moduleRoot, 17 | fileName, 18 | insertionData, 19 | config: { templatesFolder, placeholders } 20 | }: Properties) => { 21 | const defaultTemplatesFolder = path.resolve(moduleRoot, 'templates'); 22 | const templatesPath = (await fs.existsSync(path.join(root, templatesFolder))) 23 | ? path.resolve(root, templatesFolder) 24 | : defaultTemplatesFolder; 25 | 26 | const templateFilePath = path.resolve(templatesPath, fileName); 27 | 28 | if (!fs.existsSync(templateFilePath)) { 29 | return ''; 30 | } 31 | 32 | let templateData = (await fs.promises.readFile(templateFilePath)).toString(); 33 | Object.entries(placeholders).forEach(([placeholder, replacer]) => { 34 | const placeholderRegular = new RegExp(`#${placeholder}#`, 'gim'); 35 | if (placeholderRegular.test(templateData)) { 36 | templateData = templateData.replace(new RegExp(`#${placeholder}#`, 'gim'), replacer(insertionData)); 37 | } 38 | }); 39 | 40 | return templateData; 41 | }; 42 | -------------------------------------------------------------------------------- /src/getTemplateFile.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | import kleur from 'kleur'; 3 | 4 | import { getQuestionsSettings } from './getQuestionsSettings'; 5 | import { CommandLineFlags, FileOption } from './types'; 6 | import { getFileIndexForTemplate } from './helpers'; 7 | 8 | type Properties = { 9 | name: string; 10 | files: FileOption[]; 11 | commandLineFlags: CommandLineFlags; 12 | }; 13 | 14 | export const getTemplateFile = async ({ name, files, commandLineFlags }: Properties) => { 15 | if (commandLineFlags.files) { 16 | const index = getFileIndexForTemplate(commandLineFlags.files, name); 17 | 18 | if (typeof index !== 'undefined') { 19 | if (!files[index]) { 20 | console.error(kleur.red(`Error: ${kleur.yellow(index)} is incorrect index for ${kleur.yellow(name)}`)); 21 | console.error(`Max value is: ${kleur.yellow(files.length - 1)}`); 22 | process.exit(); 23 | } 24 | 25 | return files[index]; 26 | } 27 | } 28 | 29 | const { file } = await Prompt( 30 | { 31 | type: 'select', 32 | name: 'file', 33 | message: `Select type of ${kleur.reset().yellow(name)} file`, 34 | hint: 'Select using arrows and press Enter', 35 | choices: files.map((file) => ({ title: file.description, value: file })), 36 | initial: 0 37 | }, 38 | getQuestionsSettings() 39 | ); 40 | 41 | return file; 42 | }; 43 | -------------------------------------------------------------------------------- /src/getTemplateNamesToCreate.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | 3 | import { getQuestionsSettings } from './getQuestionsSettings'; 4 | import { TEMPLATE_NAMES_SELECTING_INSTRUCTIONS } from './constants'; 5 | import { getFileTemplates } from './helpers'; 6 | import { CommandLineFlags, Config } from './types'; 7 | 8 | type Properties = { 9 | commandLineFlags: CommandLineFlags; 10 | config: Config; 11 | }; 12 | 13 | export const getTemplateNamesToCreate = async ({ commandLineFlags, config: { templates } }: Properties) => { 14 | const { fileTemplates, undefinedFileTemplates, requiredTemplateNames } = getFileTemplates({ 15 | commandLineFlags, 16 | templates 17 | }); 18 | 19 | let selectedTemplateNames: string[] = []; 20 | if (commandLineFlags.files) { 21 | if (undefinedFileTemplates.length > 0) { 22 | console.error('Error: Undefined file templates:'); 23 | console.error(undefinedFileTemplates.join('\n')); 24 | process.exit(); 25 | return; 26 | } 27 | if (!fileTemplates.includes('no')) { 28 | selectedTemplateNames = fileTemplates; 29 | } 30 | } else { 31 | const templesToSelect = Object.entries(templates).filter(([, options]) => options.optional); 32 | if (templesToSelect.length) { 33 | selectedTemplateNames = ( 34 | await Prompt( 35 | { 36 | type: 'multiselect', 37 | name: 'templateNames', 38 | message: 'Select files to generate', 39 | instructions: TEMPLATE_NAMES_SELECTING_INSTRUCTIONS, 40 | choices: templesToSelect.map(([name, options]) => { 41 | const { default: selected = true } = options; 42 | return { title: name, value: name, selected }; 43 | }) 44 | }, 45 | getQuestionsSettings() 46 | ) 47 | ).templateNames; 48 | } 49 | } 50 | 51 | return [...requiredTemplateNames, ...selectedTemplateNames]; 52 | }; 53 | -------------------------------------------------------------------------------- /src/getTemplateNamesToUpdate.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | import kleur from 'kleur'; 3 | 4 | import { getQuestionsSettings } from './getQuestionsSettings'; 5 | import { TEMPLATE_NAMES_SELECTING_INSTRUCTIONS } from './constants'; 6 | import { CommandLineFlags, Config, Project, TemplateDescriptionObject } from './types'; 7 | import { generateFileName, getFileTemplates, getIsFileAlreadyExists } from './helpers'; 8 | 9 | type Properties = { 10 | root: string; 11 | commandLineFlags: CommandLineFlags; 12 | config: Config; 13 | project: Project; 14 | componentNames: string[]; 15 | resultPath: string; 16 | projectRootPath: string; 17 | }; 18 | 19 | export const getTemplateNamesToUpdate = async ({ 20 | root, 21 | commandLineFlags, 22 | config: { templates, processFileAndFolderName }, 23 | project, 24 | componentNames, 25 | resultPath, 26 | projectRootPath 27 | }: Properties) => { 28 | const componentName = componentNames[0]; 29 | const { fileTemplates, undefinedFileTemplates } = getFileTemplates({ commandLineFlags, templates }); 30 | 31 | if (commandLineFlags.files) { 32 | if (undefinedFileTemplates.length > 0) { 33 | console.error('Error: Undefined file templates:'); 34 | console.error(undefinedFileTemplates.join('\n')); 35 | process.exit(); 36 | return; 37 | } 38 | 39 | return fileTemplates; 40 | } 41 | 42 | const choices = Object.entries(templates as TemplateDescriptionObject).map(([tmpFileName, options]) => { 43 | const { default: isDefault = true, optional: isOptional = false, name } = options; 44 | const fileName = generateFileName({ 45 | fileNameTemplate: name, 46 | objectName: componentName, 47 | processFileAndFolderName 48 | }); 49 | const isAlreadyExists = getIsFileAlreadyExists({ 50 | root, 51 | fileNameTemplate: name, 52 | objectName: componentName, 53 | processFileAndFolderName, 54 | project, 55 | resultPath, 56 | projectRootPath 57 | }); 58 | return { 59 | title: `${tmpFileName}${kleur.reset( 60 | ` (${isAlreadyExists ? 'Replace' : 'Create'}: ${kleur.yellow(fileName)})` 61 | )}`, 62 | value: tmpFileName, 63 | selected: (isOptional && isDefault && !isAlreadyExists) || (!isOptional && !isAlreadyExists), 64 | exists: isAlreadyExists 65 | }; 66 | }); 67 | 68 | let initialized = false; 69 | return ( 70 | await Prompt( 71 | { 72 | type: 'multiselect', 73 | name: 'templateNames', 74 | message: 'Select files to replace or create', 75 | instructions: TEMPLATE_NAMES_SELECTING_INSTRUCTIONS, 76 | choices, 77 | // @ts-ignore 78 | onRender() { 79 | if (!initialized) { 80 | const choiceToSelect = choices.findIndex((choice) => choice.selected); 81 | // @ts-ignore 82 | this.cursor = choiceToSelect !== -1 ? choiceToSelect : 0; 83 | initialized = true; 84 | } 85 | } 86 | }, 87 | getQuestionsSettings() 88 | ) 89 | ).templateNames; 90 | }; 91 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { CommandLineFlags, Config, Project, TypingCases } from './types'; 5 | 6 | export const isDirectory = (source: string) => fs.lstatSync(source).isDirectory(); 7 | 8 | export const capitalizeName = (value: string) => value.replace(/^./g, value[0].toUpperCase()); 9 | 10 | export const processPath = (path: string): string => { 11 | return path.replace(/(^[\\/]|[\\/]$)/g, '').replace(/\\/g, '/'); 12 | }; 13 | 14 | export const makePathShort = (path: string): string => { 15 | const sourcePath = processPath(path); 16 | const pathArray = sourcePath.split('/'); 17 | if (pathArray.length <= 4) { 18 | return sourcePath; 19 | } 20 | return pathArray 21 | .reduce((acc: string[], value, index, arr) => { 22 | if (index < 1 || index > arr.length - 4) { 23 | if (index === arr.length - 3) { 24 | acc.push('...'); 25 | } 26 | acc.push(value); 27 | } 28 | return acc; 29 | }, []) 30 | .join('/'); 31 | }; 32 | 33 | export const writeToConsole = (str: string) => { 34 | process.stdout.write(`${str}\n`); 35 | }; 36 | 37 | type getRelativePathProperties = { 38 | root: string; 39 | from: string; 40 | to: string; 41 | }; 42 | 43 | export const getRelativePath = ({ root, from, to }: getRelativePathProperties): string => { 44 | const destination = path.isAbsolute(to) ? processPath(to) : processPath(path.resolve(root, processPath(to))); 45 | return processPath(path.relative(from, destination)); 46 | }; 47 | 48 | export const processCommandLineArguments = (args: string[]): string[] => { 49 | let isCollecting = false; 50 | return args.reduce((acc: string[], value, index, arr) => { 51 | if (['-n', '--name', '-f', '--files'].includes(arr[index - 1])) { 52 | isCollecting = true; 53 | acc.push(value); 54 | return acc; 55 | } else if (value.startsWith('-')) { 56 | isCollecting = false; 57 | } 58 | if (isCollecting) { 59 | acc[acc.length - 1] += ` ${value}`; 60 | return acc; 61 | } 62 | acc.push(value); 63 | return acc; 64 | }, []); 65 | }; 66 | 67 | export const processComponentNameString = (name: string | undefined): string[] | undefined => { 68 | if (typeof name === 'undefined') { 69 | return; 70 | } 71 | 72 | if (!name.includes(' ')) { 73 | return [name]; 74 | } 75 | 76 | return name 77 | .trim() 78 | .replace(/\s{1,}/g, ' ') 79 | .split(' ') 80 | .reduce((acc: string[], value) => { 81 | if (!acc.some((v) => v === value)) { 82 | acc.push(value); 83 | } 84 | return acc; 85 | }, []); 86 | }; 87 | 88 | export const splitStringByCapitalLetter = (value?: string): string[] | undefined => { 89 | if (!value || value.length === 0) { 90 | return undefined; 91 | } 92 | return value.split('').reduce((acc: string[], letter) => { 93 | if (acc.length === 0 || (letter === letter.toUpperCase() && /\w/.test(letter))) { 94 | acc.push(letter); 95 | return acc; 96 | } 97 | acc[acc.length - 1] += letter; 98 | return acc; 99 | }, []); 100 | }; 101 | 102 | export const getObjectNameParts = (name: string): string[] => { 103 | return name 104 | .replace(/(\d\D|\D\d)/g, (str) => `${str[0]}-${str[1]}`) 105 | .replace(/([A-Z])/g, '-$1') 106 | .replace(/[^a-zA-Z0-9]/g, '-') 107 | .split('-') 108 | .filter((l) => l); 109 | }; 110 | 111 | type processObjectNameProperties = { 112 | name: string; 113 | isFolder?: boolean; 114 | toComponent?: boolean; 115 | processFileAndFolderName: Config['processFileAndFolderName']; 116 | }; 117 | 118 | export const processObjectName = ({ 119 | name, 120 | isFolder = false, 121 | toComponent = false, 122 | processFileAndFolderName 123 | }: processObjectNameProperties): string => { 124 | if (processFileAndFolderName) { 125 | if (toComponent) { 126 | return mapNameToCase(name, 'PascalCase'); 127 | } 128 | 129 | if (typeof processFileAndFolderName === 'function') { 130 | return processFileAndFolderName(name, getObjectNameParts(name), isFolder); 131 | } else { 132 | return mapNameToCase(name, processFileAndFolderName); 133 | } 134 | } 135 | 136 | return name; 137 | }; 138 | 139 | export const mapNameToCase = (name: string, mapCase: TypingCases): string => { 140 | const lowerCaseParts = getObjectNameParts(name).map((part) => part.toLocaleLowerCase()); 141 | 142 | switch (mapCase) { 143 | case 'camelCase': 144 | return lowerCaseParts.map((part, index) => (index === 0 ? part : capitalizeName(part))).join(''); 145 | case 'PascalCase': 146 | return lowerCaseParts.map(capitalizeName).join(''); 147 | case 'dash-case': 148 | return lowerCaseParts.join('-'); 149 | case 'snake_case': 150 | return lowerCaseParts.join('_'); 151 | } 152 | }; 153 | 154 | type generateFileNameProperties = { 155 | fileNameTemplate: string; 156 | objectName: string; 157 | processFileAndFolderName: Config['processFileAndFolderName']; 158 | }; 159 | 160 | export const generateFileName = ({ 161 | fileNameTemplate, 162 | objectName, 163 | processFileAndFolderName 164 | }: generateFileNameProperties) => { 165 | return fileNameTemplate.replace(/\[name]/g, processObjectName({ name: objectName, processFileAndFolderName })); 166 | }; 167 | 168 | type getIsFileAlreadyExistsProperties = { 169 | root: string; 170 | fileNameTemplate: string; 171 | objectName: string; 172 | project: Project; 173 | processFileAndFolderName: Config['processFileAndFolderName']; 174 | projectRootPath: string; 175 | resultPath: string; 176 | }; 177 | 178 | export const getIsFileAlreadyExists = ({ 179 | fileNameTemplate, 180 | objectName, 181 | root, 182 | project, 183 | processFileAndFolderName, 184 | resultPath, 185 | projectRootPath 186 | }: getIsFileAlreadyExistsProperties) => { 187 | const folder = path.join( 188 | root, 189 | project, 190 | projectRootPath, 191 | resultPath, 192 | processObjectName({ name: objectName, isFolder: true, processFileAndFolderName }) 193 | ); 194 | const fileName = generateFileName({ 195 | fileNameTemplate, 196 | objectName, 197 | processFileAndFolderName 198 | }); 199 | return fs.existsSync(path.resolve(folder, fileName)); 200 | }; 201 | 202 | type getFileTemplatesProperties = { 203 | withRequired?: boolean; 204 | commandLineFlags: CommandLineFlags; 205 | templates: Config['templates']; 206 | }; 207 | 208 | export const getFileTemplates = ({ 209 | withRequired = false, 210 | commandLineFlags: { files }, 211 | templates 212 | }: getFileTemplatesProperties) => { 213 | const requiredTemplateNames = Object.entries(templates) 214 | .filter(([, options]) => !options.optional) 215 | .map(([name]) => name); 216 | 217 | let fileTemplates = files.replace(/\[\d*?]/g, '').split(' '); 218 | 219 | if (!withRequired) { 220 | fileTemplates = fileTemplates.filter((tmp) => !requiredTemplateNames.includes(tmp)); 221 | } 222 | 223 | const undefinedFileTemplates = fileTemplates.filter( 224 | (tmp) => !Object.prototype.hasOwnProperty.call(templates, tmp) && tmp !== 'no' 225 | ); 226 | 227 | return { 228 | fileTemplates, 229 | undefinedFileTemplates, 230 | requiredTemplateNames 231 | }; 232 | }; 233 | 234 | export const getFileIndexForTemplate = (files: string, template: string): number | undefined => { 235 | const names = files.split(' '); 236 | const elementIndex = names.findIndex( 237 | (name) => name.startsWith(`${template}[`) && /\[\d+?]/.test(name.replace(template, '')) 238 | ); 239 | if (elementIndex === -1) { 240 | return; 241 | } 242 | return parseInt((/\d+/.exec(names[elementIndex]) ?? ['0'])[0], 10); 243 | }; 244 | 245 | export const getIsItemExists = (item: string) => 246 | fs.promises.access(item).then( 247 | () => true, 248 | () => false 249 | ); 250 | -------------------------------------------------------------------------------- /src/initialize.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | import kleur from 'kleur'; 3 | 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | import { CONFIG_FILE_NAME, OLD_CONFIG_FILE_NAME } from './constants'; 8 | import { getQuestionsSettings } from './getQuestionsSettings'; 9 | import { getIsItemExists, writeToConsole } from './helpers'; 10 | import { CommandLineFlags } from './types'; 11 | 12 | type Properties = { 13 | root: string; 14 | moduleRoot: string; 15 | commandLineFlags: CommandLineFlags; 16 | }; 17 | 18 | export const initialize = async ({ root, moduleRoot, commandLineFlags }: Properties) => { 19 | const localConfigPath = path.resolve(root, CONFIG_FILE_NAME); 20 | if (await getIsItemExists(localConfigPath)) { 21 | return; 22 | } 23 | 24 | const oldLocalConfig = path.resolve(root, OLD_CONFIG_FILE_NAME); 25 | if (await getIsItemExists(oldLocalConfig)) { 26 | writeToConsole(`Please rename file ${kleur.yellow(OLD_CONFIG_FILE_NAME)} to ${kleur.yellow(CONFIG_FILE_NAME)}`); 27 | process.exit(); 28 | return; 29 | } 30 | 31 | writeToConsole(`Hello!\nWe haven't find configuration file (${kleur.yellow(CONFIG_FILE_NAME)}).`); 32 | writeToConsole("It seems like a first run, doesn't it?"); 33 | 34 | const { agree } = await Prompt( 35 | { 36 | type: 'toggle', 37 | name: 'agree', 38 | message: `Would you like to start configuration? It will be quick!`, 39 | initial: true, 40 | active: 'Yes', 41 | inactive: 'No' 42 | }, 43 | getQuestionsSettings() 44 | ); 45 | 46 | if (!agree) { 47 | writeToConsole('See you next time!'); 48 | process.exit(); 49 | return; 50 | } 51 | 52 | writeToConsole(`${kleur.gray('By default, CLI use basic templates to create a component.')}`); 53 | const { templatesAgreement } = await Prompt( 54 | { 55 | type: 'toggle', 56 | name: 'templatesAgreement', 57 | message: 'Would you like to create a template folder to set them up?', 58 | initial: true, 59 | active: 'Yes', 60 | inactive: 'No' 61 | }, 62 | getQuestionsSettings() 63 | ); 64 | let templateFolderName = 'templates'; 65 | 66 | if (templatesAgreement) { 67 | templateFolderName = ( 68 | await Prompt( 69 | { 70 | type: 'text', 71 | name: 'templateFolderName', 72 | message: 'What is a template folder name?', 73 | initial: templateFolderName 74 | }, 75 | getQuestionsSettings() 76 | ) 77 | ).templateFolderName; 78 | } 79 | 80 | const defaultConfigPath = path.resolve(moduleRoot, 'defaultConfig.cjs'); 81 | const defaultConfig = (await fs.promises.readFile(defaultConfigPath)).toString(); 82 | await fs.promises.writeFile( 83 | path.join(root, CONFIG_FILE_NAME), 84 | defaultConfig.replace(/(templatesFolder: ')(\w*?)(')/g, `$1${templateFolderName}$3`) 85 | ); 86 | writeToConsole(`Config file ${kleur.yellow(CONFIG_FILE_NAME)} is created.`); 87 | 88 | if (templatesAgreement) { 89 | const templateFolderPath = path.resolve(root, templateFolderName); 90 | if (!(await getIsItemExists(templateFolderPath))) { 91 | await fs.promises.mkdir(templateFolderPath); 92 | } 93 | const defaultTempleFolder = path.resolve(moduleRoot, 'templates'); 94 | const templateNames = await fs.promises.readdir(defaultTempleFolder); 95 | writeToConsole('Generated templates:'); 96 | for (const templateName of templateNames) { 97 | const tmp = (await fs.promises.readFile(path.join(defaultTempleFolder, templateName))).toString(); 98 | await fs.promises.writeFile(path.join(templateFolderPath, templateName), tmp); 99 | writeToConsole(` - ${templateFolderName}/${templateName}`); 100 | } 101 | } 102 | 103 | writeToConsole(kleur.green(`Well done! Configuration is finished!`)); 104 | 105 | if (!commandLineFlags.nfc) { 106 | const { firstComponentAgreement } = await Prompt( 107 | { 108 | type: 'toggle', 109 | name: 'firstComponentAgreement', 110 | message: `Would you like to create your first component?`, 111 | initial: true, 112 | active: 'Yes', 113 | inactive: 'No' 114 | }, 115 | getQuestionsSettings() 116 | ); 117 | 118 | if (!firstComponentAgreement) { 119 | writeToConsole('Well, see you next time!'); 120 | writeToConsole(`You can set up everything you need in the ${kleur.yellow(CONFIG_FILE_NAME)} file.`); 121 | writeToConsole('After configuration just run me again (◉ ◡ ◉ )'); 122 | process.exit(); 123 | return; 124 | } 125 | } else { 126 | process.exit(); 127 | return; 128 | } 129 | 130 | return; 131 | }; 132 | -------------------------------------------------------------------------------- /src/parseDestinationPath.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | import { processPath } from './helpers'; 7 | import { CommandLineFlags, Config, Project } from './types'; 8 | 9 | type Properties = { 10 | root: string; 11 | commandLineFlags: CommandLineFlags; 12 | config: Config; 13 | }; 14 | 15 | type Output = { 16 | project: Project; 17 | projectRootPath?: string; 18 | resultPath?: string; 19 | }; 20 | 21 | export const parseDestinationPath = async ({ 22 | root, 23 | commandLineFlags: { dest }, 24 | config: { folderPath, multiProject } 25 | }: Properties): Promise => { 26 | if (!dest) { 27 | return { project: '' }; 28 | } 29 | 30 | const absolutePath = path.isAbsolute(dest) ? dest : path.resolve(root, dest); 31 | 32 | if (!fs.existsSync(absolutePath)) { 33 | console.error(kleur.red("Error: Path doesn't exist:")); 34 | console.error(kleur.yellow(absolutePath)); 35 | process.exit(); 36 | return { project: '' }; 37 | } 38 | 39 | let relativePath = path.relative(root, absolutePath); 40 | 41 | if (relativePath === absolutePath || relativePath.startsWith('..')) { 42 | console.error(kleur.red('Error: component destination must be in project')); 43 | process.exit(); 44 | return { project: '' }; 45 | } 46 | 47 | let project = ''; 48 | 49 | if (multiProject) { 50 | const [projectName, ...pathParts] = relativePath.split(path.sep); 51 | project = projectName; 52 | relativePath = pathParts.join(path.sep); 53 | } 54 | 55 | const potentialFolders = (typeof folderPath === 'string' ? [folderPath] : folderPath).map((f) => 56 | path.join(f).replace(/[\\/]$/, '') 57 | ); 58 | const availableFolders = potentialFolders.filter((folder) => fs.existsSync(path.resolve(root, project, folder))); 59 | const currentProjectRootPath = availableFolders.find((folder) => relativePath.startsWith(folder)); 60 | 61 | if (!currentProjectRootPath) { 62 | console.error(kleur.red('Error: component destination must match to folderPath configuration parameter')); 63 | process.exit(); 64 | return { project: '' }; 65 | } 66 | 67 | const destinationPath = path.relative(path.resolve(root, project, currentProjectRootPath), absolutePath); 68 | 69 | return { 70 | project, 71 | projectRootPath: processPath(currentProjectRootPath), 72 | resultPath: processPath(destinationPath) 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/processAfterGeneration.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | import path from 'path'; 4 | import childProcess from 'child_process'; 5 | 6 | import { processObjectName, writeToConsole } from './helpers'; 7 | import { ComponentFileList, Config, Project } from './types'; 8 | 9 | type Properties = { 10 | root: string; 11 | config: Config; 12 | project: Project; 13 | componentNames: string[]; 14 | resultPath: string; 15 | projectRootPath: string; 16 | componentFileList: ComponentFileList; 17 | }; 18 | 19 | export const processAfterGeneration = async ({ 20 | root, 21 | config: { afterCreation, processFileAndFolderName }, 22 | project, 23 | componentNames, 24 | projectRootPath, 25 | resultPath, 26 | componentFileList 27 | }: Properties) => { 28 | if (afterCreation) { 29 | for (const [type, command] of Object.entries(afterCreation)) { 30 | let isFirstExecution = true; 31 | for (const componentName of componentNames) { 32 | const fileList = Object.values(componentFileList[componentName]).filter((file) => file.selected); 33 | const finalFolder = path.join( 34 | root, 35 | project, 36 | projectRootPath, 37 | resultPath, 38 | processObjectName({ name: componentName, isFolder: true, processFileAndFolderName }) 39 | ); 40 | 41 | if ( 42 | command.extensions && 43 | !command.extensions.some((ext) => fileList.some((file) => file.name.endsWith(ext))) 44 | ) { 45 | break; 46 | } 47 | 48 | if (isFirstExecution) { 49 | writeToConsole(`Executing ${kleur.yellow(type)} script:`); 50 | isFirstExecution = false; 51 | } 52 | 53 | if (componentNames.length > 1) { 54 | writeToConsole(` ${componentName}`); 55 | } 56 | 57 | for (const file of fileList) { 58 | try { 59 | if (!command.extensions || command.extensions.some((ext) => file.name.endsWith(ext))) { 60 | const filePath = path.join(finalFolder, file.name); 61 | childProcess.execSync(command.cmd.replace('[filepath]', filePath)); 62 | writeToConsole( 63 | `${componentNames.length > 1 ? ' ' : ' '}${kleur.green('√')} ${file.name}` 64 | ); 65 | } 66 | } catch (e) { 67 | console.error( 68 | kleur.red( 69 | `Unexpected error during processing ${kleur.yellow(file.name)} with ${kleur.yellow( 70 | type 71 | )} command` 72 | ) 73 | ); 74 | console.error(e); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/processCommandLineFlags.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander'; 2 | 3 | import { processCommandLineArguments } from './helpers'; 4 | import { CommandLineFlags } from './types'; 5 | 6 | export const processCommandLineFlags = (): CommandLineFlags => { 7 | program.option('-u, --update', 'update mode, to add or replace files in existent object'); 8 | program.option('-n, --name ', 'object name'); 9 | program.option('-t, --template ', 'template name'); 10 | program.option('-d, --dest ', 'path for creation'); 11 | program.option('-s, --skip-search', 'skip search (default or destination folder)'); 12 | program.option('-p, --project ', 'project name'); 13 | program.option('-f, --files ', 'file types (style, test, stories)'); 14 | program.option('--sls', 'skip last step'); 15 | program.option('--nfc', 'without first component after initialization'); 16 | program.parse(processCommandLineArguments(process.argv)); 17 | 18 | const { 19 | update = false, 20 | skipSearch = false, 21 | sls = false, 22 | nfc = false, 23 | dest = '', 24 | name = '', 25 | template = '', 26 | project = '', 27 | files = '' 28 | } = program.opts() || {}; 29 | 30 | return { 31 | update, 32 | skipSearch, 33 | sls, 34 | nfc, 35 | dest, 36 | name, 37 | template, 38 | project, 39 | files 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/setComponentNames.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | import kleur from 'kleur'; 3 | 4 | import { getQuestionsSettings } from './getQuestionsSettings'; 5 | import { capitalizeName, mapNameToCase, processComponentNameString, writeToConsole } from './helpers'; 6 | import { CommandLineFlags } from './types'; 7 | 8 | type Properties = { 9 | templateName: string; 10 | commandLineFlags: CommandLineFlags; 11 | }; 12 | 13 | export const setComponentNames = async ({ templateName, commandLineFlags }: Properties): Promise => { 14 | if (commandLineFlags.update) { 15 | return []; 16 | } 17 | 18 | let res: string[] = [commandLineFlags.name]; 19 | 20 | do { 21 | let componentName: string[] | undefined = ['']; 22 | 23 | if (res[0]) { 24 | componentName = processComponentNameString(res[0]); 25 | } else { 26 | componentName = ( 27 | await Prompt( 28 | { 29 | type: 'text', 30 | name: 'componentName', 31 | message: `What is the ${templateName} name? (ExampleName)`, 32 | format: (input: string) => processComponentNameString(input) 33 | }, 34 | getQuestionsSettings() 35 | ) 36 | ).componentName; 37 | } 38 | 39 | if (typeof componentName === 'undefined') { 40 | process.exit(); 41 | return []; 42 | } 43 | 44 | if (componentName.some((name) => name.length === 0)) { 45 | writeToConsole( 46 | kleur.yellow( 47 | `${capitalizeName(templateName)} name must have at least one character.\nExample: DocumentModal` 48 | ) 49 | ); 50 | continue; 51 | } 52 | 53 | const componentNameRegularExpression = /[^\w\d-_]/g; 54 | 55 | if (componentName.some((name) => componentNameRegularExpression.test(name))) { 56 | writeToConsole( 57 | kleur.yellow( 58 | `${capitalizeName( 59 | templateName 60 | )} name must contain only letters, numbers, dashes or underscores.\nExample: DocumentModal` 61 | ) 62 | ); 63 | continue; 64 | } 65 | 66 | res = componentName; 67 | } while (!res[0]); 68 | 69 | return res.map((name) => mapNameToCase(name, 'PascalCase')); 70 | }; 71 | -------------------------------------------------------------------------------- /src/setComponentTemplate.ts: -------------------------------------------------------------------------------- 1 | import Prompt from 'prompts'; 2 | import kleur from 'kleur'; 3 | 4 | import { getQuestionsSettings } from './getQuestionsSettings'; 5 | import { CommandLineFlags, Config } from './types'; 6 | 7 | type Properties = { 8 | config: Config; 9 | commandLineFlags: CommandLineFlags; 10 | }; 11 | 12 | type Output = { 13 | config: Config; 14 | templateName: string; 15 | }; 16 | 17 | export const setComponentTemplate = async ({ commandLineFlags: { template }, config }: Properties): Promise => { 18 | const { templates } = config; 19 | let templateName = 'component'; 20 | if (Array.isArray(templates)) { 21 | if (template) { 22 | if (!templates.map((tmp) => tmp.name).includes(template)) { 23 | console.error(kleur.red(`Error: There is no ${template} template`)); 24 | process.exit(); 25 | return { config, templateName }; 26 | } 27 | templateName = template; 28 | } else if (templates.length === 1) { 29 | templateName = templates[0].name; 30 | } else { 31 | const { selectedTemplateName } = await Prompt( 32 | { 33 | type: 'select', 34 | name: 'selectedTemplateName', 35 | message: `What would you want to create?`, 36 | hint: 'Select using arrows and press Enter', 37 | choices: templates.map((tmp) => ({ title: tmp.name, value: tmp.name })), 38 | initial: 0 39 | }, 40 | getQuestionsSettings() 41 | ); 42 | templateName = selectedTemplateName; 43 | } 44 | 45 | const selectedTemplate = templates.find((tmp) => tmp.name === templateName); 46 | if (selectedTemplate) { 47 | const newConfig = { ...config }; 48 | newConfig.templates = selectedTemplate.files; 49 | if (selectedTemplate.folderPath) { 50 | newConfig.folderPath = selectedTemplate.folderPath; 51 | } 52 | return { config: newConfig, templateName: selectedTemplate.name }; 53 | } 54 | } 55 | 56 | return { config, templateName }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/setConfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { pathToFileURL } from 'url'; 4 | 5 | import { CONFIG_FILE_NAME } from './constants'; 6 | import { Config } from './types'; 7 | 8 | const prepareFolderPath = (path: string): string => { 9 | return `${path.replace(/(^\/|^\\|\/$|\\$)/g, '')}/`; 10 | }; 11 | 12 | // This dynamic import is using because without "file://" prefix module can't be imported by Windows 13 | const dynamicImport = async (pathToModule: string) => { 14 | return import( 15 | process.platform === 'win32' && path.isAbsolute(pathToModule) 16 | ? pathToFileURL(pathToModule).toString() 17 | : pathToModule 18 | ); 19 | }; 20 | 21 | type Properties = { 22 | root: string; 23 | }; 24 | 25 | export const setConfig = async ({ root }: Properties): Promise => { 26 | // @ts-ignore 27 | let res: Config = (await dynamicImport(path.resolve(__dirname, '../defaultConfig.cjs'))).default; 28 | const localConfigPath = path.resolve(root, CONFIG_FILE_NAME); 29 | if (fs.existsSync(localConfigPath)) { 30 | const manualConfig: Config = (await dynamicImport(localConfigPath)).default; 31 | res = { ...res, ...manualConfig, placeholders: { ...res.placeholders, ...manualConfig.placeholders } }; 32 | if (Array.isArray(res.folderPath)) { 33 | res.folderPath = res.folderPath.map((p) => prepareFolderPath(p)); 34 | } else { 35 | res.folderPath = prepareFolderPath(res.folderPath); 36 | } 37 | } 38 | return res; 39 | }; 40 | -------------------------------------------------------------------------------- /src/setPath.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | import Prompt from 'prompts'; 3 | 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | import { getQuestionsSettings } from './getQuestionsSettings'; 8 | import { 9 | isDirectory, 10 | makePathShort, 11 | processObjectName, 12 | processPath, 13 | splitStringByCapitalLetter, 14 | writeToConsole 15 | } from './helpers'; 16 | import { getProjectRootPath } from './getProjectRootPath'; 17 | import { setComponentNames } from './setComponentNames'; 18 | import { CommandLineFlags, Config, Project } from './types'; 19 | 20 | export const filterChoicesByText = (choices: { title: string }[], query: string, isRoot: boolean) => 21 | choices.filter((choice, index) => { 22 | if (query === '') { 23 | return true; 24 | } 25 | if (index === 0 || (!isRoot && index === 1)) { 26 | return false; 27 | } 28 | return ( 29 | choice.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) || 30 | splitStringByCapitalLetter(query)?.every( 31 | (part) => splitStringByCapitalLetter(choice.title)?.some((substr) => substr.startsWith(part)) 32 | ) 33 | ); 34 | }); 35 | 36 | type Properties = { 37 | root: string; 38 | commandLineFlags: CommandLineFlags; 39 | config: Config; 40 | project: Project; 41 | templateName: string; 42 | projectRootPathInput?: string; 43 | resultPathInput?: string; 44 | }; 45 | 46 | type Output = { 47 | componentNames: string[]; 48 | projectRootPath: string; 49 | resultPath: string; 50 | }; 51 | 52 | export const setPath = async ({ 53 | root, 54 | commandLineFlags, 55 | config: { folderPath, processFileAndFolderName }, 56 | project, 57 | templateName, 58 | projectRootPathInput, 59 | resultPathInput 60 | }: Properties): Promise => { 61 | const { dest, update, skipSearch } = commandLineFlags; 62 | const potentialFolders = typeof folderPath === 'string' ? [folderPath] : folderPath; 63 | const availableFolders = potentialFolders.filter((folder) => fs.existsSync(path.resolve(root, project, folder))); 64 | 65 | let projectRootPath = projectRootPathInput ?? dest; 66 | 67 | if (!projectRootPath) { 68 | if (availableFolders.length === 0) { 69 | console.error(kleur.red(`Error: There is no any folder for ${templateName} from the list below`)); 70 | console.error(kleur.yellow(potentialFolders.map((f) => path.resolve(root, project, f)).join('\n'))); 71 | process.exit(); 72 | return { componentNames: [], projectRootPath, resultPath: resultPathInput ?? '' }; 73 | } else if (availableFolders.length === 1) { 74 | projectRootPath = availableFolders[0]; 75 | } else { 76 | projectRootPath = await getProjectRootPath(availableFolders); 77 | } 78 | } 79 | 80 | if (resultPathInput) { 81 | resultPathInput.split('/').forEach((part) => { 82 | writeToConsole(`${kleur.green('√')} Select destination folder for component ${kleur.gray(`»`)} ${part}`); 83 | }); 84 | } 85 | 86 | let resultPath: string | null = null; 87 | let relativePath = resultPathInput ?? '.'; 88 | if (skipSearch) { 89 | resultPath = relativePath; 90 | } else { 91 | while (resultPath === null) { 92 | const currentFolder = path.resolve(project, projectRootPath, relativePath); 93 | try { 94 | await fs.promises.stat(currentFolder); 95 | } catch (e) { 96 | console.error(kleur.red(`Error: There is no folder for ${templateName}`), kleur.yellow(currentFolder)); 97 | console.error(e); 98 | process.exit(); 99 | return { componentNames: [], projectRootPath, resultPath: resultPathInput ?? '' }; 100 | } 101 | 102 | const folders = (await fs.promises.readdir(path.resolve(project, projectRootPath, relativePath))).filter( 103 | (item) => isDirectory(path.resolve(project, projectRootPath, relativePath, item)) 104 | ); 105 | 106 | if (folders.length === 0) { 107 | resultPath = path.join(relativePath); 108 | continue; 109 | } 110 | const isRoot = ['.', './', '.\\'].includes(relativePath); 111 | 112 | type Choice = { 113 | title: string; 114 | value: string | number; 115 | description: string; 116 | }; 117 | 118 | const choices: Choice[] = folders.map((f) => ({ 119 | title: f, 120 | value: f, 121 | description: makePathShort(path.join(project, projectRootPath, relativePath, f)) 122 | })); 123 | 124 | if (!(update && isRoot)) { 125 | choices.unshift({ 126 | title: update ? '>> This <<' : '>> Here <<', 127 | value: 1, 128 | description: makePathShort(path.join(project, projectRootPath, relativePath)) 129 | }); 130 | } 131 | 132 | if (!isRoot) { 133 | choices.unshift({ 134 | title: '< Back', 135 | value: -1, 136 | description: makePathShort(path.join(project, projectRootPath, relativePath, '../')) 137 | }); 138 | } 139 | 140 | let searching = false; 141 | const { folder } = await Prompt( 142 | { 143 | type: 'autocomplete', 144 | name: 'folder', 145 | message: update 146 | ? `Select ${templateName} to update` 147 | : `Select destination folder for ${templateName}`, 148 | hint: 'Select using arrows and press Enter', 149 | choices: choices.map((choice) => ({ ...choice, description: kleur.yellow(choice.description) })), 150 | initial: isRoot ? 0 : 1, 151 | onState() { 152 | // @ts-ignore 153 | if (this.input === '' && searching) { 154 | // @ts-ignore 155 | this.select = isRoot ? 0 : 1; 156 | searching = false; 157 | // @ts-ignore 158 | } else if (this.input !== '' && !searching) { 159 | // @ts-ignore 160 | this.select = 0; 161 | searching = true; 162 | } 163 | }, 164 | suggest: (text, choices) => { 165 | return Promise.resolve(filterChoicesByText(choices, text, isRoot)); 166 | } 167 | }, 168 | getQuestionsSettings() 169 | ); 170 | 171 | if (folder === 1) { 172 | resultPath = relativePath; 173 | } else if (folder === -1) { 174 | relativePath = path.join(relativePath, '../'); 175 | } else { 176 | relativePath = path.join(relativePath, folder); 177 | } 178 | } 179 | } 180 | 181 | projectRootPath = processPath(projectRootPath); 182 | resultPath = processPath(resultPath); 183 | 184 | if (update) { 185 | const pathParts = resultPath.split('/'); 186 | resultPath = pathParts.slice(0, pathParts.length - 1).join('/'); 187 | return { 188 | resultPath, 189 | projectRootPath, 190 | componentNames: [ 191 | processObjectName({ 192 | name: pathParts[pathParts.length - 1], 193 | isFolder: true, 194 | toComponent: true, 195 | processFileAndFolderName 196 | }) 197 | ] 198 | }; 199 | } 200 | 201 | return { componentNames: await setComponentNames({ commandLineFlags, templateName }), resultPath, projectRootPath }; 202 | }; 203 | -------------------------------------------------------------------------------- /src/setProject.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | import Prompt from 'prompts'; 3 | 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | import { getQuestionsSettings } from './getQuestionsSettings'; 8 | import { isDirectory, writeToConsole } from './helpers'; 9 | import { CommandLineFlags, Config, Project } from './types'; 10 | 11 | const typeAboutSelectedProject = (project: Project) => { 12 | writeToConsole(`${kleur.green('√')} Selected project ${kleur.gray(`»`)} ${project}`); 13 | }; 14 | 15 | type Properties = { 16 | project: Project; 17 | root: string; 18 | commandLineFlags: CommandLineFlags; 19 | config: Config; 20 | templateName: string; 21 | }; 22 | 23 | export const setProject = async ({ 24 | project: inputProject, 25 | root, 26 | commandLineFlags, 27 | config: { multiProject, folderPath }, 28 | templateName 29 | }: Properties): Promise => { 30 | let project = ''; 31 | 32 | if (inputProject) { 33 | typeAboutSelectedProject(inputProject); 34 | return inputProject; 35 | } 36 | 37 | if (commandLineFlags.dest || !multiProject) { 38 | return ''; 39 | } 40 | 41 | if (commandLineFlags.project) { 42 | project = commandLineFlags.project; 43 | typeAboutSelectedProject(project); 44 | } 45 | 46 | if (!project && multiProject) { 47 | const projectList = await fs.promises.readdir(path.resolve(root)).then((items) => { 48 | return items 49 | .filter((item) => isDirectory(path.join(root, item))) 50 | .filter((folder) => { 51 | if (Array.isArray(folderPath)) { 52 | return folderPath.some((fp) => fs.existsSync(path.resolve(root, folder, fp))); 53 | } 54 | return fs.existsSync(path.resolve(root, folder, folderPath)); 55 | }); 56 | }); 57 | 58 | if (projectList.length === 0) { 59 | console.error( 60 | `${kleur.red('There is no projects with the following path:\n')}${kleur.yellow( 61 | Array.isArray(folderPath) ? folderPath.join('\n') : folderPath 62 | )}` 63 | ); 64 | process.exit(); 65 | return ''; 66 | } 67 | 68 | if (projectList.length === 1) { 69 | writeToConsole(`Creating ${templateName} for ${kleur.yellow(projectList[0])} project`); 70 | return projectList[0]; 71 | } 72 | 73 | const { selectedProject } = await Prompt( 74 | { 75 | type: 'select', 76 | name: 'selectedProject', 77 | message: 'Please, select the project', 78 | choices: projectList.map((p) => ({ title: p, value: p })), 79 | initial: 0 80 | }, 81 | getQuestionsSettings() 82 | ); 83 | 84 | project = selectedProject; 85 | } 86 | 87 | if ( 88 | (Array.isArray(folderPath) && folderPath.some((fp) => fs.existsSync(path.resolve(root, project, fp)))) || 89 | (!Array.isArray(folderPath) && fs.existsSync(path.resolve(root, project, folderPath))) 90 | ) { 91 | return project; 92 | } 93 | 94 | console.error( 95 | kleur.red(`Error: There is no folder for ${templateName} in ${kleur.yellow(project)} project`), 96 | folderPath 97 | ); 98 | process.exit(); 99 | return ''; 100 | }; 101 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type FileOption = { 2 | name: string; 3 | description: string; 4 | }; 5 | 6 | type AfterCreationCommand = { 7 | extensions?: string[]; 8 | cmd: string; 9 | }; 10 | 11 | export type TemplateDescription = { name: string; file?: string | FileOption[]; optional?: boolean; default?: boolean }; 12 | 13 | export type TemplateDescriptionObject = { 14 | [key in string]: TemplateDescription; 15 | }; 16 | 17 | type MultiTemplate = { 18 | name: string; 19 | folderPath?: string; 20 | files: TemplateDescriptionObject; 21 | }[]; 22 | 23 | export type FileDescription = { 24 | name: string; 25 | file?: string; 26 | selected: boolean; 27 | type?: string; 28 | }; 29 | 30 | export type FilesList = { 31 | [key in string]: FileDescription; 32 | }; 33 | 34 | export type ComponentFileList = { [key in string]: FilesList }; 35 | 36 | export type TypingCases = 'camelCase' | 'PascalCase' | 'snake_case' | 'dash-case'; 37 | 38 | export type ProcessFileAndFolderName = ((name?: string, parts?: string[], isFolder?: boolean) => string) | TypingCases; 39 | 40 | export type Config = { 41 | multiProject: boolean; 42 | skipFinalStep: boolean; 43 | checkExistenceOnCreate: boolean; 44 | folderPath: string | string[]; 45 | templatesFolder: string; 46 | templates: TemplateDescriptionObject | MultiTemplate; 47 | placeholders: { [key in string]: (data: unknown) => string }; 48 | processFileAndFolderName?: ProcessFileAndFolderName; 49 | afterCreation?: { 50 | [key in string]: AfterCreationCommand; 51 | }; 52 | }; 53 | 54 | export type Project = string; 55 | 56 | export type CommandLineFlags = { 57 | update: boolean; 58 | skipSearch: boolean; 59 | sls: boolean; 60 | nfc: boolean; 61 | dest: string; 62 | name: string; 63 | template: string; 64 | project: string; 65 | files: string; 66 | }; 67 | 68 | export type templatePlaceholdersData = { 69 | project: string; 70 | componentName: string; 71 | objectName: string; 72 | objectType: string; 73 | pathToObject: string; 74 | destinationFolder: string; 75 | objectFolder: string; 76 | relativeObjectFolder: string; 77 | filePrefix: string; 78 | folderName: string; 79 | files: FilesList; 80 | getRelativePath: (to: string) => string; 81 | join: (...parts: string[]) => string; 82 | stringToCase: (str: string, toCase: TypingCases) => string; 83 | }; 84 | 85 | type DeepPartial = { 86 | [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; 87 | }; 88 | -------------------------------------------------------------------------------- /templates/class.tmp: -------------------------------------------------------------------------------- 1 | #STYLE#type #NAME#Props = {} 2 | 3 | type #NAME#State = {} 4 | 5 | export class #NAME# extends React.Component<#NAME#Props, #NAME#State> { 6 | constructor(props) { 7 | super(props) 8 | this.state = { 9 | 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /templates/fc.tmp: -------------------------------------------------------------------------------- 1 | #STYLE#type #NAME#Props = {} 2 | 3 | export function #NAME#(props: #NAME#Props) { 4 | return
5 | } -------------------------------------------------------------------------------- /templates/index.tmp: -------------------------------------------------------------------------------- 1 | export {#NAME#} from './#COMPONENT_FILE_PREFIX#' 2 | -------------------------------------------------------------------------------- /templates/stories.tmp: -------------------------------------------------------------------------------- 1 | import { #NAME# } from './#NAME#' 2 | 3 | export default { title: '#STORY_PATH#' } 4 | 5 | const defaultProps: React.ComponentProps = { 6 | 7 | } 8 | 9 | export const Overview = () => { 10 | return ( 11 | <#NAME# {...defaultProps} /> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /templates/test.tmp: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | describe('#NAME#', () => { 4 | it('case', () => { 5 | 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /tests/generateFiles.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from 'mock-fs'; 2 | 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | import { ComponentFileList, Config, Project } from '../src/types'; 7 | import { generateFiles } from '../src/generateFiles'; 8 | import { getTemplate } from '../src/getTemplate'; 9 | 10 | jest.mock('../src/getTemplate', () => ({ 11 | getTemplate: jest.fn(() => 'data') 12 | })); 13 | 14 | describe('generateFiles', () => { 15 | const fsMockFolders = { 16 | node_modules: mockFs.load(path.resolve(__dirname, '../node_modules')), 17 | src: { 18 | Comp1: {} 19 | } 20 | }; 21 | const root = process.cwd(); 22 | const moduleRoot = ''; 23 | const config = {} as Config; 24 | const project: Project = ''; 25 | const componentNames = ['Comp1', 'Comp2']; 26 | const templateName = 'component'; 27 | const projectRootPath = 'src/'; 28 | const resultPath = '.'; 29 | const componentFileList: ComponentFileList = { 30 | Comp1: { 31 | index: { 32 | name: 'index.ts', 33 | file: 'index.ts', 34 | selected: true 35 | }, 36 | component: { 37 | name: 'Comp1.tsx', 38 | file: 'fc.tsx', 39 | selected: true, 40 | type: 'Functional component' 41 | }, 42 | styles: { 43 | name: 'Comp1.module.css', 44 | selected: false 45 | } 46 | }, 47 | Comp2: { 48 | index: { 49 | name: 'index.ts', 50 | file: 'index.ts', 51 | selected: true 52 | }, 53 | test: { 54 | name: '__test__/test.ts', 55 | file: 'tst.tsx', 56 | selected: true 57 | } 58 | } 59 | }; 60 | 61 | beforeEach(() => { 62 | mockFs(fsMockFolders); 63 | }); 64 | 65 | afterEach(() => { 66 | mockFs.restore(); 67 | }); 68 | 69 | it('generate', async () => { 70 | const folder = path.resolve(root, project, projectRootPath, resultPath); 71 | const mkdirSpy = jest.spyOn(fs.promises, 'mkdir'); 72 | const writeFileSpy = jest.spyOn(fs.promises, 'writeFile'); 73 | await generateFiles({ 74 | root, 75 | moduleRoot, 76 | config, 77 | project, 78 | componentNames, 79 | templateName, 80 | resultPath, 81 | projectRootPath, 82 | componentFileList 83 | }); 84 | const filesComp1 = await fs.promises.readdir(path.resolve(folder, componentNames[0])); 85 | const filesComp2 = await fs.promises.readdir(path.resolve(folder, componentNames[1])); 86 | expect(filesComp1.length).toBe(2); 87 | expect(filesComp2.length).toBe(2); 88 | expect(getTemplate).toBeCalledTimes(4); 89 | expect(mkdirSpy).toBeCalledTimes(2); 90 | expect(mkdirSpy).toBeCalledWith(path.resolve(folder, componentNames[1])); 91 | expect(mkdirSpy).toBeCalledWith(path.resolve(folder, componentNames[1], '__test__')); 92 | expect(writeFileSpy).toBeCalledTimes(4); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/getFinalAgreement.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { getFinalAgreement } from '../src/getFinalAgreement'; 4 | 5 | describe('getFinalAgreement', () => { 6 | it('yes', async () => { 7 | prompts.inject([true]); 8 | const res = await getFinalAgreement(); 9 | expect(res).toBe(true); 10 | }); 11 | it('no', async () => { 12 | prompts.inject([false]); 13 | const res = await getFinalAgreement(); 14 | expect(res).toBe(false); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/getProjectRootPath.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { getProjectRootPath } from '../src/getProjectRootPath'; 4 | 5 | describe('getProjectRootPath', () => { 6 | it('default path', async () => { 7 | const paths = ['Static/Scripts/components/', 'Static/Scripts/React/components/']; 8 | prompts.inject(paths[0] as never); 9 | const res1 = await getProjectRootPath(paths); 10 | expect(res1).toBe(paths[0]); 11 | prompts.inject(paths[1] as never); 12 | const res2 = await getProjectRootPath(paths); 13 | expect(res2).toBe(paths[1]); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/getQuestionsSettings.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuestionsSettings } from '../src/getQuestionsSettings'; 2 | 3 | import { mockProcess } from './testUtils'; 4 | 5 | describe('getQuestionsSettings', () => { 6 | const { exitMock } = mockProcess(); 7 | 8 | it('onCancel call', async () => { 9 | const settings = getQuestionsSettings(); 10 | settings.onCancel(); 11 | expect(exitMock).toHaveBeenCalledTimes(1); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/getTemplateFile.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { CommandLineFlags } from '../src/types'; 4 | import { getTemplateFile } from '../src/getTemplateFile'; 5 | 6 | import { mockConsole, mockProcess } from './testUtils'; 7 | 8 | describe('getTemplateFile', () => { 9 | const props: Parameters[0] = { 10 | name: 'component', 11 | files: [], 12 | commandLineFlags: { 13 | files: '' 14 | } as CommandLineFlags 15 | }; 16 | 17 | const { exitMock } = mockProcess(); 18 | mockConsole(); 19 | 20 | beforeEach(() => { 21 | props.files = []; 22 | props.commandLineFlags.files = ''; 23 | }); 24 | 25 | it('select by prompt', async () => { 26 | props.files = [ 27 | { name: 'fc.tsx', description: 'Functional component' }, 28 | { name: 'class.tsx', description: 'Class component' } 29 | ]; 30 | prompts.inject([props.files[0]]); 31 | const selectedFileOption = await getTemplateFile(props); 32 | expect(selectedFileOption).toEqual(props.files[0]); 33 | }); 34 | 35 | it('select by command line', async () => { 36 | props.files = [ 37 | { name: 'fc.tsx', description: 'Functional component' }, 38 | { name: 'class.tsx', description: 'Class component' } 39 | ]; 40 | props.commandLineFlags.files = 'style component[0] test'; 41 | const selectedFileOption = await getTemplateFile(props); 42 | expect(selectedFileOption).toEqual(props.files[0]); 43 | }); 44 | 45 | it('wrong select by command line', async () => { 46 | props.files = [ 47 | { name: 'fc.tsx', description: 'Functional component' }, 48 | { name: 'class.tsx', description: 'Class component' } 49 | ]; 50 | props.commandLineFlags.files = 'style component[2] test'; 51 | await getTemplateFile(props); 52 | expect(exitMock).toBeCalled(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/getTemplateNamesToCreate.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { getTemplateNamesToCreate } from '../src/getTemplateNamesToCreate'; 4 | import { CommandLineFlags, Config } from '../src/types'; 5 | 6 | import { mockConsole, mockProcess } from './testUtils'; 7 | 8 | describe('getTemplateNamesToCreate', () => { 9 | const props: Parameters[0] = { 10 | commandLineFlags: { 11 | files: '' 12 | } as CommandLineFlags, 13 | config: { 14 | templates: {} 15 | } as Config 16 | }; 17 | mockConsole(); 18 | const { exitMock } = mockProcess(); 19 | 20 | beforeEach(() => { 21 | props.config.templates = {}; 22 | props.commandLineFlags.files = ''; 23 | }); 24 | 25 | it('no optional', async () => { 26 | props.config.templates = { 27 | index: { 28 | name: 'index.ts', 29 | file: 'index.ts' 30 | } 31 | }; 32 | const res = await getTemplateNamesToCreate(props); 33 | expect(res).toEqual(['index']); 34 | }); 35 | 36 | it('with optional and without selection', async () => { 37 | props.config.templates = { 38 | file1: { 39 | name: 'index.ts', 40 | file: 'index.ts' 41 | }, 42 | file2: { 43 | name: 'index.ts', 44 | file: 'index.ts', 45 | optional: true 46 | } 47 | }; 48 | prompts.inject([[]]); 49 | const res = await getTemplateNamesToCreate(props); 50 | expect(res).toEqual(['file1']); 51 | }); 52 | 53 | it('with optional and with selection', async () => { 54 | props.config.templates = { 55 | file1: { 56 | name: 'index.ts', 57 | file: 'index.ts' 58 | }, 59 | file2: { 60 | name: 'index.ts', 61 | file: 'index.ts', 62 | optional: true 63 | } 64 | }; 65 | prompts.inject([['file2']]); 66 | const res = await getTemplateNamesToCreate(props); 67 | expect(res).toEqual(['file1', 'file2']); 68 | }); 69 | 70 | it('command line selection', async () => { 71 | props.config.templates = { 72 | file1: { 73 | name: 'index.ts', 74 | file: 'index.ts' 75 | }, 76 | file2: { 77 | name: 'index.ts', 78 | file: 'index.ts', 79 | optional: true 80 | } 81 | }; 82 | props.commandLineFlags.files = 'file2[1]'; 83 | const res = await getTemplateNamesToCreate(props); 84 | expect(res).toEqual(['file1', 'file2']); 85 | }); 86 | 87 | it('command line selection with no', async () => { 88 | props.config.templates = { 89 | file1: { 90 | name: 'index.ts', 91 | file: 'index.ts' 92 | }, 93 | file2: { 94 | name: 'index.ts', 95 | file: 'index.ts', 96 | optional: true 97 | } 98 | }; 99 | props.commandLineFlags.files = 'no'; 100 | const res = await getTemplateNamesToCreate(props); 101 | expect(res).toEqual(['file1']); 102 | }); 103 | 104 | it('command line selection with unexpected filename', async () => { 105 | props.config.templates = { 106 | file1: { 107 | name: 'index.ts', 108 | file: 'index.ts' 109 | }, 110 | file2: { 111 | name: 'index.ts', 112 | file: 'index.ts', 113 | optional: true 114 | } 115 | }; 116 | props.commandLineFlags.files = 'unexpected'; 117 | await getTemplateNamesToCreate(props); 118 | expect(exitMock).toBeCalled(); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /tests/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from 'mock-fs'; 2 | 3 | import path from 'path'; 4 | 5 | // @ts-ignore 6 | import defaultConfig from '../defaultConfig'; 7 | import { 8 | capitalizeName, 9 | getFileIndexForTemplate, 10 | getFileTemplates, 11 | getIsFileAlreadyExists, 12 | getObjectNameParts, 13 | isDirectory, 14 | makePathShort, 15 | mapNameToCase, 16 | processCommandLineArguments, 17 | processComponentNameString, 18 | processPath, 19 | splitStringByCapitalLetter 20 | } from '../src/helpers'; 21 | import { CommandLineFlags, TypingCases, Config, Project } from '../src/types'; 22 | 23 | describe('helpers', () => { 24 | const root = process.cwd(); 25 | const project: Project = ''; 26 | const projectRootPath = 'src/'; 27 | const resultPath = '.'; 28 | let config = {} as Config; 29 | let commandLineFlags: CommandLineFlags | undefined = undefined; 30 | const fsMockFolders = { 31 | node_modules: mockFs.load(path.resolve(__dirname, '../node_modules')), 32 | src: { 33 | TestComponent: { 34 | 'index.ts': '', 35 | 'TestComponent.tsx': '' 36 | } 37 | }, 38 | emptyFolder: {} 39 | }; 40 | 41 | beforeEach(() => { 42 | commandLineFlags = undefined; 43 | config = { ...defaultConfig, templates: defaultConfig.templates[0].files } as Config; 44 | mockFs(fsMockFolders); 45 | }); 46 | 47 | afterEach(() => { 48 | mockFs.restore(); 49 | }); 50 | 51 | it('capitalizeName', () => { 52 | expect(capitalizeName('test')).toBe('Test'); 53 | }); 54 | 55 | it('isDirectory', () => { 56 | expect(isDirectory('emptyFolder')).toBe(true); 57 | }); 58 | 59 | it.each([ 60 | ['a', 'a'], 61 | ['a/b/', 'a/b'], 62 | ['/a/b', 'a/b'], 63 | ['\\a/b', 'a/b'], 64 | ['a/b/', 'a/b'], 65 | ['a/b/c\\', 'a/b/c'] 66 | ])('processPath: %s converted to %s', (value, expected) => { 67 | expect(processPath(value)).toBe(expected); 68 | }); 69 | 70 | it.each([ 71 | ['a', 'a'], 72 | ['a/b', 'a/b'], 73 | ['a/b/c', 'a/b/c'], 74 | ['a/b/c/d', 'a/b/c/d'], 75 | ['a/b/c/d/e', 'a/.../c/d/e'], 76 | ['a/b/c/d/e/f', 'a/.../d/e/f'], 77 | ['a\\b\\c\\d\\e\\f', 'a/.../d/e/f'] 78 | ])('makePathShort: %s shorted to %s', (value, expected) => { 79 | expect(makePathShort(value)).toBe(expected); 80 | }); 81 | 82 | it.each([ 83 | [ 84 | ['test', 'test', 'test'], 85 | ['test', 'test', 'test'] 86 | ], 87 | [ 88 | ['test', '--test', 'test'], 89 | ['test', '--test', 'test'] 90 | ], 91 | [ 92 | ['test', '--name', 'test'], 93 | ['test', '--name', 'test'] 94 | ], 95 | [ 96 | ['test', '-n', 'test'], 97 | ['test', '-n', 'test'] 98 | ], 99 | [ 100 | ['test', '--name', 'test', 'test'], 101 | ['test', '--name', 'test test'] 102 | ], 103 | [ 104 | ['test', '-n', 'test', 'test'], 105 | ['test', '-n', 'test test'] 106 | ], 107 | [ 108 | ['test', '-n', 'test', 'test', '--test'], 109 | ['test', '-n', 'test test', '--test'] 110 | ] 111 | ])('processCommandLineArguments: %s processed to %s', (value, expected) => { 112 | expect(processCommandLineArguments(value)).toEqual(expected); 113 | }); 114 | 115 | it.each([ 116 | [undefined, undefined], 117 | ['a', ['a']], 118 | [' a ', ['a']], 119 | ['a a', ['a']], 120 | ['a b', ['a', 'b']], 121 | [' a b c ', ['a', 'b', 'c']] 122 | ])('processComponentNameString: "%s" processed to %s', (value, expected) => { 123 | expect(processComponentNameString(value)).toEqual(expected); 124 | }); 125 | 126 | it.each([ 127 | [undefined, undefined], 128 | ['', undefined], 129 | ['a', ['a']], 130 | [' a ', [' a ']], 131 | ['B b', ['B b']], 132 | ['TestValue', ['Test', 'Value']], 133 | ['OneMoreTESTValue', ['One', 'More', 'T', 'E', 'S', 'T', 'Value']] 134 | ])('processComponentNameString: "%s" processed to %s', (value, expected) => { 135 | expect(splitStringByCapitalLetter(value)).toEqual(expected); 136 | }); 137 | 138 | it.each([ 139 | ['index.ts', true], 140 | ['[name].tsx', true], 141 | ['[name].module.css', false] 142 | ])('getIsFileAlreadyExists: is file exists "%s" ? %s', (fileNameTemplate, expected) => { 143 | const result = getIsFileAlreadyExists({ 144 | root, 145 | project, 146 | fileNameTemplate, 147 | objectName: 'TestComponent', 148 | processFileAndFolderName: config.processFileAndFolderName, 149 | resultPath, 150 | projectRootPath 151 | }); 152 | 153 | expect( 154 | result 155 | ).toEqual(expected); 156 | }); 157 | 158 | it.each([ 159 | ['TestComponent', ['Test', 'Component']], 160 | ['test-component', ['test', 'component']], 161 | ['_test__Component__', ['test', 'Component']], 162 | ['test-component123test', ['test', 'component', '123', 'test']] 163 | ])('getObjectNameParts: parts from "%s" mast be "%s"', (name, expected) => { 164 | expect(getObjectNameParts(name)).toEqual(expected); 165 | }); 166 | 167 | const allCases: [TypingCases, string][] = [ 168 | ['camelCase', 'testComponent123'], 169 | ['PascalCase', 'TestComponent123'], 170 | ['dash-case', 'test-component-123'], 171 | ['snake_case', 'test_component_123'] 172 | ]; 173 | 174 | it.each( 175 | allCases 176 | .map((currentCase) => { 177 | return allCases.map((c) => [currentCase[1], ...c]); 178 | }) 179 | .reduce((acc, val) => [...acc, ...val], []) // flat (for nodejs 10) 180 | )('mapNameToCase: value "%s" in "%s" case must be like "%s"', (name, toCase, expected) => { 181 | expect(mapNameToCase(name, toCase as TypingCases)).toEqual(expected); 182 | }); 183 | 184 | it.each([ 185 | ['style test component bug', ['style', 'test', 'bug'], false, ['bug']], 186 | ['bug style component test', ['bug', 'style', 'component', 'test'], true, ['bug']], 187 | ['bug[123] component test', ['bug', 'component', 'test'], true, ['bug']], 188 | ['bug component[1] test', ['bug', 'component', 'test'], true, ['bug']], 189 | ['no', ['no'], false, []] 190 | ])('getFileTemplates: %s must be converted to %s', (str, templates, withRequired, unexpected) => { 191 | commandLineFlags = { files: str } as CommandLineFlags; 192 | const { fileTemplates, undefinedFileTemplates, requiredTemplateNames } = getFileTemplates({ 193 | commandLineFlags, 194 | withRequired, 195 | templates: config.templates 196 | }); 197 | expect(fileTemplates).toEqual(templates); 198 | expect(undefinedFileTemplates).toEqual(unexpected); 199 | expect(requiredTemplateNames).toEqual(['index', 'component']); 200 | }); 201 | 202 | it.each([ 203 | ['style test component[0] bug', 'component', 0], 204 | ['style test component bug[123]', 'bug', 123], 205 | ['style test component bug[123]', 'test', undefined], 206 | ['style test component[abd] bug', 'component', undefined] 207 | ])('getFileIndexForTemplate: "%s" for %s must be %d', (str, template, index) => { 208 | expect(getFileIndexForTemplate(str, template)).toBe(index); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /tests/initialize.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | import mockFs from 'mock-fs'; 3 | 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | import { initialize } from '../src/initialize'; 8 | import * as consts from '../src/constants'; 9 | import { CommandLineFlags } from '../src/types'; 10 | 11 | import { mockProcess } from './testUtils'; 12 | import {getIsItemExists} from "../src/helpers"; 13 | 14 | describe('initialize', () => { 15 | const props: Parameters[0] = { 16 | root: process.cwd(), 17 | moduleRoot: '', 18 | commandLineFlags: { 19 | nfc: false 20 | } as CommandLineFlags 21 | }; 22 | const mkdirSpy = jest.spyOn(fs.promises, 'mkdir'); 23 | const configFileName = consts.CONFIG_FILE_NAME; 24 | const existentConfigName = 'existent.config.js'; 25 | const fsMockFolders = { 26 | node_modules: mockFs.load(path.resolve(__dirname, '../node_modules')), 27 | templates: mockFs.load(path.resolve(__dirname, '../templates')), 28 | templatesFolder: {}, 29 | 'defaultConfig.cjs': '', 30 | [existentConfigName]: '' 31 | }; 32 | 33 | mockProcess(); 34 | 35 | beforeEach(() => { 36 | jest.clearAllMocks(); 37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 | (consts.CONFIG_FILE_NAME as any) = configFileName; 39 | mockFs(fsMockFolders); 40 | }); 41 | 42 | afterAll(() => { 43 | mockFs.restore(); 44 | }); 45 | 46 | it('config exists, skip everything', async () => { 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | (consts.CONFIG_FILE_NAME as any) = existentConfigName; 49 | const result = await initialize(props); 50 | expect(result).toBeUndefined(); 51 | }); 52 | 53 | it('config not exists, yes to everything', async () => { 54 | prompts.inject([true, true, 'templatesFolder']); 55 | const result = await initialize(props); 56 | expect(result).toBeUndefined(); 57 | expect(mkdirSpy).toBeCalledTimes(0); 58 | }); 59 | 60 | it('config not exists, disagree', async () => { 61 | prompts.inject([false]); 62 | const result = await initialize(props); 63 | expect(result).toBeUndefined(); 64 | }); 65 | 66 | it('config not exists, agree but create a folder for templates', async () => { 67 | const newTemplatesFolder = 'non-existent-template-folder'; 68 | prompts.inject([true, true, newTemplatesFolder]); 69 | await initialize(props); 70 | expect(mkdirSpy).toBeCalledTimes(1); 71 | expect(await getIsItemExists(path.resolve(__dirname, '../', newTemplatesFolder))).toBe(true); 72 | }); 73 | 74 | it('config not exists, disagree about folder for templates', async () => { 75 | prompts.inject([true, false]); 76 | const result = await initialize(props); 77 | expect(result).toBeUndefined(); 78 | expect(mkdirSpy).toBeCalledTimes(0); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /tests/setComponentNames.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { setComponentNames } from '../src/setComponentNames'; 4 | import { CommandLineFlags } from '../src/types'; 5 | 6 | import { mockProcess } from './testUtils'; 7 | 8 | describe('setComponentNames', () => { 9 | const props: Parameters[0] = { 10 | commandLineFlags: { name: '' } as CommandLineFlags, 11 | templateName: 'component' 12 | }; 13 | const { exitMock } = mockProcess(); 14 | 15 | beforeEach(() => { 16 | props.commandLineFlags.name = ''; 17 | }); 18 | 19 | it('normal input', async () => { 20 | const name = 'NormalComponentName'; 21 | prompts.inject([name]); 22 | expect(await setComponentNames(props)).toEqual([name]); 23 | }); 24 | 25 | it('normal input with several names', async () => { 26 | const name = 'NormalComponentName NormalComponentName2'; 27 | prompts.inject([name]); 28 | expect(await setComponentNames(props)).toEqual(name.split(' ')); 29 | }); 30 | 31 | it('normal input with several equal names', async () => { 32 | const name = 'NormalComponentName NormalComponentName'; 33 | prompts.inject([name]); 34 | expect(await setComponentNames(props)).toEqual([name.split(' ')[0]]); 35 | }); 36 | 37 | it('normal input by commandLine', async () => { 38 | const name = 'NormalComponentName'; 39 | props.commandLineFlags.name = name; 40 | expect(await setComponentNames(props)).toEqual([name]); 41 | }); 42 | 43 | it('empty input', async () => { 44 | const name = 'NormalComponentName'; 45 | prompts.inject(['', name]); 46 | expect(await setComponentNames(props)).toEqual([name]); 47 | }); 48 | 49 | it('unexpected input', async () => { 50 | const name = 'NormalComponentName'; 51 | prompts.inject(['!%$@', name]); 52 | expect(await setComponentNames(props)).toEqual([name]); 53 | }); 54 | 55 | it('undefined component name', async () => { 56 | prompts.inject([undefined]); 57 | await setComponentNames(props); 58 | expect(exitMock).toBeCalledTimes(1); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/setComponentTemplate.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { setComponentTemplate } from '../src/setComponentTemplate'; 4 | import { CommandLineFlags, Config } from '../src/types'; 5 | 6 | import { mockConsole, mockProcess } from './testUtils'; 7 | 8 | describe('setComponentTemplate', () => { 9 | const props: Parameters[0] = { 10 | config: { 11 | templates: {} 12 | } as Config, 13 | commandLineFlags: { 14 | template: '' 15 | } as CommandLineFlags 16 | }; 17 | const { exitMock } = mockProcess(); 18 | mockConsole(); 19 | 20 | beforeEach(() => { 21 | props.config.templates = {}; 22 | }); 23 | 24 | it('basic config with component template', async () => { 25 | const { templateName } = await setComponentTemplate(props); 26 | expect(templateName).toBe('component'); 27 | }); 28 | 29 | it('config with array of components but with only one', async () => { 30 | props.config.templates = [ 31 | { 32 | name: 'service', 33 | files: {} 34 | } 35 | ]; 36 | const { templateName } = await setComponentTemplate(props); 37 | expect(templateName).toBe('service'); 38 | }); 39 | 40 | it('config with array and select', async () => { 41 | props.config.templates = [ 42 | { 43 | name: 'component', 44 | files: {} 45 | }, 46 | { 47 | name: 'service', 48 | files: {} 49 | } 50 | ]; 51 | prompts.inject(['service']); 52 | const { templateName } = await setComponentTemplate(props); 53 | expect(templateName).toBe('service'); 54 | }); 55 | 56 | it('config with array and unique folder path', async () => { 57 | props.config.templates = [ 58 | { 59 | name: 'service', 60 | folderPath: 'src/components/', 61 | files: {} 62 | } 63 | ]; 64 | const { config, templateName } = await setComponentTemplate(props); 65 | expect(templateName).toBe('service'); 66 | expect(config.folderPath).toBe('src/components/'); 67 | }); 68 | 69 | it('config with array and wrong commandline flag', async () => { 70 | props.commandLineFlags.template = 'cmp'; 71 | props.config.templates = [ 72 | { 73 | name: 'component', 74 | files: {} 75 | } 76 | ]; 77 | await setComponentTemplate(props); 78 | expect(exitMock).toBeCalled(); 79 | }); 80 | 81 | it('config with array and right commandline flag', async () => { 82 | props.commandLineFlags.template = 'component'; 83 | props.config.templates = [ 84 | { 85 | name: 'component', 86 | files: {} 87 | } 88 | ]; 89 | const { templateName } = await setComponentTemplate(props); 90 | expect(templateName).toBe('component'); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/setPath.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | import mockFs from 'mock-fs'; 3 | 4 | import path from 'path'; 5 | 6 | import { filterChoicesByText, setPath } from '../src/setPath'; 7 | import { getProjectRootPath } from '../src/getProjectRootPath'; 8 | import * as helpers from '../src/helpers'; 9 | import { processPath } from '../src/helpers'; 10 | import { CommandLineFlags, Config } from '../src/types'; 11 | 12 | import { mockConsole, mockProcess } from './testUtils'; 13 | 14 | const emptyFolderPath = 'some/path/to/emptyFolder'; 15 | 16 | jest.mock('../src/getProjectRootPath', () => { 17 | return { 18 | getProjectRootPath: jest.fn(() => 'src') 19 | }; 20 | }); 21 | 22 | jest.mock('../src/setComponentNames', () => { 23 | return { 24 | setComponentNames: jest.fn(() => Promise.resolve([])) 25 | }; 26 | }); 27 | 28 | const getPath = ({ projectRootPath, resultPath }: { projectRootPath: string; resultPath: string }) => { 29 | return path 30 | .join(projectRootPath, resultPath) 31 | .replace(/[\\/]$/g, '') 32 | .replace(/\\/g, '/'); 33 | }; 34 | 35 | describe('setPath', () => { 36 | const props: Parameters[0] = { 37 | root: process.cwd(), 38 | project: '', 39 | commandLineFlags: { 40 | dest: '', 41 | name: 'Component' 42 | } as CommandLineFlags, 43 | config: { 44 | folderPath: 'src/' 45 | } as Config, 46 | templateName: 'component' 47 | }; 48 | const fsMockFolders = { 49 | node_modules: mockFs.load(path.resolve(__dirname, '../node_modules')), 50 | src: { 51 | folder1: { 52 | Component1: {} 53 | }, 54 | folder2: { 55 | Component2: {} 56 | }, 57 | manualPathFolder: {} 58 | }, 59 | [emptyFolderPath]: {} 60 | }; 61 | 62 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 | (helpers.isDirectory as any) = jest.fn(() => true); 64 | 65 | const { exitMock } = mockProcess(); 66 | mockConsole(); 67 | 68 | beforeEach(() => { 69 | jest.clearAllMocks(); 70 | props.commandLineFlags.dest = ''; 71 | mockFs(fsMockFolders); 72 | }); 73 | 74 | afterEach(() => { 75 | mockFs.restore(); 76 | }); 77 | 78 | it('default path', async () => { 79 | prompts.inject([1]); 80 | 81 | expect(getPath(await setPath(props))).toBe(processPath(props.config.folderPath as string)); 82 | }); 83 | 84 | it('first folder path', async () => { 85 | const anyFolderName = Object.keys(fsMockFolders.src)[0]; 86 | prompts.inject([anyFolderName, 1]); 87 | 88 | expect(getPath(await setPath(props))).toBe( 89 | processPath(path.join(props.config.folderPath as string, anyFolderName)) 90 | ); 91 | }); 92 | 93 | it('first folder and back path', async () => { 94 | const anyFolderName = Object.keys(fsMockFolders.src)[0]; 95 | prompts.inject([anyFolderName, -1, 1]); 96 | 97 | expect(getPath(await setPath(props))).toBe(processPath(props.config.folderPath as string)); 98 | }); 99 | 100 | it('manual set', async () => { 101 | const MANUAL_PATH = 'src/manualPathFolder'; 102 | props.commandLineFlags.dest = MANUAL_PATH; 103 | 104 | prompts.inject([1]); 105 | 106 | expect(getPath(await setPath(props))).toBe(MANUAL_PATH); 107 | }); 108 | 109 | it('empty folder', async () => { 110 | const pathToEmptyFolder = emptyFolderPath; 111 | props.commandLineFlags.dest = pathToEmptyFolder; 112 | expect(getPath(await setPath(props))).toBe(pathToEmptyFolder); 113 | }); 114 | 115 | it('folder is not exists', async () => { 116 | props.commandLineFlags.dest = '/src/nonExistentFolder'; 117 | await setPath(props); 118 | expect(exitMock).toBeCalledTimes(1); 119 | }); 120 | 121 | it('multi-path choice', async () => { 122 | props.config.folderPath = ['src/folder1', 'src/folder2']; 123 | prompts.inject([1]); 124 | await setPath(props); 125 | expect(getProjectRootPath).toBeCalledTimes(1); 126 | }); 127 | 128 | it('multi-path only one', async () => { 129 | props.config.folderPath = ['src', 'nonExistentFolder']; 130 | const { projectRootPath } = await setPath({ ...props, resultPathInput: 'any' }); 131 | expect(getProjectRootPath).toBeCalledTimes(0); 132 | expect(projectRootPath).toBe('src'); 133 | }); 134 | 135 | it('multi-path no one', async () => { 136 | props.config.folderPath = ['nonExistentFolder', 'nonExistentFolder']; 137 | prompts.inject([1]); 138 | await setPath(props); 139 | expect(exitMock).toBeCalledTimes(1); 140 | }); 141 | 142 | it('filterChoicesByText', () => { 143 | const mapStringsToTitles = (str: string[]) => str.map((s) => ({ title: s })); 144 | expect(filterChoicesByText(mapStringsToTitles(['>> Here <<', 'folder', 'item', 'folder2']), '', true)).toEqual( 145 | mapStringsToTitles(['>> Here <<', 'folder', 'item', 'folder2']) 146 | ); 147 | expect( 148 | filterChoicesByText(mapStringsToTitles(['>> Here <<', 'folder', 'item', 'folder2']), 'item', true) 149 | ).toEqual(mapStringsToTitles(['item'])); 150 | expect( 151 | filterChoicesByText( 152 | mapStringsToTitles(['< Back', '>> Here <<', 'folder', 'item', 'folder2']), 153 | 'item', 154 | false 155 | ) 156 | ).toEqual(mapStringsToTitles(['item'])); 157 | expect( 158 | filterChoicesByText(mapStringsToTitles(['< Back', '>> Here <<', 'folder', 'item', 'folder2']), 'b', false) 159 | ).toEqual(mapStringsToTitles([])); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/setProject.test.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | 3 | import { setProject } from '../src/setProject'; 4 | import * as helpers from '../src/helpers'; 5 | import { CommandLineFlags, Config } from '../src/types'; 6 | 7 | import { mockConsole, mockProcess } from './testUtils'; 8 | 9 | jest.mock('fs', () => { 10 | return { 11 | existsSync: (p: string) => !p.includes('NONEXISTENT_FOLDER'), 12 | promises: { 13 | readdir: () => { 14 | return Promise.resolve(['Folder1', 'Folder2']); 15 | } 16 | } 17 | }; 18 | }); 19 | 20 | describe('setProject', () => { 21 | const props: Parameters[0] = { 22 | root: process.cwd(), 23 | project: '', 24 | commandLineFlags: { 25 | dest: '', 26 | project: '' 27 | } as CommandLineFlags, 28 | config: { 29 | multiProject: true, 30 | folderPath: 'Folder1' 31 | } as Config, 32 | templateName: 'component' 33 | }; 34 | const { exitMock } = mockProcess(); 35 | mockConsole(); 36 | 37 | beforeEach(() => { 38 | jest.clearAllMocks(); 39 | delete props.project; 40 | props.commandLineFlags.dest = ''; 41 | props.commandLineFlags.project = ''; 42 | props.config.folderPath = 'Folder1'; 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 44 | (helpers.isDirectory as any) = jest.fn(() => true); 45 | }); 46 | 47 | it('default project', async () => { 48 | prompts.inject(['Folder1']); 49 | expect(await setProject(props)).toBe('Folder1'); 50 | }); 51 | 52 | it('wrong project', async () => { 53 | props.commandLineFlags.project = 'NONEXISTENT_FOLDER'; 54 | await setProject(props); 55 | expect(exitMock).toBeCalled(); 56 | }); 57 | 58 | it('manual project', async () => { 59 | props.commandLineFlags.project = 'Folder1'; 60 | expect(await setProject(props)).toBe('Folder1'); 61 | expect(exitMock).toHaveBeenCalledTimes(0); 62 | }); 63 | 64 | it('several projects', async () => { 65 | props.config.folderPath = ['Folder1', 'Folder2']; 66 | prompts.inject(['Folder1']); 67 | expect(await setProject(props)).toBe('Folder1'); 68 | }); 69 | 70 | it('command line destinations', async () => { 71 | props.commandLineFlags.dest = 'src/'; 72 | expect(await setProject(props)).toBe(''); 73 | }); 74 | 75 | it('no projects exception', async () => { 76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 77 | (helpers.isDirectory as any) = jest.fn(() => false); 78 | await setProject(props); 79 | expect(exitMock).toBeCalled(); 80 | }); 81 | 82 | it('only one project match to folderPath', async () => { 83 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 84 | (helpers.isDirectory as any) = jest.fn((pathStr: string) => pathStr.includes('Folder1')); 85 | expect(await setProject(props)).toBe('Folder1'); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/testUtils.ts: -------------------------------------------------------------------------------- 1 | export const mockProcess = () => { 2 | const exitMock = jest.fn(); 3 | const stdoutWrite = jest.fn(); 4 | const realProcess = process; 5 | 6 | beforeEach(() => { 7 | global.process = { 8 | ...realProcess, 9 | exit: exitMock, 10 | stdout: { ...realProcess.stdout, write: stdoutWrite } 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | } as any; 13 | }); 14 | 15 | afterAll(() => (global.process = realProcess)); 16 | 17 | return { 18 | exitMock, 19 | stdoutWrite 20 | }; 21 | }; 22 | 23 | export const mockConsole = () => { 24 | const consoleError = jest.fn(); 25 | const realConsole = console; 26 | 27 | beforeEach(() => { 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 | global.console = { ...realConsole, error: consoleError } as any; 30 | }); 31 | 32 | afterAll(() => (global.console = realConsole)); 33 | 34 | return { consoleError }; 35 | }; 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "lib": [ 5 | "dom", 6 | "esnext" 7 | ], 8 | "allowJs": false, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | "noImplicitAny": true, 20 | "isolatedModules": false, 21 | "baseUrl": "./" 22 | }, 23 | "include": [ 24 | "index.ts", 25 | "src" 26 | ], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------