├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── docs.yml │ └── release.yml ├── .gitignore ├── .markdownlint.json ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ └── usage │ │ ├── 01-templates.md │ │ ├── 02-configuration_files.md │ │ ├── 03-cli.md │ │ ├── 04-node.md │ │ ├── 05-examples.md │ │ ├── 06-migration.md │ │ ├── _category_.yml │ │ └── index.md ├── docusaurus.config.ts ├── package.json ├── pnpm-lock.yaml ├── sidebars.ts ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── markdown-page.md ├── static │ ├── .nojekyll │ └── img │ │ ├── docusaurus-social-card.jpg │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── favicon.svg │ │ ├── intro.gif │ │ ├── logo-lg.png │ │ ├── logo-lg.svg │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg └── tsconfig.json ├── eslint.config.mjs ├── examples ├── .dotdir │ └── README.md └── test-input │ └── Component │ ├── .hidden-file │ ├── button-example.png │ ├── inner │ └── inner-{{name}}.txt │ └── {{pascalCase name}}.tsx ├── jest.config.ts ├── nodemon.json ├── package.json ├── pnpm-lock.yaml ├── scaffold.config.js ├── src ├── cmd.ts ├── config.ts ├── docs.css ├── file.ts ├── git.ts ├── index.ts ├── logger.ts ├── parser.ts ├── scaffold.ts ├── types.ts └── utils.ts ├── tests ├── config.test.ts ├── parser.test.ts ├── scaffold.test.ts └── utils.test.ts ├── tsconfig.json └── typedoc.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | tab_width = 2 3 | indent_style = space 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | 7 | [*.md] 8 | trim_trailing_whitespace = false 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: chenasraf 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: casraf 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: 13 | - "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22¤cy_code=ILS&source=url" 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug, needs-triage 6 | assignees: chenasraf 7 | --- 8 | 9 | #### Describe the bug 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | #### How To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Prepare templates: 18 | 19 | ```text 20 | This is my {{ template }} 21 | ``` 22 | 23 | 2. Run with args/config: 24 | 25 | ```shell 26 | npx simple-scaffold@latest -t input -o output TplName 27 | ``` 28 | 29 | #### Expected behavior\*\* 30 | 31 | A clear and concise description of what you expected to happen. 32 | 33 | #### Logs 34 | 35 | If applicable, paste your logs to help explain your problem. To see more logs, run the scaffold with 36 | `-v 1` to enable debug logging. 37 | 38 | #### Desktop (please complete the following information): 39 | 40 | - OS: [e.g. macOS, Windows, Linux] 41 | - OS Version: [e.g. Big Sur, 11, Ubuntu 20.04] 42 | - Node.js: [e.g. 16.8] 43 | - Simple Scaffold Version [e.g. 1.1.2] 44 | 45 | #### Additional context 46 | 47 | Add any other context about the problem here. 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement, needs-triage 6 | assignees: chenasraf 7 | --- 8 | 9 | #### Is your feature request related to a problem? Please describe. 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | #### Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | #### Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered, if 20 | applicable. 21 | 22 | #### Additional context 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | docs: 13 | name: Build Documentation 14 | runs-on: ubuntu-latest 15 | # if: "contains(github.event.head_commit.message, 'chore(release)')" 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - run: npm i -g pnpm 22 | - run: | 23 | pnpm install --frozen-lockfile 24 | cd docs && pnpm install --frozen-lockfile 25 | - run: pnpm docs:build 26 | - name: Deploy on GitHub Pages 27 | uses: peaceiris/actions-gh-pages@v3 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | publish_dir: ./docs/build 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | test: 17 | name: Test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | - run: npm i -g pnpm 25 | - run: pnpm run ci 26 | - run: pnpm test 27 | 28 | build: 29 | name: Build 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: 20 36 | - run: npm i -g pnpm 37 | - run: pnpm run ci 38 | - run: pnpm build 39 | 40 | release: 41 | name: Release Please 42 | if: github.event_name == 'push' 43 | needs: 44 | - build 45 | - test 46 | runs-on: ubuntu-latest 47 | outputs: 48 | release_created: ${{ steps.release.outputs.release_created }} 49 | steps: 50 | - uses: googleapis/release-please-action@v4 51 | id: release 52 | with: 53 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 54 | release-type: node 55 | target-branch: master 56 | 57 | publish: 58 | name: NPM Publish 59 | needs: release 60 | runs-on: ubuntu-latest 61 | if: ${{ needs.release.outputs.release_created }} 62 | steps: 63 | - uses: actions/checkout@v4 64 | - uses: actions/setup-node@v4 65 | with: 66 | node-version: 20 67 | registry-url: "https://registry.npmjs.org" 68 | - run: npm i -g pnpm 69 | - run: pnpm run ci 70 | - run: pnpm build 71 | - run: cd dist && npm publish 72 | env: 73 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # NPM 46 | .npmrc 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | examples/test-output/**/* 64 | dist/ 65 | .DS_Store 66 | tmp/ 67 | .nvmrc 68 | 69 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": { 3 | "line_length": 100, 4 | "tables": false, 5 | "code_blocks": false 6 | }, 7 | "no-inline-html": false, 8 | "first-line-h1": false 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "printWidth": 120, 5 | "tabWidth": 2, 6 | "overrides": [ 7 | { 8 | "files": "*.md", 9 | "options": { 10 | "printWidth": 100, 11 | "proseWrap": "always" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Scaffold", 9 | "type": "node", 10 | "request": "launch", 11 | "protocol": "inspector", 12 | "cwd": "${workspaceFolder}", 13 | "program": "${workspaceFolder}/test.ts", 14 | "outFiles": ["${workspaceRoot}/dist/test.js"], 15 | "env": { 16 | "NODE_ENV": "develop" 17 | }, 18 | "sourceMaps": true 19 | }, 20 | { 21 | "type": "node", 22 | "request": "attach", 23 | "name": "Attach by Process ID", 24 | "processId": "${command:PickProcess}" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "npm.packageManager": "yarn", 4 | "cSpell.words": [ 5 | "massarg", 6 | "MYCOMPONENT", 7 | "myname", 8 | "nobrace", 9 | "nocomment", 10 | "nodir", 11 | "noext", 12 | "nonegate", 13 | "subdir", 14 | "variabletoken" 15 | ], 16 | "[markdown]": { 17 | "editor.rulers": [87, 100] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "script": "build", 6 | "label": "build", 7 | "type": "npm", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "script": "dev", 12 | "label": "dev", 13 | "type": "npm", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "command": "pnpm typedoc --watch", 18 | "label": "typedoc --watch", 19 | "type": "shell", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "script": "start", 24 | "label": "start", 25 | "type": "npm", 26 | "problemMatcher": [] 27 | }, 28 | { 29 | "script": "test", 30 | "label": "test", 31 | "type": "npm", 32 | "problemMatcher": [] 33 | }, 34 | { 35 | "command": "pnpm test --watchAll", 36 | "label": "pnpm test --watchAll", 37 | "type": "shell", 38 | "problemMatcher": [] 39 | }, 40 | { 41 | "script": "cmd", 42 | "label": "cmd", 43 | "type": "npm", 44 | "problemMatcher": [] 45 | }, 46 | { 47 | "script": "build-test", 48 | "label": "build-test", 49 | "type": "npm", 50 | "problemMatcher": [] 51 | }, 52 | { 53 | "script": "build-cmd", 54 | "label": "build-cmd", 55 | "type": "npm", 56 | "problemMatcher": [] 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Chen Asraf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 |

6 | 7 | [GitHub](https://github.com/chenasraf/simple-scaffold) | 8 | [Documentation](https://chenasraf.github.io/simple-scaffold) | 9 | [NPM](https://npmjs.com/package/simple-scaffold) | [casraf.dev](https://casraf.dev) 10 | 11 | ![master](https://img.shields.io/github/package-json/v/chenasraf/simple-scaffold/master?label=master) 12 | ![build](https://img.shields.io/github/actions/workflow/status/chenasraf/simple-scaffold/release.yml?branch=master) 13 | 14 |

15 | 16 | Looking to streamline your workflow and get your projects up and running quickly? Look no further 17 | than Simple Scaffold - the easy-to-use NPM package that simplifies the process of organizing and 18 | copying your commonly-created files. 19 | 20 | With its agnostic and un-opinionated approach, Simple Scaffold can handle anything from a few simple 21 | files to an entire app boilerplate setup. Plus, with the power of **Handlebars.js** syntax, you can 22 | easily replace custom data and personalize your files to fit your exact needs. But that's not all - 23 | you can also use it to loop through data, use conditions, and write custom functions using helpers. 24 | 25 | Don't waste any more time manually copying and pasting files - let Simple Scaffold do the heavy 26 | lifting for you and start building your projects faster and more efficiently today! 27 | 28 |
29 | 30 | ![Intro](https://chenasraf.github.io/simple-scaffold/img/intro.gif) 31 | 32 |
33 | 34 | --- 35 | 36 | ## Documentation 37 | 38 | See full documentation [here](https://chenasraf.github.io/simple-scaffold). 39 | 40 | - [Command Line Interface (CLI) usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli) 41 | - [Node.js usage](https://chenasraf.github.io/simple-scaffold/docs/usage/node) 42 | - [Templates](https://chenasraf.github.io/simple-scaffold/docs/usage/templates) 43 | - [Configuration Files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files) 44 | - [Migration](https://chenasraf.github.io/simple-scaffold/docs/usage/migration) 45 | 46 | ## Getting Started 47 | 48 | ### Cheat Sheet 49 | 50 | A quick rundown of common usage scenarios: 51 | 52 | - Remote template config file on GitHub: 53 | 54 | ```sh 55 | npx simple-scaffold -g username/repository -c scaffold.js -k component NewComponentName 56 | ``` 57 | 58 | - Local template config file: 59 | 60 | ```sh 61 | npx simple-scaffold -c scaffold.js -k component NewComponentName 62 | ``` 63 | 64 | - Local one-time usage: 65 | 66 | ```sh 67 | npx simple-scaffold -t templates/component -o src/components NewComponentName 68 | ``` 69 | 70 | ### Remote Configurations 71 | 72 | The fastest way to get started is to is to re-use someone else's (or your own) work using a template 73 | repository. 74 | 75 | A remote config can be loaded in one of these ways: 76 | 77 | - For templates hosted on GitHub, the syntax is `-g user/repository_name` 78 | - For other Git platforms like GitLab, use `-g https://example.com/user/repository_name.git` 79 | 80 | These remote configurations support multiple scaffold groups, which can be specified using the 81 | `--key` or `-k` argument: 82 | 83 | ```sh 84 | $ npx simple-scaffold \ 85 | -g chenasraf/simple-scaffold \ 86 | -k component \ 87 | PageWrapper 88 | 89 | # equivalent to: 90 | $ npx simple-scaffold \ 91 | -g https://github.com/chenasraf/simple-scaffold.git \ 92 | -c scaffold.config.js \ 93 | -k component \ 94 | PageWrapper 95 | ``` 96 | 97 | By default, the template name is set to `default` when the `--key` option is not provided. 98 | 99 | See information about each option and flag using the `--help` flag, or read the 100 | [CLI documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/cli). For information 101 | about how configuration files work, [see below](#configuration-files). 102 | 103 | ### Configuration Files 104 | 105 | You can use a config file to more easily maintain all your scaffold definitions. 106 | 107 | `scaffold.config.js` 108 | 109 | ```js 110 | module.exports = { 111 | // use "default" to avoid needing to specify key 112 | // in this case the key is "component" 113 | component: { 114 | templates: ["templates/component"], 115 | output: "src/components", 116 | data: { 117 | // ... 118 | }, 119 | }, 120 | } 121 | ``` 122 | 123 | Then call your scaffold like this: 124 | 125 | ```sh 126 | $ npx simple-scaffold -c scaffold.config.js PageWrapper 127 | ``` 128 | 129 | This will allow you to avoid needing to remember which configs are needed or to store them in a 130 | one-liner in `package.json` which can get pretty long and messy, and harder to maintain. 131 | 132 | Also, this allows you to define more complex scaffolds with logic without having to use the Node.js 133 | API directly. (Of course you always have the option to still do so if you wish) 134 | 135 | More information can be found at the 136 | [Configuration Files documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files). 137 | 138 | ### Templates Structure 139 | 140 | Templates are **any file** in the a directory given to `--templates`. 141 | 142 | Simple Scaffold will maintain any file and directory structure you try to generate, while replacing 143 | any tokens such as `{{ name }}` or other custom-data using 144 | [Handlebars.js](https://handlebarsjs.com/). 145 | 146 | `templates/component/{{ pascalName name }}.tsx` 147 | 148 | ```tsx 149 | // Created: {{ now 'yyyy-MM-dd' }} 150 | import React from 'react' 151 | 152 | export default {{pascalCase name}}: React.FC = (props) => { 153 | return ( 154 |
{{pascalCase name}} Component
155 | ) 156 | } 157 | ``` 158 | 159 | To generate the template output once without saving a configuration file, run: 160 | 161 | ```sh 162 | # generate single component 163 | $ npx simple-scaffold \ 164 | -t templates/component \ 165 | -o src/components \ 166 | PageWrapper 167 | ``` 168 | 169 | This will immediately create the following file: `src/components/PageWrapper.tsx` 170 | 171 | ```tsx 172 | // Created: 2077-01-01 173 | import React from 'react' 174 | 175 | export default PageWrapper: React.FC = (props) => { 176 | return ( 177 |
PageWrapper Component
178 | ) 179 | } 180 | ``` 181 | 182 | ## Contributing 183 | 184 | I am developing this package on my free time, so any support, whether code, issues, or just stars is 185 | very helpful to sustaining its life. If you are feeling incredibly generous and would like to donate 186 | just a small amount to help sustain this project, I would be very very thankful! 187 | 188 | 189 | Buy Me a Coffee at ko-fi.com 194 | 195 | 196 | I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature, 197 | don't hesitate to open an appropriate issue and I will do my best to reply promptly. 198 | 199 | If you are a developer and want to contribute code, here are some starting tips: 200 | 201 | 1. Fork this repository 202 | 2. Run `pnpm install` 203 | 3. Run `pnpm dev` to start file watch mode 204 | 4. Make any changes you would like 205 | 5. Create tests for your changes 206 | 6. Update the relevant documentation (readme, code comments, type comments) 207 | 7. Create a PR on upstream 208 | 209 | Some tips on getting around the code: 210 | 211 | - Use `pnpm cmd` to use the CLI feature of Simple Scaffold from within the root directory, enabling 212 | you to test different behaviors. See `pnpm cmd -h` for more information. 213 | - Use `pnpm test` to run tests 214 | - Use `pnpm docs:build` to build the documentation once 215 | - Use `pnpm docs:watch` to start docs in watch mode 216 | - Use `pnpm build` to build the output 217 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | docs/api 23 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are 18 | reflected live without having to restart the server. 19 | 20 | ### Build 21 | 22 | ``` 23 | $ yarn build 24 | ``` 25 | 26 | This command generates static content into the `build` directory and can be served using any static 27 | contents hosting service. 28 | 29 | ### Deployment 30 | 31 | Using SSH: 32 | 33 | ``` 34 | $ USE_SSH=true yarn deploy 35 | ``` 36 | 37 | Not using SSH: 38 | 39 | ``` 40 | $ GIT_USER= yarn deploy 41 | ``` 42 | 43 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and 44 | push to the `gh-pages` branch. 45 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | } 4 | -------------------------------------------------------------------------------- /docs/docs/usage/01-templates.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Template Files 3 | --- 4 | 5 | # Preparing template files 6 | 7 | Put your template files anywhere, and fill them with tokens for replacement. 8 | 9 | Each template (not file) in the config array is parsed individually, and copied to the output 10 | directory. If a single template path contains multiple files (e.g. if you use a folder path or a 11 | glob pattern), the first directory up the tree of that template will become the base inside the 12 | defined output path for that template, while copying files recursively and maintaining their 13 | relative structure. 14 | 15 | Examples: 16 | 17 | > In the following examples, the config `name` is `AppName`, and the config `output` is `src`. 18 | 19 | | Input template | Files in template | Output path(s) | 20 | | ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ | 21 | | `./templates/{{ name }}.txt` | `./templates/{{ name }}.txt` | `src/AppName.txt` | 22 | | `./templates/directory` | `outer/{{name}}.txt`,
`outer2/inner/{{name}}.txt` | `src/outer/AppName.txt`,
`src/outer2/inner/AppName.txt` | 23 | | `./templates/others/**/*.txt` | `outer/{{name}}.jpg`,
`outer2/inner/{{name}}.txt` | `src/outer2/inner/AppName.txt` | 24 | 25 | ## Variable/token replacement 26 | 27 | Scaffolding will replace `{{ varName }}` in both the file name and its contents and put the 28 | transformed files in the output directory. 29 | 30 | The data available for the template parser is the data you pass to the `data` config option (or 31 | `--data` argument in CLI). 32 | 33 | For example, using the following command: 34 | 35 | ```bash 36 | npx simple-scaffold@latest \ 37 | --templates templates/components/{{name}}.jsx \ 38 | --output src/components \ 39 | --create-sub-folder true \ 40 | MyComponent 41 | ``` 42 | 43 | Will output a file with the path: 44 | 45 | ```text 46 | /src/components/MyComponent.jsx 47 | ``` 48 | 49 | The contents of the file will be transformed in a similar fashion. 50 | 51 | Your `data` will be pre-populated with the following: 52 | 53 | - `{{name}}`: raw name of the component as you entered it 54 | 55 | > Simple-Scaffold uses [Handlebars.js](https://handlebarsjs.com/) for outputting the file contents. 56 | > Any `data` you add in the config will be available for use with their names wrapped in `{{` and 57 | > `}}`. Other Handlebars built-ins such as `each`, `if` and `with` are also supported, see 58 | > [Handlebars.js Language Features](https://handlebarsjs.com/guide/#language-features) for more 59 | > information. 60 | 61 | ## Helpers 62 | 63 | ### Built-in Helpers 64 | 65 | Simple-Scaffold provides some built-in text transformation filters usable by Handlebars. 66 | 67 | For example, you may use `{{ snakeCase name }}` inside a template file or filename, and it will 68 | replace `My Name` with `my_name` when producing the final value. 69 | 70 | #### Capitalization Helpers 71 | 72 | | Helper name | Example code | Example output | 73 | | ------------ | ----------------------- | -------------- | 74 | | [None] | `{{ name }}` | my name | 75 | | `camelCase` | `{{ camelCase name }}` | myName | 76 | | `snakeCase` | `{{ snakeCase name }}` | my_name | 77 | | `startCase` | `{{ startCase name }}` | My Name | 78 | | `kebabCase` | `{{ kebabCase name }}` | my-name | 79 | | `hyphenCase` | `{{ hyphenCase name }}` | my-name | 80 | | `pascalCase` | `{{ pascalCase name }}` | MyName | 81 | | `upperCase` | `{{ upperCase name }}` | MY NAME | 82 | | `lowerCase` | `{{ lowerCase name }}` | my name | 83 | 84 | #### Date helpers 85 | 86 | | Helper name | Description | Example code | Example output | 87 | | -------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ | 88 | | `now` | Current date with format | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` | 89 | | `now` (with offset) | Current date with format, and with offset | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` | 90 | | `date` | Custom date with format | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` | 91 | | `date` (with offset) | Custom date with format, and with offset | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" -1 "days" }}` | `2041-31-12 15:00` | 92 | | `date` (with date from `--data`) | Custom date with format, with data from the `data` config option | `{{ date myCustomDate "yyyy-MM-dd HH:mm" }}` | `2042-01-01 12:00` | 93 | 94 | Further details: 95 | 96 | - We use [`date-fns`](https://date-fns.org/docs/) for parsing/manipulating the dates. If you want 97 | more information on the date tokens to use, refer to 98 | [their format documentation](https://date-fns.org/docs/format). 99 | 100 | - The date helper format takes the following arguments: 101 | 102 | ```typescript 103 | ( 104 | date: string, 105 | format: string, 106 | offsetAmount?: number, 107 | offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds" 108 | ) 109 | ``` 110 | 111 | - **The now helper** (for current time) takes the same arguments, minus the first one (`date`) as it 112 | is implicitly the current date: 113 | 114 | ```typescript 115 | ( 116 | format: string, 117 | offsetAmount?: number, 118 | offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds" 119 | ) 120 | ``` 121 | 122 | ### Custom Helpers 123 | 124 | You may also add your own custom helpers using the `helpers` options when using the JS API (rather 125 | than the CLI). The `helpers` option takes an object whose keys are helper names, and values are the 126 | transformation functions. For example, `upperCase` is implemented like so: 127 | 128 | ```typescript 129 | config.helpers = { 130 | upperCase: (text) => text.toUpperCase(), 131 | } 132 | ``` 133 | 134 | All of the above helpers (built in and custom) will also be available to you when using 135 | `subdirHelper` (`--sub-dir-helper`/`-H`) as a possible value. 136 | 137 | > To see more information on how helpers work and more features, see 138 | > [Handlebars.js docs](https://handlebarsjs.com/guide/#custom-helpers). 139 | -------------------------------------------------------------------------------- /docs/docs/usage/02-configuration_files.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration Files 3 | --- 4 | 5 | If you want to have reusable configurations which are complex and don't fit into command lines 6 | easily, or just want to manage your templates easier, you can use configuration files to load your 7 | scaffolding configurations. 8 | 9 | ## Creating config files 10 | 11 | Configuration files should be valid `.js`/`.mjs`/`.cjs`/`.json` files that contain valid Scaffold 12 | configurations. 13 | 14 | Each file hold multiple scaffolds. Each scaffold is a key, and its value is the configuration. For 15 | example: 16 | 17 | ```js 18 | module.exports = { 19 | component: { 20 | templates: ["templates/component"], 21 | output: "src/components", 22 | }, 23 | } 24 | ``` 25 | 26 | For the full configuration options, see [ScaffoldConfigFile](../api/modules#scaffoldconfigfile). 27 | 28 | If you want to supply functions inside the configurations, you must use a `.js`/`.cjs`/`.mjs` file 29 | as JSON does not support non-primitives. 30 | 31 | Another feature of using a JS file is you can export a function which will be loaded with the CMD 32 | config provided to Simple Scaffold. The `extra` key contains any values not consumed by built-in 33 | flags, so you can pre-process your args before outputting a config: 34 | 35 | ```js 36 | /** @type {import('simple-scaffold').ScaffoldConfigFile} */ 37 | module.exports = (config) => { 38 | console.log("Config:", config) 39 | return { 40 | component: { 41 | templates: ["templates/component"], 42 | output: "src/components", 43 | }, 44 | } 45 | } 46 | ``` 47 | 48 | If you want to provide templates that need no name (such as common config files which are easily 49 | portable between projects), you may provide the `name` property in the config object. 50 | 51 | You will always be able to override it using `--name NewName`, but it will be given a value by 52 | default and therefore it will no longer be required in the CLI arguments. 53 | 54 | ## Using a config file 55 | 56 | Once your config is created, you can use it by providing the file name to the `--config` (or `-c` 57 | for brevity), optionally alongside `--key` or `-k`, denoting the key to use as the config object, as 58 | you define in your config: 59 | 60 | ```sh 61 | simple-scaffold -c -k 62 | ``` 63 | 64 | For example: 65 | 66 | ```sh 67 | simple-scaffold -c scaffold.json -k component MyComponentName 68 | ``` 69 | 70 | If you don't want to supply a template/config name (e.g. `component`), `default` will be used: 71 | 72 | ```js 73 | /** @type {import('simple-scaffold').ScaffoldConfigFile} */ 74 | module.exports = { 75 | default: { 76 | // ... 77 | }, 78 | } 79 | ``` 80 | 81 | And then: 82 | 83 | ```sh 84 | # will use 'default' template 85 | simple-scaffold -c scaffold.json MyComponentName 86 | ``` 87 | 88 | - When the a directory is given, the following files in the given directory will be tried in order: 89 | 90 | - `scaffold.config.*` 91 | - `scaffold.*` 92 | 93 | Where `*` denotes any supported file extension, in the priority listed in 94 | [Supported file types](#supported-file-types) 95 | 96 | - When the `template_key` is ommitted, `default` will be used as default. 97 | 98 | ### Supported file types 99 | 100 | Any importable file is supported, depending on your build process. 101 | 102 | Common files include: 103 | 104 | - `*.mjs` 105 | - `*.cjs` 106 | - `*.js` 107 | - `*.json` 108 | 109 | When filenames are ommited when loading configs, these are the file extensions that will be 110 | automatically tried, by the specified order of priority. 111 | 112 | Note that you might need to find the correct extension of `.js`, `.cjs` or `.mjs` depending on your 113 | build process and your package type (for example, packages with `"type": "module"` in their 114 | `package.json` might be required to use `.mjs`.) 115 | 116 | ### Git/GitHub Templates 117 | 118 | You may specify a git or GitHub url to use remote templates. 119 | 120 | The command line option is `--git` or `-g`. 121 | 122 | - You may specify a full git or HTTPS git URL, which will be tried 123 | - You may specify a git username and project if the project is on GitHub 124 | 125 | ```sh 126 | # GitHub shorthand 127 | simple-scaffold -g / [-c ] [-k ] 128 | 129 | # Any git URL, git:// and https:// are supported 130 | simple-scaffold -g git://gitlab.com// [-c ] [-k ] 131 | simple-scaffold -g https://gitlab.com//.git [-c ] [-k ] 132 | ``` 133 | 134 | When a config file path is omitted, the files given in the list above will be tried on the root 135 | directory of the git repository. 136 | 137 | **Note:** The repository will be cloned to a temporary directory and removed after the scaffolding 138 | has been done. 139 | 140 | ## Use In Node.js 141 | 142 | You can also start a scaffold from Node.js with a remote file or URL config. 143 | 144 | Just use the `Scaffold.fromConfig` function: 145 | 146 | ```ts 147 | Scaffold.fromConfig( 148 | "scaffold.config.js", // file or HTTPS git URL 149 | { 150 | // name of the generated component 151 | name: "My Component", 152 | // key to load from the config 153 | key: "component", 154 | }, 155 | { 156 | // other config overrides 157 | }, 158 | ) 159 | ``` 160 | -------------------------------------------------------------------------------- /docs/docs/usage/03-cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: CLI Usage 3 | --- 4 | 5 | ## Available flags 6 | 7 | ```text 8 | Usage: simple-scaffold [options] 9 | ``` 10 | 11 | To see this and more information anytime, add the `-h` or `--help` flag to your call, e.g. 12 | `npx simple-scaffold@latest -h`. 13 | 14 | Options: 15 | 16 | | Option/flag \| Alias | Description | 17 | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 18 | | `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. You may omit the `--name` or `-n` for this specific option. | 19 | | `--config` \| `-c` | Filename or directory to load config from | 20 | | `--git` \| `-g` | Git URL or GitHub path to load a template from. | 21 | | `--key` \| `-k` | Key to load inside the config file. This overwrites the config key provided after the colon in `--config` (e.g. `--config scaffold.cmd.js:component)` | 22 | | `--output` \| `-o` | Path to output to. If `--subdir` is enabled, the subdir will be created inside this path. Default is current working directory. | 23 | | `--templates` \| `-t` | Template files to use as input. You may provide multiple files, each of which can be a relative or absolute path, or a glob pattern for multiple file matching easily. | 24 | | `--overwrite` \| `-w` \| `--no-overwrite` \| `-W` | Enable to override output files, even if they already exist. (default: false) | 25 | | `--data` \| `-d` | Add custom data to the templates. By default, only your app name is included. | 26 | | `--append-data` \| `-D` | Append additional custom data to the templates, which will overwrite `--data`, using an alternate syntax, which is easier to use with CLI: `-D key1=string -D key2:=raw` | 27 | | `--subdir` \| `-s` \| `--no-subdir` \| `-S` | Create a parent directory with the input name (and possibly `--subdir-helper` (default: false) | 28 | | `--subdir-helper` \| `-H` | Default helper to apply to subdir name when using `--subdir`. | 29 | | `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`)(default: false) | 30 | | `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none, debug, info, warn, error`. The provided level will display messages of the same level or higher. (default: info) | 31 | | `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. | 32 | | `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. (default: false) | 33 | | `--version` \| `-v` | Display version. | 34 | 35 | ### Before Write option 36 | 37 | This option allows you to preprocess a file before it is being written, such as running a formatter, 38 | linter or other commands. 39 | 40 | To use this option, pass it the command you would like to run. The following tokens will be replaced 41 | in your string: 42 | 43 | - `{{path}}` - the temporary file path for you to read from 44 | - `{{rawpath}}` - a different file path containing the raw file contents **before** they were 45 | handled by Handlebars.js. 46 | 47 | If none of these tokens are found, the regular (non-raw) path will be appended to the end of the 48 | command. 49 | 50 | ```shell 51 | simple-scaffold -c . --before-write prettier 52 | # command: prettier /tmp/somefile 53 | 54 | simple-scaffold -c . --before-write 'cat {{path}} | my-linter' 55 | # command: cat /tmp/somefile | my-linter 56 | ``` 57 | 58 | The command should return the string to write to the file through standard output (stdout), and not 59 | re-write the tmp file as it is not used for writing. Returning an empty string (after trimming) will 60 | discard the result and write the original file contents. 61 | 62 | See 63 | [beforeWrite](https://chenasraf.github.io/simple-scaffold/docs/api/interfaces/ScaffoldConfig#beforewrite) 64 | Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can 65 | output `''` for the same effect. 66 | 67 | ## Available Commands: 68 | 69 | | Command \| Alias | Description | 70 | | ---------------- | ------------------------------------------------------------------------------------ | 71 | | `list` \| `ls` | List all available templates for a given config. See `list -h` for more information. | 72 | 73 | ## Examples: 74 | 75 | > See 76 | > [Configuration Files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files) 77 | > for organizing multiple scaffold types into easy-to-maintain files 78 | 79 | Usage with config file 80 | 81 | ```shell 82 | $ simple-scaffold -c scaffold.cmd.js -k component MyComponent 83 | ``` 84 | 85 | Usage with GitHub config file 86 | 87 | ```shell 88 | $ simple-scaffold -g chenasraf/simple-scaffold -k component MyComponent 89 | ``` 90 | 91 | Usage with https git URL (for non-GitHub) 92 | 93 | ```shell 94 | $ simple-scaffold \ 95 | -g https://example.com/user/template.git \ 96 | -c scaffold.cmd.js \ 97 | -k component \ 98 | MyComponent 99 | ``` 100 | 101 | Full syntax with config path and template key (applicable to all above methods) 102 | 103 | ```shell 104 | $ simple-scaffold -c scaffold.cmd.js -k component MyComponent 105 | ``` 106 | 107 | Excluded template key, assumes 'default' key 108 | 109 | ```shell 110 | $ simple-scaffold -c scaffold.cmd.js MyComponent 111 | ``` 112 | 113 | Shortest syntax for GitHub, assumes file 'scaffold.cmd.js' and template key 'default' 114 | 115 | ```shell 116 | $ simple-scaffold -g chenasraf/simple-scaffold MyComponent 117 | ``` 118 | 119 | You can also add this as a script in your `package.json`: 120 | 121 | ```json 122 | { 123 | "scripts": { 124 | "scaffold-cfg": "npx simple-scaffold -c scaffold.cmd.js -k component", 125 | "scaffold-gh": "npx simple-scaffold -g chenasraf/simple-scaffold -k component", 126 | "scaffold": "npx simple-scaffold@latest -t scaffolds/component/**/* -o src/components -d '{\"myProp\": \"propName\", \"myVal\": 123}'" 127 | "scaffold-component": "npx simple-scaffold -c scaffold.cmd.js -k" 128 | } 129 | } 130 | ``` 131 | -------------------------------------------------------------------------------- /docs/docs/usage/04-node.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Node.js Usage 3 | --- 4 | 5 | ## Overview 6 | 7 | You can build the scaffold yourself, if you want to create more complex arguments, scaffold groups, 8 | etc - simply pass a config object to the Scaffold function when you are ready to start. 9 | 10 | The config takes similar arguments to the command line. The full type definitions can be found in 11 | [src/types.ts](https://github.com/chenasraf/simple-scaffold/blob/develop/src/types.ts#L13). 12 | 13 | See the full 14 | [documentation](https://chenasraf.github.io/simple-scaffold/interfaces/ScaffoldConfig.html) for the 15 | configuration options and their behavior. 16 | 17 | ```ts 18 | interface ScaffoldConfig { 19 | name: string 20 | templates: string[] 21 | output: FileResponse 22 | subdir?: boolean 23 | data?: Record 24 | overwrite?: FileResponse 25 | quiet?: boolean 26 | verbose?: LogLevel 27 | dryRun?: boolean 28 | helpers?: Record 29 | subdirHelper?: DefaultHelpers | string 30 | beforeWrite?( 31 | content: Buffer, 32 | rawContent: Buffer, 33 | outputPath: string, 34 | ): string | Buffer | undefined | Promise 35 | } 36 | ``` 37 | 38 | ### Before Write option 39 | 40 | This option allows you to preprocess a file before it is being written, such as running a formatter, 41 | linter or other commands. 42 | 43 | To use this option, you can run any async/blocking command, and return a string as the final output 44 | to be used as the file contents. 45 | 46 | Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by 47 | Simple Scaffold. 48 | 49 | ## Example 50 | 51 | This is an example of loading a complete scaffold via Node.js: 52 | 53 | ```typescript 54 | import Scaffold from "simple-scaffold" 55 | 56 | const config = { 57 | name: "component", 58 | templates: [path.join(__dirname, "scaffolds", "component")], 59 | output: path.join(__dirname, "src", "components"), 60 | subdir: true, 61 | subdirHelper: "upperCase", 62 | data: { 63 | property: "value", 64 | }, 65 | helpers: { 66 | twice: (text) => [text, text].join(" "), 67 | }, 68 | // return a string to replace the final file contents after pre-processing, or `undefined` 69 | // to keep it as-is 70 | beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase(), 71 | } 72 | 73 | const scaffold = Scaffold(config) 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/docs/usage/05-examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | ## Example files 6 | 7 | ### Input 8 | 9 | - Input file path: 10 | 11 | ```text 12 | project → scaffold → {{Name}}.js → src → components 13 | ``` 14 | 15 | - Input file contents: 16 | 17 | ```typescript 18 | /** 19 | * Author: {{ author }} 20 | * Date: {{ now "yyyy-MM-dd" }} 21 | */ 22 | import React from 'react' 23 | 24 | export default {{camelCase name}}: React.FC = (props) => { 25 | return ( 26 |
{{camelCase name}} Component
27 | ) 28 | } 29 | ``` 30 | 31 | ### Output 32 | 33 | - Output file path: 34 | 35 | - With `subdir = false` (default): 36 | 37 | ```text 38 | project → src → components → MyComponent.js 39 | ``` 40 | 41 | - With `subdir = true`: 42 | 43 | ```text 44 | project → src → components → MyComponent → MyComponent.js 45 | ``` 46 | 47 | - With `subdir = true` and `subdirHelper = 'upperCase'`: 48 | 49 | ```text 50 | project → src → components → MYCOMPONENT → MyComponent.js 51 | ``` 52 | 53 | - Output file contents: 54 | 55 | ```typescript 56 | /** 57 | * Author: My Name 58 | * Date: 2077-01-01 59 | */ 60 | import React from 'react' 61 | 62 | export default MyComponent: React.FC = (props) => { 63 | return ( 64 |
MyComponent Component
65 | ) 66 | } 67 | ``` 68 | 69 | ## Example run commands 70 | 71 | ### Command Example 72 | 73 | ```bash 74 | simple-scaffold \ 75 | -t project/scaffold/**/* \ 76 | -o src/components \ 77 | -d '{"className": "myClassName","author": "My Name"}' 78 | MyComponent 79 | ``` 80 | 81 | ### Equivalent Node Module Example 82 | 83 | ```typescript 84 | import Scaffold from "simple-scaffold" 85 | 86 | async function main() { 87 | await Scaffold({ 88 | name: "MyComponent", 89 | templates: ["project/scaffold/**/*"], 90 | output: ["src/components"], 91 | data: { 92 | className: "myClassName", 93 | author: "My Name", 94 | }, 95 | }) 96 | console.log("Done.") 97 | } 98 | ``` 99 | 100 | ### Re-usable config 101 | 102 | #### Shell 103 | 104 | ```bash 105 | # cjs 106 | simple-scaffold -c scaffold.cjs MyComponent \ 107 | -d '{"className": "myClassName","author": "My Name"}' 108 | # mjs 109 | simple-scaffold -c scaffold.mjs MyComponent \ 110 | -d '{"className": "myClassName","author": "My Name"}' 111 | ``` 112 | 113 | #### scaffold.cjs 114 | 115 | ```js 116 | module.exports = (config) => ({ 117 | default: { 118 | templates: ["project/scaffold/**/*"], 119 | output: ["src/components"], 120 | data: { 121 | className: "myClassName", 122 | author: "My Name", 123 | }, 124 | }, 125 | }) 126 | ``` 127 | 128 | #### scaffold.mjs 129 | 130 | ```js 131 | export default (config) => ({ 132 | default: { 133 | templates: ["project/scaffold/**/*"], 134 | output: ["src/components"], 135 | data: { 136 | className: "myClassName", 137 | author: "My Name", 138 | }, 139 | }, 140 | }) 141 | ``` 142 | -------------------------------------------------------------------------------- /docs/docs/usage/06-migration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Migration 3 | --- 4 | 5 | ## v1.x to v2.x 6 | 7 | ### CLI option changes 8 | 9 | - Several changes to how remote configs are loaded via CLI: 10 | - The `:template_key` syntax has been removed. You can still use `-k template_key` to achieve the 11 | same result. 12 | - The `--github` (`-gh`) flag has been replaced by a generic `--git` (`-g`) one, which handles any 13 | git URL. Providing a partial GitHub path will default to trying to find the project on GitHub, 14 | e.g. `-g username/project` 15 | - The `#template_file` syntax has been removed, you may use `--config` or `-c` to tell Simple 16 | Scaffold which file to look for inside the git project. There is a default file priority list 17 | which can find the file for you if it is in one of the supported filenames. 18 | - `verbose` can now take the names `debug`, `info`, `warn`, `error` or `none` (case insensitive). 19 | - `--create-sub-folder` (`-s`) has been renamed to `--subdir` (`-s`) in the CLI. The Node.js names 20 | have been changed as well. 21 | - `--sub-folder-name-helper` (`-sh`) has been renamed to `--subdir-helper` (`-sh`). The Node.js 22 | names have been changed as well. 23 | - All boolean flags no longer take a value. `-q` instead of `-q 1` or `-q true`, `-s` instead of 24 | `-s 1`, `-w` instead of `-w 1`, etc. 25 | 26 | ### Behavior changes 27 | 28 | - Data is no longer auto-populated with `Name` (PascalCase) by default. You can just use the helper 29 | in your templates contents and file names, simply use `{{ pascalCase name }}` instead of 30 | `{{ Name }}`. `Name` was arbitrary and it is confusing (is it `Title Case`? `PascalCase`? only 31 | reading the docs can tell). Alternatively, you can inject the transformed name into your `data` 32 | manually using a scaffold config file, by using the Node API or by appending the data to the CLI 33 | invocation. 34 | 35 | ## v0.x to v1.x 36 | 37 | In Simple Scaffold v1.0, the entire codebase was overhauled, yet usage remains mostly the same 38 | between versions. With these notable exceptions: 39 | 40 | - Some of the argument names have changed 41 | - Template syntax has been improved 42 | - The command to run Scaffold has been simplified from `new SimpleScaffold(opts).run()` to 43 | `SimpleScaffold(opts)`, which now returns a promise that you can await to know when the process 44 | has been completed. 45 | 46 | ### Argument changes 47 | 48 | - `locals` has been renamed to `data`. The appropriate command line args have been updated as well 49 | to `--data` | `-d`. 50 | - Additional options have been added to both CLI and Node interfaces. See 51 | [Command Line Interface (CLI) usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli) 52 | and [Node.js usage](https://chenasraf.github.io/simple-scaffold/docs/usage/node) for more 53 | information. 54 | 55 | ### Template syntax changes 56 | 57 | Simple Scaffold still uses Handlebars.js to handle template content and file names. However, helpers 58 | have been added to remove the need for you to pre-process the template data on simple use-cases such 59 | as case type manipulation (converting to camel case, snake case, etc) 60 | 61 | See the readme for the full information on how to use these helpers and which are available. 62 | -------------------------------------------------------------------------------- /docs/docs/usage/_category_.yml: -------------------------------------------------------------------------------- 1 | label: "Usage" 2 | -------------------------------------------------------------------------------- /docs/docs/usage/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | --- 4 | 5 | - [CLI Usage](cli) 6 | - [Configuration Files](configuration_files) 7 | - [Examples](examples) 8 | - [Migration](migration) 9 | - [Node.js Usage](node) 10 | - [Template Files](templates) 11 | -------------------------------------------------------------------------------- /docs/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import { themes as prismThemes } from "prism-react-renderer" 2 | import type { Config } from "@docusaurus/types" 3 | import type * as Preset from "@docusaurus/preset-classic" 4 | 5 | const config: Config = { 6 | title: "Simple Scaffold", 7 | tagline: "Generate any file structure - from single components to entire app boilerplates, with a single command.", 8 | favicon: "img/favicon.svg", 9 | 10 | // Set the production url of your site here 11 | url: "https://chenasraf.github.io", 12 | // Set the // pathname under which your site is served 13 | // For GitHub pages deployment, it is often '//' 14 | baseUrl: "/simple-scaffold", 15 | 16 | // GitHub pages deployment config. 17 | // If you aren't using GitHub pages, you don't need these. 18 | organizationName: "chenasraf", // Usually your GitHub org/user name. 19 | projectName: "simple-scaffold", // Usually your repo name. 20 | 21 | onBrokenLinks: "warn", 22 | onBrokenMarkdownLinks: "warn", 23 | 24 | // Even if you don't use internationalization, you can use this field to set 25 | // useful metadata like html lang. For example, if your site is Chinese, you 26 | // may want to replace "en" with "zh-Hans". 27 | i18n: { 28 | defaultLocale: "en", 29 | locales: ["en"], 30 | }, 31 | 32 | plugins: [ 33 | [ 34 | "docusaurus-plugin-typedoc", 35 | 36 | // Plugin / TypeDoc options 37 | { 38 | entryPoints: ["../src/index.ts"], 39 | tsconfig: "../tsconfig.json", 40 | 41 | // typedoc options 42 | watch: process.env.NODE_ENV === "development", 43 | excludePrivate: true, 44 | excludeProtected: true, 45 | excludeInternal: true, 46 | // includeVersion: true, 47 | categorizeByGroup: false, 48 | sort: ["visibility"], 49 | categoryOrder: ["Main", "*"], 50 | media: "media", 51 | entryPointStrategy: "expand", 52 | validation: { 53 | invalidLink: true, 54 | }, 55 | }, 56 | ], 57 | ], 58 | 59 | presets: [ 60 | [ 61 | "classic", 62 | { 63 | docs: { 64 | sidebarPath: "./sidebars.ts", 65 | // Please change this to your repo. 66 | // Remove this to remove the "edit this page" links. 67 | editUrl: "https://github.com/chenasraf/simple-scaffold/blob/master/docs", 68 | }, 69 | theme: { 70 | customCss: "./src/css/custom.css", 71 | }, 72 | googleTagManager: { 73 | containerId: "GTM-KHQS9TQ", 74 | }, 75 | } satisfies Preset.Options, 76 | ], 77 | ], 78 | 79 | themeConfig: { 80 | // Replace with your project's social card 81 | image: "img/docusaurus-social-card.jpg", 82 | navbar: { 83 | title: "Simple Scaffold", 84 | logo: { 85 | alt: "Simple Scaffold", 86 | src: "img/favicon.svg", 87 | }, 88 | items: [ 89 | { 90 | position: "left", 91 | type: "docSidebar", 92 | sidebarId: "api", 93 | label: "API", 94 | to: "docs/api", 95 | }, 96 | { 97 | position: "left", 98 | type: "docSidebar", 99 | sidebarId: "usage", 100 | label: "Usage", 101 | to: "docs/usage", 102 | }, 103 | // { 104 | // position: "left", 105 | // type: "docSidebar", 106 | // sidebarId: "docs", 107 | // }, 108 | // { 109 | // label: "API", 110 | // href: "/docs/api", 111 | // position: "left", 112 | // }, 113 | // { 114 | // label: "Usage", 115 | // href: "/docs/usage", 116 | // position: "left", 117 | // }, 118 | { 119 | href: "https://npmjs.com/package/simple-scaffold", 120 | label: "NPM", 121 | position: "right", 122 | }, 123 | { 124 | href: "https://github.com/chenasraf/simple-scaffold", 125 | label: "GitHub", 126 | position: "right", 127 | }, 128 | ], 129 | }, 130 | footer: { 131 | style: "dark", 132 | links: [ 133 | { 134 | title: "Docs", 135 | items: [ 136 | { 137 | label: "Tutorial", 138 | to: "/docs/intro", 139 | }, 140 | ], 141 | }, 142 | { 143 | title: "More from @casraf", 144 | items: [ 145 | { 146 | label: "Massarg - CLI Argument Parser", 147 | href: "https://chenasraf.github.io/massarg", 148 | }, 149 | { 150 | label: "Website", 151 | href: "https://casraf.dev", 152 | }, 153 | ], 154 | }, 155 | { 156 | title: "More", 157 | items: [ 158 | { 159 | label: "npm", 160 | href: "https://npmjs.com/package/simple-scaffold", 161 | }, 162 | { 163 | label: "GitHub", 164 | href: "https://github.com/chenasraf/simple-scaffold", 165 | }, 166 | ], 167 | }, 168 | ], 169 | copyright: `Copyright © ${new Date().getFullYear()} Chen Asraf. Built with Docusaurus.`, 170 | }, 171 | prism: { 172 | theme: prismThemes.github, 173 | darkTheme: prismThemes.dracula, 174 | }, 175 | } satisfies Preset.ThemeConfig, 176 | } 177 | 178 | export default config 179 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-scaffold-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start --port 3001", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.1.1", 19 | "@docusaurus/plugin-google-tag-manager": "^3.1.1", 20 | "@docusaurus/preset-classic": "3.1.1", 21 | "@mdx-js/react": "^3.0.0", 22 | "clsx": "^2.1.0", 23 | "prism-react-renderer": "^2.3.1", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@docusaurus/module-type-aliases": "3.1.1", 29 | "@docusaurus/tsconfig": "3.1.1", 30 | "@docusaurus/types": "3.1.1", 31 | "docusaurus-plugin-typedoc": "^0.22.0", 32 | "typedoc": "^0.25.7", 33 | "typedoc-plugin-markdown": "^3.17.1", 34 | "typescript": "~5.2.2" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.5%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 3 chrome version", 44 | "last 3 firefox version", 45 | "last 5 safari version" 46 | ] 47 | }, 48 | "engines": { 49 | "node": ">=18.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type { SidebarsConfig } from "@docusaurus/plugin-content-docs" 2 | 3 | /** 4 | * Creating a sidebar enables you to: 5 | - create an ordered group of docs 6 | - render a sidebar for each doc of that group 7 | - provide next/previous navigation 8 | 9 | The sidebars can be generated from the filesystem, or explicitly defined here. 10 | 11 | Create as many sidebars as you want. 12 | */ 13 | const sidebars: SidebarsConfig = { 14 | // By default, Docusaurus generates a sidebar from the docs folder structure 15 | // docs: [{ type: "autogenerated", dirName: "." }], 16 | usage: ["usage/index"], 17 | api: ["api/index"], 18 | docs: [{ type: "autogenerated", dirName: "." }], 19 | // docs: [ 20 | // { 21 | // type: "category", 22 | // label: "Guides", 23 | // link: { 24 | // type: "generated-index", 25 | // title: "Docusaurus Guides", 26 | // description: "Learn about the most important Docusaurus concepts!", 27 | // slug: "/category/docusaurus-guides", 28 | // keywords: ["guides"], 29 | // image: "/img/docusaurus.png", 30 | // }, 31 | // items: ["pages", "docs", "blog", "search"], 32 | // }, 33 | // ], 34 | // usage: [{ type: "autogenerated", dirName: "usage" }], 35 | // api: [{ type: "autogenerated", dirName: "api" }], 36 | 37 | // But you can create a sidebar manually 38 | /* 39 | tutorialSidebar: [ 40 | 'intro', 41 | 'hello', 42 | { 43 | type: 'category', 44 | label: 'Tutorial', 45 | items: ['tutorial-basics/create-a-document'], 46 | }, 47 | ], 48 | */ 49 | } 50 | 51 | export default sidebars 52 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import Heading from "@theme/Heading" 3 | import styles from "./styles.module.css" 4 | 5 | type FeatureItem = { 6 | title: string 7 | Svg: React.ComponentType> 8 | description: JSX.Element 9 | } 10 | 11 | const FeatureList: FeatureItem[] = [ 12 | { 13 | title: "Easy to Use", 14 | Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default, 15 | description: ( 16 | <> 17 | Generate anything from a simple component to an entire app boilerplate - you decide! Put dynamic data in your 18 | templates to quickly generate skeletons, formatted data dumps, or repetitive code - and immediately get to 19 | coding! 20 | 21 | ), 22 | }, 23 | { 24 | title: "Use It Anywhere, For Anything", 25 | Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default, 26 | description: ( 27 | <> 28 | Whether you need files specific to your project or commonly used templates - you can use them both locally or 29 | use Git to share them with your team. Spackle on some one-time-use data, and run one command. 30 | 31 | ), 32 | }, 33 | { 34 | title: "Handlebars Support", 35 | Svg: require("@site/static/img/undraw_docusaurus_react.svg").default, 36 | description: ( 37 | <> 38 | Did you think you stop at some static data? Generate entire mapped lists of items, pre-parse information, fake 39 | data, and more - you can attach any function or any data to your templates. Handlebars will parse it all and 40 | generate the files you need. 41 | 42 | ), 43 | }, 44 | ] 45 | 46 | function Feature({ title, Svg, description }: FeatureItem) { 47 | return ( 48 |
49 |
50 | 51 |
52 |
53 | {title} 54 |

{description}

55 |
56 |
57 | ) 58 | } 59 | 60 | export default function HomepageFeatures(): JSX.Element { 61 | return ( 62 |
63 |
64 |
65 | {FeatureList.map((props, idx) => ( 66 | 67 | ))} 68 |
69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme="dark"] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | gap: 2rem; 24 | } 25 | 26 | .heroImage { 27 | margin-bottom: 1.5rem; 28 | } 29 | 30 | .logo { 31 | width: 100%; 32 | max-width: 300px; 33 | margin: 0 auto; 34 | } 35 | -------------------------------------------------------------------------------- /docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import Link from "@docusaurus/Link" 3 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext" 4 | import Layout from "@theme/Layout" 5 | import HomepageFeatures from "@site/src/components/HomepageFeatures" 6 | import Heading from "@theme/Heading" 7 | 8 | import styles from "./index.module.css" 9 | 10 | function HomepageHeader() { 11 | const { siteConfig } = useDocusaurusContext() 12 | return ( 13 |
14 |
15 | Simple Scaffold 16 | 17 | {siteConfig.title} 18 | 19 |

{siteConfig.tagline}

20 | Simple-Scaffold doing its thing 21 |
22 | 23 | API 24 | 25 | 26 | Usage 27 | 28 |
29 |
30 |
31 | ) 32 | } 33 | 34 | export default function Home(): JSX.Element { 35 | const { siteConfig } = useDocusaurusContext() 36 | return ( 37 | 38 | 39 |
40 | 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/docusaurus-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/docusaurus-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/favicon.png -------------------------------------------------------------------------------- /docs/static/img/intro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/intro.gif -------------------------------------------------------------------------------- /docs/static/img/logo-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/logo-lg.png -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_mountain.svg: -------------------------------------------------------------------------------- 1 | 2 | Easy to Use 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_tree.svg: -------------------------------------------------------------------------------- 1 | 2 | Focus on What Matters 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | 4 | export default [ 5 | ...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended), 6 | { 7 | rules: { 8 | 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 9 | '@typescript-eslint/no-unused-vars': [ 10 | 'warn', 11 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 12 | ], 13 | }, 14 | }, 15 | { 16 | ignores: ['node_modules/', 'build/', 'dist/', 'gen/'], 17 | }, 18 | ] 19 | -------------------------------------------------------------------------------- /examples/.dotdir/README.md: -------------------------------------------------------------------------------- 1 | # {{ name }} Readme 2 | 3 | TO DO: 4 | 5 | - [ ] ... 6 | -------------------------------------------------------------------------------- /examples/test-input/Component/.hidden-file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/examples/test-input/Component/.hidden-file -------------------------------------------------------------------------------- /examples/test-input/Component/button-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenasraf/simple-scaffold/c1536839e3426e91f742909af678334647d3e0d4/examples/test-input/Component/button-example.png -------------------------------------------------------------------------------- /examples/test-input/Component/inner/inner-{{name}}.txt: -------------------------------------------------------------------------------- 1 | {{name}} 2 | -------------------------------------------------------------------------------- /examples/test-input/Component/{{pascalCase name}}.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as css from "./{{pascalCase name}}.css" 3 | 4 | class {{pascalCase name}} extends React.Component { 5 | private {{ property }} 6 | 7 | constructor(props: any) { 8 | super(props) 9 | this.{{ property }} = {{ value }} 10 | } 11 | 12 | public render() { 13 | return
14 | } 15 | } 16 | 17 | export default {{pascalCase name}} 18 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/q9/0mns8fgd00b4t5j5lq2wh2yh0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | coveragePathIgnorePatterns: ["/node_modules/", "scaffold.config.js"], 30 | 31 | // Indicates which provider should be used to instrument code for coverage 32 | coverageProvider: "v8", 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // The default configuration for fake timers 52 | // fakeTimers: { 53 | // "enableGlobally": false 54 | // }, 55 | 56 | // Force coverage collection from ignored files using an array of glob patterns 57 | // forceCoverageMatch: [], 58 | 59 | // A path to a module which exports an async function that is triggered once before all test suites 60 | // globalSetup: undefined, 61 | 62 | // A path to a module which exports an async function that is triggered once after all test suites 63 | // globalTeardown: undefined, 64 | 65 | // A set of global variables that need to be available in all test environments 66 | // globals: {}, 67 | 68 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 69 | // maxWorkers: "50%", 70 | 71 | // An array of directory names to be searched recursively up from the requiring module's location 72 | // moduleDirectories: [ 73 | // "node_modules" 74 | // ], 75 | 76 | // An array of file extensions your modules use 77 | // moduleFileExtensions: [ 78 | // "js", 79 | // "mjs", 80 | // "cjs", 81 | // "jsx", 82 | // "ts", 83 | // "tsx", 84 | // "json", 85 | // "node" 86 | // ], 87 | 88 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 89 | // moduleNameMapper: {}, 90 | // moduleNameMapper: { 91 | // "#ansi-styles": "/node_modules/chalk/source/vendor/ansi-styles/index.js", 92 | // "#supports-color": "/node_modules/chalk/source/vendor/supports-color/index.js", 93 | // }, 94 | 95 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 96 | modulePathIgnorePatterns: ["/dist"], 97 | 98 | // Activates notifications for test results 99 | // notify: false, 100 | 101 | // An enum that specifies notification mode. Requires { notify: true } 102 | // notifyMode: "failure-change", 103 | 104 | // A preset that is used as a base for Jest's configuration 105 | preset: "ts-jest", 106 | 107 | // Run tests from one or more projects 108 | // projects: undefined, 109 | 110 | // Use this configuration option to add custom reporters to Jest 111 | // reporters: undefined, 112 | 113 | // Automatically reset mock state before every test 114 | // resetMocks: false, 115 | 116 | // Reset the module registry before running each individual test 117 | // resetModules: false, 118 | 119 | // A path to a custom resolver 120 | // resolver: undefined, 121 | 122 | // Automatically restore mock state and implementation before every test 123 | // restoreMocks: false, 124 | 125 | // The root directory that Jest should scan for tests and modules within 126 | // rootDir: undefined, 127 | 128 | // A list of paths to directories that Jest should use to search for files in 129 | // roots: [ 130 | // "" 131 | // ], 132 | 133 | // Allows you to use a custom runner instead of Jest's default test runner 134 | // runner: "jest-runner", 135 | 136 | // The paths to modules that run some code to configure or set up the testing environment before each test 137 | // setupFiles: [], 138 | 139 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 140 | // setupFilesAfterEnv: [], 141 | 142 | // The number of seconds after which a test is considered as slow and reported as such in the results. 143 | // slowTestThreshold: 5, 144 | 145 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 146 | // snapshotSerializers: [], 147 | 148 | // The test environment that will be used for testing 149 | // testEnvironment: "jest-environment-node", 150 | 151 | // Options that will be passed to the testEnvironment 152 | // testEnvironmentOptions: {}, 153 | 154 | // Adds a location field to test results 155 | // testLocationInResults: false, 156 | 157 | // The glob patterns Jest uses to detect test files 158 | // testMatch: [ 159 | // "**/__tests__/**/*.[jt]s?(x)", 160 | // "**/?(*.)+(spec|test).[tj]s?(x)" 161 | // ], 162 | 163 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 164 | // testPathIgnorePatterns: [ 165 | // "/node_modules/" 166 | // ], 167 | 168 | // The regexp pattern or array of patterns that Jest uses to detect test files 169 | // testRegex: [], 170 | 171 | // This option allows the use of a custom results processor 172 | // testResultsProcessor: undefined, 173 | 174 | // This option allows use of a custom test runner 175 | // testRunner: "jest-circus/runner", 176 | 177 | // A map from regular expressions to paths to transformers 178 | // transform: undefined, 179 | 180 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 181 | // transformIgnorePatterns: [ 182 | // "/node_modules/", 183 | // "\\.pnp\\.[^\\/]+$" 184 | // ], 185 | 186 | // transform: { 187 | // "^.+\\.ts?$": "ts-jest", 188 | // }, 189 | // transformIgnorePatterns: ["/node_modules/"], 190 | 191 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 192 | // unmockedModulePathPatterns: undefined, 193 | 194 | // Indicates whether each individual test should be reported during the run 195 | verbose: true, 196 | 197 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 198 | // watchPathIgnorePatterns: [], 199 | 200 | // Whether to use watchman for file crawling 201 | // watchman: true, 202 | } 203 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["**/*.test.ts", "**/*.spec.ts", ".git", "node_modules"], 3 | "watch": ["src"], 4 | "exec": "node -r tsconfig-paths/register -r ts-node/register ./src/index.ts", 5 | "ext": "ts, js" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-scaffold", 3 | "version": "2.3.2", 4 | "description": "Generate any file structure - from single components to entire app boilerplates, with a single command.", 5 | "homepage": "https://chenasraf.github.io/simple-scaffold", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/chenasraf/simple-scaffold.git" 9 | }, 10 | "author": "Chen Asraf (https://casraf.dev)", 11 | "license": "MIT", 12 | "main": "index.js", 13 | "bin": { 14 | "simple-scaffold": "cmd.js" 15 | }, 16 | "packageManager": "pnpm@9.9.0", 17 | "keywords": [ 18 | "javascript", 19 | "cli", 20 | "template", 21 | "files", 22 | "typescript", 23 | "generator", 24 | "scaffold", 25 | "file", 26 | "scaffolding" 27 | ], 28 | "scripts": { 29 | "clean": "rimraf dist", 30 | "build": "pnpm clean && tsc && chmod -R +x ./dist && cp ./package.json ./README.md ./dist/", 31 | "dev": "tsc --watch", 32 | "start": "ts-node src/scaffold.ts", 33 | "test": "jest", 34 | "coverage": "open coverage/lcov-report/index.html", 35 | "cmd": "ts-node src/cmd.ts", 36 | "docs:build": "cd docs && pnpm build", 37 | "docs:watch": "cd docs && pnpm start", 38 | "audit-fix": "pnpm audit --fix", 39 | "ci": "pnpm install --frozen-lockfile" 40 | }, 41 | "dependencies": { 42 | "date-fns": "^4.1.0", 43 | "glob": "^11.0.0", 44 | "handlebars": "^4.7.8", 45 | "massarg": "2.0.1" 46 | }, 47 | "devDependencies": { 48 | "@eslint/js": "^9.13.0", 49 | "@types/jest": "^29.5.14", 50 | "@types/mock-fs": "^4.13.4", 51 | "@types/node": "^22.8.1", 52 | "jest": "^29.7.0", 53 | "mock-fs": "^5.4.0", 54 | "rimraf": "^6.0.1", 55 | "ts-jest": "^29.2.5", 56 | "ts-node": "^10.9.2", 57 | "typescript": "^5.6.3", 58 | "typescript-eslint": "^8.11.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scaffold.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** @type {import('./dist').ScaffoldConfigFile} */ 3 | module.exports = (conf) => { 4 | console.log("Config:", conf) 5 | return { 6 | default: { 7 | templates: ["examples/test-input/Component"], 8 | output: "examples/test-output", 9 | data: { property: "myProp", value: "10" }, 10 | }, 11 | component: { 12 | templates: ["examples/test-input/Component"], 13 | output: "examples/test-output/component", 14 | data: { property: "myProp", value: "10" }, 15 | }, 16 | configs: { 17 | templates: ["examples/test-input/**/.*"], 18 | output: "examples/test-output/configs", 19 | name: "---", 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/cmd.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from "node:path" 4 | import fs from "node:fs/promises" 5 | import { massarg } from "massarg" 6 | import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types" 7 | import { Scaffold } from "./scaffold" 8 | import { getConfigFile, parseAppendData, parseConfigFile } from "./config" 9 | import { log } from "./logger" 10 | import { MassargCommand } from "massarg/command" 11 | import { getUniqueTmpPath as generateUniqueTmpPath } from "./file" 12 | import { colorize } from "./utils" 13 | 14 | export async function parseCliArgs(args = process.argv.slice(2)) { 15 | const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false)) 16 | const pkgFile = await fs.readFile(path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json")) 17 | const pkg = JSON.parse(pkgFile.toString()) 18 | const isVersionFlag = args.includes("--version") || args.includes("-v") 19 | const isConfigFileProvided = args.includes("--config") || args.includes("-c") 20 | const isGitProvided = args.includes("--git") || args.includes("-g") 21 | const isConfigProvided = isConfigFileProvided || isGitProvided || isVersionFlag 22 | 23 | return massarg({ 24 | name: pkg.name, 25 | description: pkg.description, 26 | }) 27 | .main(async (config) => { 28 | if (config.version) { 29 | console.log(pkg.version) 30 | return 31 | } 32 | log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`) 33 | config.tmpDir = generateUniqueTmpPath() 34 | try { 35 | log(config, LogLevel.debug, "Parsing config file...", config) 36 | const parsed = await parseConfigFile(config) 37 | await Scaffold(parsed) 38 | } catch (e) { 39 | const message = "message" in (e as any) ? (e as any).message : e?.toString() 40 | log(config, LogLevel.error, message) 41 | } finally { 42 | log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir) 43 | await fs.rm(config.tmpDir, { recursive: true, force: true }) 44 | } 45 | }) 46 | .option({ 47 | name: "name", 48 | aliases: ["n"], 49 | description: 50 | "Name to be passed to the generated files. `{{name}}` and other data parameters inside " + 51 | "contents and file names will be replaced accordingly. You may omit the `--name` or `-n` " + 52 | "for this specific option.", 53 | isDefault: true, 54 | required: !isConfigProvided, 55 | }) 56 | .option({ 57 | name: "config", 58 | aliases: ["c"], 59 | description: "Filename or directory to load config from", 60 | }) 61 | .option({ 62 | name: "git", 63 | aliases: ["g"], 64 | description: "Git URL or GitHub path to load a template from.", 65 | }) 66 | .option({ 67 | name: "key", 68 | aliases: ["k"], 69 | description: 70 | "Key to load inside the config file. This overwrites the config key provided after the colon in `--config` " + 71 | "(e.g. `--config scaffold.cmd.js:component)`", 72 | }) 73 | .option({ 74 | name: "output", 75 | aliases: ["o"], 76 | description: 77 | "Path to output to. If `--subdir` is enabled, the subdir will be created inside " + 78 | "this path. Default is current working directory.", 79 | required: !isConfigProvided, 80 | }) 81 | .option({ 82 | name: "templates", 83 | aliases: ["t"], 84 | array: true, 85 | description: 86 | "Template files to use as input. You may provide multiple files, each of which can be a relative or " + 87 | "absolute path, " + 88 | "or a glob pattern for multiple file matching easily.", 89 | required: !isConfigProvided, 90 | }) 91 | .flag({ 92 | name: "overwrite", 93 | aliases: ["w"], 94 | defaultValue: false, 95 | description: "Enable to override output files, even if they already exist.", 96 | negatable: true, 97 | }) 98 | .option({ 99 | name: "data", 100 | aliases: ["d"], 101 | description: "Add custom data to the templates. By default, only your app name is included.", 102 | parse: (v) => JSON.parse(v), 103 | }) 104 | .option({ 105 | name: "append-data", 106 | aliases: ["D"], 107 | description: 108 | "Append additional custom data to the templates, which will overwrite `--data`, using an alternate syntax, " + 109 | "which is easier to use with CLI: `-D key1=string -D key2:=raw`", 110 | parse: parseAppendData, 111 | }) 112 | .flag({ 113 | name: "subdir", 114 | aliases: ["s"], 115 | defaultValue: false, 116 | description: "Create a parent directory with the input name (and possibly `--subdir-helper`", 117 | negatable: true, 118 | negationName: "no-subdir", 119 | }) 120 | .option({ 121 | name: "subdir-helper", 122 | aliases: ["H"], 123 | description: "Default helper to apply to subdir name when using `--subdir`.", 124 | }) 125 | .flag({ 126 | name: "quiet", 127 | aliases: ["q"], 128 | defaultValue: false, 129 | description: "Suppress output logs (Same as `--log-level none`)", 130 | }) 131 | .option({ 132 | name: "log-level", 133 | aliases: ["l"], 134 | defaultValue: LogLevel.info, 135 | description: 136 | "Determine amount of logs to display. The values are: " + 137 | `${colorize.bold`\`none | debug | info | warn | error\``}. ` + 138 | "The provided level will display messages of the same level or higher.", 139 | parse: (v) => { 140 | const val = v.toLowerCase() 141 | if (!(val in LogLevel)) { 142 | throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`) 143 | } 144 | return val 145 | }, 146 | }) 147 | .option({ 148 | name: "before-write", 149 | aliases: ["B"], 150 | description: 151 | "Run a script before writing the files. This can be a command or a path to a" + 152 | " file. A temporary file path will be passed to the given command and the command should " + 153 | "return a string for the final output.", 154 | }) 155 | .flag({ 156 | name: "dry-run", 157 | aliases: ["dr"], 158 | defaultValue: false, 159 | description: 160 | "Don't emit files. This is good for testing your scaffolds and making sure they " + 161 | "don't fail, without having to write actual file contents or create directories.", 162 | }) 163 | .flag({ 164 | name: "version", 165 | aliases: ["v"], 166 | description: "Display version.", 167 | }) 168 | .command( 169 | new MassargCommand({ 170 | name: "list", 171 | aliases: ["ls"], 172 | description: "List all available templates for a given config. See `list -h` for more information.", 173 | run: async (_config) => { 174 | const config = { 175 | templates: [], 176 | name: "", 177 | version: false, 178 | output: "", 179 | subdir: false, 180 | overwrite: false, 181 | dryRun: false, 182 | tmpDir: generateUniqueTmpPath(), 183 | ..._config, 184 | config: _config.config ?? (!_config.git ? process.cwd() : undefined), 185 | } 186 | try { 187 | const file = await getConfigFile(config) 188 | console.log(colorize.underline`Available templates:\n`) 189 | console.log(Object.keys(file).join("\n")) 190 | } catch (e) { 191 | const message = "message" in (e as any) ? (e as any).message : e?.toString() 192 | log(config, LogLevel.error, message) 193 | } finally { 194 | log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir) 195 | await fs.rm(config.tmpDir, { recursive: true, force: true }) 196 | } 197 | }, 198 | }) 199 | .option({ 200 | name: "config", 201 | aliases: ["c"], 202 | description: "Filename or directory to load config from. Defaults to current working directory.", 203 | }) 204 | .option({ 205 | name: "git", 206 | aliases: ["g"], 207 | description: "Git URL or GitHub path to load a template from.", 208 | }) 209 | .option({ 210 | name: "log-level", 211 | aliases: ["l"], 212 | defaultValue: LogLevel.none, 213 | description: 214 | "Determine amount of logs to display. The values are: " + 215 | `${colorize.bold`\`none | debug | info | warn | error\``}. ` + 216 | "The provided level will display messages of the same level or higher.", 217 | parse: (v) => { 218 | const val = v.toLowerCase() 219 | if (!(val in LogLevel)) { 220 | throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`) 221 | } 222 | return val 223 | }, 224 | }) 225 | .help({ 226 | bindOption: true, 227 | }), 228 | ) 229 | .example({ 230 | description: "Usage with config file", 231 | input: "simple-scaffold -c scaffold.cmd.js --key component", 232 | }) 233 | .example({ 234 | description: "Usage with GitHub config file", 235 | input: "simple-scaffold -g chenasraf/simple-scaffold --key component", 236 | }) 237 | .example({ 238 | description: "Usage with https git URL (for non-GitHub)", 239 | input: "simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component", 240 | }) 241 | .example({ 242 | description: "Excluded template key, assumes 'default' key", 243 | input: "simple-scaffold -c scaffold.cmd.js MyComponent", 244 | }) 245 | .example({ 246 | description: 247 | "Shortest syntax for GitHub, searches for config file automaticlly, assumes and template key 'default'", 248 | input: "simple-scaffold -g chenasraf/simple-scaffold MyComponent", 249 | }) 250 | .help({ 251 | bindOption: true, 252 | lineLength: 100, 253 | useGlobalTableColumns: true, 254 | usageText: [colorize.yellow`simple-scaffold`, colorize.gray`[options]`, colorize.cyan``].join(" "), 255 | optionOptions: { 256 | displayNegations: true, 257 | }, 258 | footerText: [ 259 | `Version: ${pkg.version}`, 260 | `Copyright © Chen Asraf 2017-${new Date().getFullYear()}`, 261 | ``, 262 | `Documentation: ${colorize.underline`https://chenasraf.github.io/simple-scaffold`}`, 263 | `NPM: ${colorize.underline`https://npmjs.com/package/simple-scaffold`}`, 264 | `GitHub: ${colorize.underline`https://github.com/chenasraf/simple-scaffold`}`, 265 | ].join("\n"), 266 | }) 267 | .parse(args) 268 | } 269 | 270 | parseCliArgs() 271 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import fs from "node:fs/promises" 3 | import { 4 | ConfigLoadConfig, 5 | FileResponse, 6 | FileResponseHandler, 7 | LogConfig, 8 | LogLevel, 9 | RemoteConfigLoadConfig, 10 | ScaffoldCmdConfig, 11 | ScaffoldConfig, 12 | ScaffoldConfigFile, 13 | ScaffoldConfigMap, 14 | } from "./types" 15 | import { handlebarsParse } from "./parser" 16 | import { log } from "./logger" 17 | import { resolve, wrapNoopResolver } from "./utils" 18 | import { getGitConfig } from "./git" 19 | import { createDirIfNotExists, getUniqueTmpPath, isDir, pathExists } from "./file" 20 | import { exec } from "node:child_process" 21 | 22 | /** @internal */ 23 | export function getOptionValueForFile( 24 | config: ScaffoldConfig, 25 | filePath: string, 26 | fn: FileResponse, 27 | defaultValue?: T, 28 | ): T { 29 | if (typeof fn !== "function") { 30 | return defaultValue ?? (fn as T) 31 | } 32 | return (fn as FileResponseHandler)( 33 | filePath, 34 | path.dirname(handlebarsParse(config, filePath, { asPath: true }).toString()), 35 | path.basename(handlebarsParse(config, filePath, { asPath: true }).toString()), 36 | ) 37 | } 38 | 39 | /** @internal */ 40 | export function parseAppendData(value: string, options: ScaffoldCmdConfig): unknown { 41 | const data = options.data ?? {} 42 | const [key, val] = value.split(/:?=/) 43 | // raw 44 | if (value.includes(":=") && !val.includes(":=")) { 45 | return { ...data, [key]: JSON.parse(val) } 46 | } 47 | return { ...data, [key]: isWrappedWithQuotes(val) ? val.substring(1, val.length - 1) : val } 48 | } 49 | 50 | function isWrappedWithQuotes(string: string): boolean { 51 | return (string.startsWith('"') && string.endsWith('"')) || (string.startsWith("'") && string.endsWith("'")) 52 | } 53 | 54 | /** @internal */ 55 | export async function getConfigFile(config: ScaffoldCmdConfig): Promise { 56 | if (config.git && !config.git.includes("://")) { 57 | log(config, LogLevel.info, `Loading config from GitHub ${config.git}`) 58 | config.git = githubPartToUrl(config.git) 59 | } 60 | 61 | const isGit = Boolean(config.git) 62 | const configFilename = config.config 63 | const configPath = isGit ? config.git : configFilename 64 | 65 | log(config, LogLevel.info, `Loading config from file ${configFilename}`) 66 | 67 | const configPromise = await (isGit 68 | ? getRemoteConfig({ git: configPath, config: configFilename, logLevel: config.logLevel, tmpDir: config.tmpDir! }) 69 | : getLocalConfig({ config: configFilename, logLevel: config.logLevel })) 70 | 71 | // resolve the config 72 | let configImport = await resolve(configPromise, config) 73 | 74 | // If the config is a function or promise, return the output 75 | if (typeof configImport.default === "function" || configImport.default instanceof Promise) { 76 | log(config, LogLevel.debug, "Config is a function or promise, resolving...") 77 | configImport = await resolve(configImport.default, config) 78 | } 79 | return configImport 80 | } 81 | 82 | /** @internal */ 83 | export async function parseConfigFile(config: ScaffoldCmdConfig): Promise { 84 | let output: ScaffoldConfig = { 85 | name: config.name, 86 | templates: config.templates ?? [], 87 | output: config.output, 88 | logLevel: config.logLevel, 89 | dryRun: config.dryRun, 90 | data: config.data, 91 | subdir: config.subdir, 92 | overwrite: config.overwrite, 93 | subdirHelper: config.subdirHelper, 94 | beforeWrite: undefined, 95 | tmpDir: config.tmpDir!, 96 | } 97 | 98 | if (config.quiet) { 99 | config.logLevel = LogLevel.none 100 | } 101 | 102 | const shouldLoadConfig = Boolean(config.config || config.git) 103 | 104 | if (shouldLoadConfig) { 105 | const key = config.key ?? "default" 106 | const configImport = await getConfigFile(config) 107 | 108 | if (!configImport[key]) { 109 | throw new Error(`Template "${key}" not found in ${config.config}`) 110 | } 111 | 112 | const imported = configImport[key] 113 | log(config, LogLevel.debug, "Imported result", imported) 114 | output = { 115 | ...output, 116 | ...imported, 117 | beforeWrite: undefined, 118 | data: { 119 | ...imported.data, 120 | ...config.data, 121 | }, 122 | } 123 | } 124 | 125 | output.data = { ...output.data, ...config.appendData } 126 | const cmdBeforeWrite = config.beforeWrite ? wrapBeforeWrite(config, config.beforeWrite) : undefined 127 | output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite 128 | 129 | if (!output.name) { 130 | throw new Error("simple-scaffold: Missing required option: name") 131 | } 132 | 133 | log(output, LogLevel.debug, "Parsed config", output) 134 | return output 135 | } 136 | 137 | /** @internal */ 138 | export function githubPartToUrl(part: string): string { 139 | const gitUrl = new URL(`https://github.com/${part}`) 140 | if (!gitUrl.pathname.endsWith(".git")) { 141 | gitUrl.pathname += ".git" 142 | } 143 | return gitUrl.toString() 144 | } 145 | 146 | /** @internal */ 147 | export async function getLocalConfig(config: ConfigLoadConfig & Partial): Promise { 148 | const { config: configFile, ...logConfig } = config as Required 149 | 150 | const absolutePath = path.resolve(process.cwd(), configFile) 151 | 152 | const _isDir = await isDir(absolutePath) 153 | 154 | if (_isDir) { 155 | log(logConfig, LogLevel.debug, `Resolving config file from directory ${absolutePath}`) 156 | const file = await findConfigFile(absolutePath) 157 | const exists = await pathExists(file) 158 | if (!exists) { 159 | throw new Error(`Could not find config file in directory ${absolutePath}`) 160 | } 161 | log(logConfig, LogLevel.info, `Loading config from: ${path.resolve(absolutePath, file)}`) 162 | return wrapNoopResolver(import(path.resolve(absolutePath, file))) 163 | } 164 | 165 | log(logConfig, LogLevel.info, `Loading config from: ${absolutePath}`) 166 | return wrapNoopResolver(import(absolutePath)) 167 | } 168 | 169 | /** @internal */ 170 | export async function getRemoteConfig( 171 | config: RemoteConfigLoadConfig & Partial, 172 | ): Promise { 173 | const { config: configFile, git, tmpDir, ...logConfig } = config as Required 174 | 175 | log(logConfig, LogLevel.info, `Loading config from remote ${git}, file ${configFile}`) 176 | 177 | const url = new URL(git!) 178 | const isHttp = url.protocol === "http:" || url.protocol === "https:" 179 | const isGit = url.protocol === "git:" || (isHttp && url.pathname.endsWith(".git")) 180 | 181 | if (!isGit) { 182 | throw new Error(`Unsupported protocol ${url.protocol}`) 183 | } 184 | 185 | return getGitConfig(url, configFile, tmpDir, logConfig) 186 | } 187 | 188 | /** @internal */ 189 | export async function findConfigFile(root: string): Promise { 190 | const allowed = ["mjs", "cjs", "js", "json"].reduce((acc, ext) => { 191 | acc.push(`scaffold.config.${ext}`) 192 | acc.push(`scaffold.${ext}`) 193 | return acc 194 | }, [] as string[]) 195 | for (const file of allowed) { 196 | const exists = await pathExists(path.resolve(root, file)) 197 | if (exists) { 198 | return file 199 | } 200 | } 201 | throw new Error(`Could not find config file in git repo`) 202 | } 203 | 204 | function wrapBeforeWrite( 205 | config: LogConfig & Pick, 206 | beforeWrite: string, 207 | ): ScaffoldConfig["beforeWrite"] { 208 | return async (content, rawContent, outputFile) => { 209 | const tmpDir = path.join(getUniqueTmpPath(), path.basename(outputFile)) 210 | await createDirIfNotExists(path.dirname(tmpDir), config) 211 | const ext = path.extname(outputFile) 212 | const rawTmpPath = tmpDir.replace(ext, ".raw" + ext) 213 | try { 214 | log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite) 215 | const cmd = await prepareBeforeWriteCmd({ beforeWrite, tmpDir, content, rawTmpPath, rawContent }) 216 | const result = await new Promise((resolve, reject) => { 217 | log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd) 218 | const proc = exec(cmd) 219 | proc.stdout!.on("data", (data) => { 220 | if (data.trim()) { 221 | resolve(data.toString()) 222 | } else { 223 | resolve(undefined) 224 | } 225 | }) 226 | proc.stderr!.on("data", (data) => { 227 | reject(data.toString()) 228 | }) 229 | }) 230 | return result 231 | } catch (e) { 232 | log(config, LogLevel.debug, e) 233 | log(config, LogLevel.warning, "Error running beforeWrite command, returning original content") 234 | return undefined 235 | } finally { 236 | await fs.rm(tmpDir, { force: true }) 237 | await fs.rm(rawTmpPath, { force: true }) 238 | } 239 | } 240 | } 241 | 242 | async function prepareBeforeWriteCmd({ 243 | beforeWrite, 244 | tmpDir, 245 | content, 246 | rawTmpPath, 247 | rawContent, 248 | }: { 249 | beforeWrite: string 250 | tmpDir: string 251 | content: Buffer 252 | rawTmpPath: string 253 | rawContent: Buffer 254 | }): Promise { 255 | let cmd: string = "" 256 | const pathReg = /\{\{\s*path\s*\}\}/gi 257 | const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi 258 | if (pathReg.test(beforeWrite)) { 259 | await fs.writeFile(tmpDir, content) 260 | cmd = beforeWrite.replaceAll(pathReg, tmpDir) 261 | } 262 | if (rawPathReg.test(beforeWrite)) { 263 | await fs.writeFile(rawTmpPath, rawContent) 264 | cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath) 265 | } 266 | if (!cmd) { 267 | await fs.writeFile(tmpDir, content) 268 | cmd = [beforeWrite, tmpDir].join(" ") 269 | } 270 | return cmd 271 | } 272 | -------------------------------------------------------------------------------- /src/docs.css: -------------------------------------------------------------------------------- 1 | .tsd-typography table { 2 | border-collapse: collapse; 3 | width: 100%; 4 | } 5 | 6 | .tsd-typography table td, 7 | .tsd-typography table th { 8 | vertical-align: top; 9 | border: 1px solid var(--color-accent); 10 | padding: 6px; 11 | } 12 | .tsd-typography h1 + pre, 13 | .tsd-typography h2 + pre, 14 | .tsd-typography h3 + pre, 15 | .tsd-typography h4 + pre, 16 | .tsd-typography h5 + pre, 17 | .tsd-typography h6 + pre, 18 | /* */ 19 | .tsd-typography h1 + table, 20 | .tsd-typography h2 + table, 21 | .tsd-typography h3 + table, 22 | .tsd-typography h4 + table, 23 | .tsd-typography h5 + table, 24 | .tsd-typography h6 + table { 25 | margin-top: 1em; 26 | } 27 | 28 | .tsd-typography pre + a + h1, 29 | .tsd-typography pre + a + h2, 30 | .tsd-typography pre + a + h3, 31 | .tsd-typography pre + a + h4, 32 | .tsd-typography pre + a + h5, 33 | .tsd-typography pre + a + h6, 34 | /* */ 35 | .tsd-typography table + a + h1, 36 | .tsd-typography table + a + h2, 37 | .tsd-typography table + a + h3, 38 | .tsd-typography table + a + h4, 39 | .tsd-typography table + a + h5, 40 | .tsd-typography table + a + h6 { 41 | margin-top: 2em; 42 | } 43 | 44 | .tsd-index-accordion[data-key*="Configuration."] ul.tsd-nested-navigation, 45 | .tsd-index-accordion[data-key*="Configuration."] .tsd-accordion-summary > svg, 46 | .tsd-index-accordion[data-key="Changelog"] ul.tsd-nested-navigation, 47 | .tsd-index-accordion[data-key="Changelog"] .tsd-accordion-summary > svg, 48 | .tsd-index-accordion[data-key="Configuration"] li:nth-child(n + 6) { 49 | display: none; 50 | } 51 | 52 | .tsd-index-accordion[data-key*="Configuration."], 53 | .tsd-index-accordion[data-key="Changelog"] { 54 | margin-left: 0; 55 | } 56 | -------------------------------------------------------------------------------- /src/file.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os" 2 | import path from "node:path" 3 | import fs from "node:fs/promises" 4 | import { F_OK } from "node:constants" 5 | import { LogConfig, LogLevel, ScaffoldConfig } from "./types" 6 | import { glob, hasMagic } from "glob" 7 | import { log } from "./logger" 8 | import { getOptionValueForFile } from "./config" 9 | import { handlebarsParse } from "./parser" 10 | import { handleErr } from "./utils" 11 | 12 | const { stat, access, mkdir, readFile, writeFile } = fs 13 | 14 | export async function createDirIfNotExists( 15 | dir: string, 16 | config: LogConfig & Pick, 17 | ): Promise { 18 | if (config.dryRun) { 19 | log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`) 20 | return 21 | } 22 | const parentDir = path.dirname(dir) 23 | 24 | if (!(await pathExists(parentDir))) { 25 | await createDirIfNotExists(parentDir, config) 26 | } 27 | 28 | if (!(await pathExists(dir))) { 29 | try { 30 | log(config, LogLevel.debug, `Creating dir ${dir}`) 31 | await mkdir(dir) 32 | return 33 | } catch (e: any) { 34 | if (e.code !== "EEXIST") { 35 | throw e 36 | } 37 | return 38 | } 39 | } 40 | } 41 | 42 | export async function pathExists(filePath: string): Promise { 43 | try { 44 | await access(filePath, F_OK) 45 | return true 46 | } catch (e: any) { 47 | if (e.code === "ENOENT") { 48 | return false 49 | } 50 | throw e 51 | } 52 | } 53 | 54 | export async function isDir(path: string): Promise { 55 | const tplStat = await stat(path) 56 | return tplStat.isDirectory() 57 | } 58 | 59 | export function removeGlob(template: string): string { 60 | return path.normalize(template.replace(/\*/g, "")) 61 | } 62 | 63 | export function makeRelativePath(str: string): string { 64 | return str.startsWith(path.sep) ? str.slice(1) : str 65 | } 66 | 67 | export function getBasePath(relPath: string): string { 68 | return path 69 | .resolve(process.cwd(), relPath) 70 | .replace(process.cwd() + path.sep, "") 71 | .replace(process.cwd(), "") 72 | } 73 | 74 | export async function getFileList(config: ScaffoldConfig, templates: string[]): Promise { 75 | log(config, LogLevel.debug, `Getting file list for glob list: ${templates}`) 76 | return ( 77 | await glob(templates, { 78 | dot: true, 79 | nodir: true, 80 | }) 81 | ).map(path.normalize) 82 | } 83 | 84 | export interface GlobInfo { 85 | nonGlobTemplate: string 86 | origTemplate: string 87 | isDirOrGlob: boolean 88 | isGlob: boolean 89 | template: string 90 | } 91 | 92 | export async function getTemplateGlobInfo(config: ScaffoldConfig, template: string): Promise { 93 | const isGlob = hasMagic(template) 94 | log(config, LogLevel.debug, "before isDir", "isGlob:", isGlob, template) 95 | let _template = template 96 | let nonGlobTemplate = isGlob ? removeGlob(template) : template 97 | nonGlobTemplate = path.normalize(nonGlobTemplate) 98 | const isDirOrGlob = isGlob ? true : await isDir(template) 99 | const _shouldAddGlob = !isGlob && isDirOrGlob 100 | log(config, LogLevel.debug, "after", { isDirOrGlob, _shouldAddGlob }) 101 | const origTemplate = template 102 | if (_shouldAddGlob) { 103 | _template = path.join(template, "**", "*") 104 | } 105 | return { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template: _template } 106 | } 107 | 108 | export interface OutputFileInfo { 109 | inputPath: string 110 | outputPathOpt: string 111 | outputDir: string 112 | outputPath: string 113 | exists: boolean 114 | } 115 | 116 | export async function getTemplateFileInfo( 117 | config: ScaffoldConfig, 118 | { templatePath, basePath }: { templatePath: string; basePath: string }, 119 | ): Promise { 120 | const inputPath = path.resolve(process.cwd(), templatePath) 121 | const outputPathOpt = getOptionValueForFile(config, inputPath, config.output) 122 | const outputDir = getOutputDir(config, outputPathOpt, basePath.replace(config.tmpDir!, "./")) 123 | const rawOutputPath = path.join(outputDir, path.basename(inputPath)) 124 | const outputPath = handlebarsParse(config, rawOutputPath, { asPath: true }).toString() 125 | const exists = await pathExists(outputPath) 126 | return { inputPath, outputPathOpt, outputDir, outputPath, exists } 127 | } 128 | 129 | export async function copyFileTransformed( 130 | config: ScaffoldConfig, 131 | { 132 | exists, 133 | overwrite, 134 | outputPath, 135 | inputPath, 136 | }: { 137 | exists: boolean 138 | overwrite: boolean 139 | outputPath: string 140 | inputPath: string 141 | }, 142 | ): Promise { 143 | if (!exists || overwrite) { 144 | if (exists && overwrite) { 145 | log(config, LogLevel.info, `File ${outputPath} exists, overwriting`) 146 | } 147 | log(config, LogLevel.debug, `Processing file ${inputPath}`) 148 | const templateBuffer = await readFile(inputPath) 149 | const unprocessedOutputContents = handlebarsParse(config, templateBuffer) 150 | const finalOutputContents = 151 | (await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ?? unprocessedOutputContents 152 | 153 | if (!config.dryRun) { 154 | await writeFile(outputPath, finalOutputContents) 155 | } else { 156 | log(config, LogLevel.info, "Dry Run. Output should be:") 157 | log(config, LogLevel.info, finalOutputContents.toString()) 158 | } 159 | } else if (exists) { 160 | log(config, LogLevel.info, `File ${outputPath} already exists, skipping`) 161 | } 162 | log(config, LogLevel.info, "Done.") 163 | } 164 | 165 | export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string { 166 | return path.resolve( 167 | process.cwd(), 168 | ...([ 169 | outputPathOpt, 170 | basePath, 171 | config.subdir 172 | ? config.subdirHelper 173 | ? handlebarsParse(config, `{{ ${config.subdirHelper} name }}`).toString() 174 | : config.name 175 | : undefined, 176 | ].filter(Boolean) as string[]), 177 | ) 178 | } 179 | 180 | export async function handleTemplateFile( 181 | config: ScaffoldConfig, 182 | { templatePath, basePath }: { templatePath: string; basePath: string }, 183 | ): Promise { 184 | return new Promise(async (resolve, reject) => { 185 | try { 186 | const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(config, { 187 | templatePath, 188 | basePath, 189 | }) 190 | const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false) 191 | 192 | log( 193 | config, 194 | LogLevel.debug, 195 | `\nParsing ${templatePath}`, 196 | `\nBase path: ${basePath}`, 197 | `\nFull input path: ${inputPath}`, 198 | `\nOutput Path Opt: ${outputPathOpt}`, 199 | `\nFull output dir: ${outputDir}`, 200 | `\nFull output path: ${outputPath}`, 201 | `\n`, 202 | ) 203 | 204 | await createDirIfNotExists(path.dirname(outputPath), config) 205 | 206 | log(config, LogLevel.info, `Writing to ${outputPath}`) 207 | await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath }) 208 | resolve() 209 | } catch (e: any) { 210 | handleErr(e) 211 | reject(e) 212 | } 213 | }) 214 | } 215 | 216 | /** @internal */ 217 | export function getUniqueTmpPath(): string { 218 | return path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`) 219 | } 220 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { log } from "./logger" 3 | import { AsyncResolver, LogConfig, LogLevel, ScaffoldCmdConfig, ScaffoldConfigMap } from "./types" 4 | import { spawn } from "node:child_process" 5 | import { resolve, wrapNoopResolver } from "./utils" 6 | import { findConfigFile } from "./config" 7 | 8 | export async function getGitConfig( 9 | url: URL, 10 | file: string, 11 | tmpPath: string, 12 | logConfig: LogConfig, 13 | ): Promise> { 14 | const repoUrl = `${url.protocol}//${url.host}${url.pathname}` 15 | 16 | log(logConfig, LogLevel.info, `Cloning git repo ${repoUrl}`) 17 | 18 | return new Promise((res, reject) => { 19 | log(logConfig, LogLevel.debug, `Cloning git repo to ${tmpPath}`) 20 | const clone = spawn("git", ["clone", "--recurse-submodules", "--depth", "1", repoUrl, tmpPath]) 21 | 22 | clone.on("error", reject) 23 | clone.on("close", async (code) => { 24 | if (code === 0) { 25 | res(await loadGitConfig({ logConfig, url: repoUrl, file, tmpPath })) 26 | return 27 | } 28 | 29 | reject(new Error(`Git clone failed with code ${code}`)) 30 | }) 31 | }) 32 | } 33 | 34 | /** @internal */ 35 | export async function loadGitConfig({ 36 | logConfig, 37 | url: repoUrl, 38 | file, 39 | tmpPath, 40 | }: { 41 | logConfig: LogConfig 42 | url: string 43 | file: string 44 | tmpPath: string 45 | }): Promise> { 46 | log(logConfig, LogLevel.info, `Loading config from git repo: ${repoUrl}`) 47 | const filename = file || (await findConfigFile(tmpPath)) 48 | const absolutePath = path.resolve(tmpPath, filename) 49 | log(logConfig, LogLevel.debug, `Resolving config file: ${absolutePath}`) 50 | const loadedConfig = await resolve(async () => (await import(absolutePath)).default as ScaffoldConfigMap, logConfig) 51 | 52 | log(logConfig, LogLevel.info, `Loaded config from git`) 53 | log(logConfig, LogLevel.debug, `Raw config:`, loadedConfig) 54 | const fixedConfig: ScaffoldConfigMap = {} 55 | for (const [k, v] of Object.entries(loadedConfig)) { 56 | fixedConfig[k] = { 57 | ...v, 58 | templates: v.templates.map((t) => path.resolve(tmpPath, t)), 59 | } 60 | } 61 | return wrapNoopResolver(fixedConfig) 62 | } 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./scaffold" 2 | export * from "./types" 3 | import Scaffold from "./scaffold" 4 | 5 | export default Scaffold 6 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import util from "util" 2 | import { LogConfig, LogLevel, ScaffoldConfig } from "./types" 3 | import { colorize, TermColor } from "./utils" 4 | 5 | export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void { 6 | const priority: Record = { 7 | [LogLevel.none]: 0, 8 | [LogLevel.debug]: 1, 9 | [LogLevel.info]: 2, 10 | [LogLevel.warning]: 3, 11 | [LogLevel.error]: 4, 12 | } 13 | 14 | if (config.logLevel === LogLevel.none || priority[level] < priority[config.logLevel ?? LogLevel.info]) { 15 | return 16 | } 17 | 18 | const levelColor: Record = { 19 | [LogLevel.none]: "reset", 20 | [LogLevel.debug]: "blue", 21 | [LogLevel.info]: "dim", 22 | [LogLevel.warning]: "yellow", 23 | [LogLevel.error]: "red", 24 | } 25 | 26 | const colorFn = colorize[levelColor[level]] 27 | const key: "log" | "warn" | "error" = level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log" 28 | const logFn: any = console[key] 29 | logFn( 30 | ...obj.map((i) => 31 | i instanceof Error 32 | ? colorFn(i, JSON.stringify(i, undefined, 1), i.stack) 33 | : typeof i === "object" 34 | ? util.inspect(i, { depth: null, colors: true }) 35 | : colorFn(i), 36 | ), 37 | ) 38 | } 39 | 40 | export function logInputFile( 41 | config: ScaffoldConfig, 42 | data: { 43 | originalTemplate: string 44 | relativePath: string 45 | parsedTemplate: string 46 | inputFilePath: string 47 | nonGlobTemplate: string 48 | basePath: string 49 | isDirOrGlob: boolean 50 | isGlob: boolean 51 | }, 52 | ): void { 53 | log(config, LogLevel.debug, data) 54 | } 55 | 56 | export function logInitStep(config: ScaffoldConfig): void { 57 | log(config, LogLevel.debug, "Full config:", { 58 | name: config.name, 59 | templates: config.templates, 60 | output: config.output, 61 | subdir: config.subdir, 62 | data: config.data, 63 | overwrite: config.overwrite, 64 | subdirHelper: config.subdirHelper, 65 | helpers: Object.keys(config.helpers ?? {}), 66 | logLevel: config.logLevel, 67 | dryRun: config.dryRun, 68 | beforeWrite: config.beforeWrite, 69 | } as Record) 70 | log(config, LogLevel.info, "Data:", config.data) 71 | } 72 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path" 2 | import { DefaultHelpers, Helper, LogLevel, ScaffoldConfig } from "./types" 3 | import Handlebars from "handlebars" 4 | import dtAdd from "date-fns/add" 5 | import dtFormat from "date-fns/format" 6 | import dtParseISO from "date-fns/parseISO" 7 | import { log } from "./logger" 8 | import { Duration } from "date-fns" 9 | 10 | const dateFns = { 11 | add: dtAdd.add, 12 | format: dtFormat.format, 13 | parseISO: dtParseISO.parseISO, 14 | } 15 | 16 | export const defaultHelpers: Record = { 17 | camelCase, 18 | snakeCase, 19 | startCase, 20 | kebabCase, 21 | hyphenCase: kebabCase, 22 | pascalCase, 23 | lowerCase: (text) => text.toLowerCase(), 24 | upperCase: (text) => text.toUpperCase(), 25 | now: nowHelper, 26 | date: dateHelper, 27 | } 28 | 29 | function _dateHelper(date: Date, formatString: string): string 30 | function _dateHelper(date: Date, formatString: string, durationDifference: number, durationType: keyof Duration): string 31 | function _dateHelper( 32 | date: Date, 33 | formatString: string, 34 | durationDifference?: number, 35 | durationType?: keyof Duration, 36 | ): string { 37 | if (durationType && durationDifference !== undefined) { 38 | return dateFns.format(dateFns.add(date, { [durationType]: durationDifference }), formatString) 39 | } 40 | return dateFns.format(date, formatString) 41 | } 42 | 43 | export function nowHelper(formatString: string): string 44 | export function nowHelper(formatString: string, durationDifference: number, durationType: keyof Duration): string 45 | export function nowHelper(formatString: string, durationDifference?: number, durationType?: keyof Duration): string { 46 | return _dateHelper(new Date(), formatString, durationDifference!, durationType!) 47 | } 48 | 49 | export function dateHelper(date: string, formatString: string): string 50 | export function dateHelper( 51 | date: string, 52 | formatString: string, 53 | durationDifference: number, 54 | durationType: keyof Duration, 55 | ): string 56 | export function dateHelper( 57 | date: string, 58 | formatString: string, 59 | durationDifference?: number, 60 | durationType?: keyof Duration, 61 | ): string { 62 | return _dateHelper(dateFns.parseISO(date), formatString, durationDifference!, durationType!) 63 | } 64 | 65 | // splits by either non-alpha character or capital letter 66 | function toWordParts(string: string): string[] { 67 | return string.split(/(?=[A-Z])|[^a-zA-Z]/).filter((s) => s.length > 0) 68 | } 69 | 70 | function camelCase(s: string): string { 71 | return toWordParts(s).reduce((acc, part, i) => { 72 | if (i === 0) { 73 | return part.toLowerCase() 74 | } 75 | return acc + part[0].toUpperCase() + part.slice(1).toLowerCase() 76 | }, "") 77 | } 78 | 79 | function snakeCase(s: string): string { 80 | return toWordParts(s).join("_").toLowerCase() 81 | } 82 | 83 | function kebabCase(s: string): string { 84 | return toWordParts(s).join("-").toLowerCase() 85 | } 86 | 87 | function startCase(s: string): string { 88 | return toWordParts(s) 89 | .map((part) => part[0].toUpperCase() + part.slice(1).toLowerCase()) 90 | .join(" ") 91 | } 92 | 93 | function pascalCase(s: string): string { 94 | return startCase(s).replace(/\s+/g, "") 95 | } 96 | 97 | export function registerHelpers(config: ScaffoldConfig): void { 98 | const _helpers = { ...defaultHelpers, ...config.helpers } 99 | for (const helperName in _helpers) { 100 | log(config, LogLevel.debug, `Registering helper: ${helperName}`) 101 | Handlebars.registerHelper(helperName, _helpers[helperName as keyof typeof _helpers]) 102 | } 103 | } 104 | 105 | export function handlebarsParse( 106 | config: ScaffoldConfig, 107 | templateBuffer: Buffer | string, 108 | { asPath = false }: { asPath?: boolean } = {}, 109 | ): Buffer { 110 | const { data } = config 111 | try { 112 | let str = templateBuffer.toString() 113 | if (asPath) { 114 | str = str.replace(/\\/g, "/") 115 | } 116 | const parser = Handlebars.compile(str, { noEscape: true }) 117 | let outputContents = parser(data) 118 | if (asPath && path.sep !== "/") { 119 | outputContents = outputContents.replace(/\//g, "\\") 120 | } 121 | return Buffer.from(outputContents) 122 | } catch (e) { 123 | log(config, LogLevel.debug, e) 124 | log(config, LogLevel.info, "Couldn't parse file with handlebars, returning original content") 125 | return Buffer.from(templateBuffer) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/scaffold.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module 3 | * Simple Scaffold 4 | * 5 | * See [readme](README.md) 6 | */ 7 | import path from "node:path" 8 | import os from "node:os" 9 | 10 | import { handleErr, resolve } from "./utils" 11 | import { 12 | isDir, 13 | removeGlob, 14 | makeRelativePath, 15 | getTemplateGlobInfo, 16 | getFileList, 17 | getBasePath, 18 | handleTemplateFile, 19 | GlobInfo, 20 | } from "./file" 21 | import { LogLevel, MinimalConfig, Resolver, ScaffoldCmdConfig, ScaffoldConfig } from "./types" 22 | import { registerHelpers } from "./parser" 23 | import { log, logInitStep, logInputFile } from "./logger" 24 | import { parseConfigFile } from "./config" 25 | 26 | /** 27 | * Create a scaffold using given `options`. 28 | * 29 | * #### Create files 30 | * To create a file structure to output, use any directory and file structure you would like. 31 | * Inside folder names, file names or file contents, you may place `{{ var }}` where `var` is either 32 | * `name` which is the scaffold name you provided or one of the keys you provided in the `data` option. 33 | * 34 | * The contents and names will be replaced with the transformed values so you can use your original structure as a 35 | * boilerplate for other projects, components, modules, or even single files. 36 | * 37 | * The files will maintain their structure, starting from the directory containing the template (or the template itself 38 | * if it is already a directory), and will output from that directory into the directory defined by `config.output`. 39 | * 40 | * #### Helpers 41 | * Helpers are functions you can use to transform your `{{ var }}` contents into other values without having to 42 | * pre-define the data and use a duplicated key. 43 | * 44 | * Any functions you provide in `helpers` option will also be available to you to make custom formatting as you see fit 45 | * (for example, formatting a date) 46 | * 47 | * For available default values, see {@link DefaultHelpers}. 48 | * 49 | * @param {ScaffoldConfig} config The main configuration object 50 | * @return {Promise} A promise that resolves when the scaffold is complete 51 | * 52 | * @see {@link DefaultHelpers} 53 | * @see {@link CaseHelpers} 54 | * @see {@link DateHelpers} 55 | * 56 | * @category Main 57 | */ 58 | export async function Scaffold(config: ScaffoldConfig): Promise { 59 | config.output ??= process.cwd() 60 | 61 | registerHelpers(config) 62 | try { 63 | config.data = { name: config.name, ...config.data } 64 | logInitStep(config) 65 | const excludes = config.templates.filter((t) => t.startsWith("!")) 66 | const includes = config.templates.filter((t) => !t.startsWith("!")) 67 | const templates: GlobInfo[] = [] 68 | for (let _template of includes) { 69 | try { 70 | const { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template } = await getTemplateGlobInfo( 71 | config, 72 | _template, 73 | ) 74 | templates.push({ nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template }) 75 | } catch (e: any) { 76 | handleErr(e) 77 | } 78 | } 79 | for (const tpl of templates) { 80 | const files = await getFileList(config, [tpl.template, ...excludes]) 81 | for (const file of files) { 82 | if (await isDir(file)) { 83 | continue 84 | } 85 | log(config, LogLevel.debug, "Iterating files", { files, file }) 86 | const relPath = makeRelativePath(path.dirname(removeGlob(file).replace(tpl.nonGlobTemplate, ""))) 87 | const basePath = getBasePath(relPath) 88 | logInputFile(config, { 89 | originalTemplate: tpl.origTemplate, 90 | relativePath: relPath, 91 | parsedTemplate: tpl.template, 92 | inputFilePath: file, 93 | nonGlobTemplate: tpl.nonGlobTemplate, 94 | basePath, 95 | isDirOrGlob: tpl.isDirOrGlob, 96 | isGlob: tpl.isGlob, 97 | }) 98 | await handleTemplateFile(config, { 99 | templatePath: file, 100 | basePath, 101 | }) 102 | } 103 | } 104 | } catch (e: any) { 105 | log(config, LogLevel.error, e) 106 | throw e 107 | } 108 | } 109 | 110 | /** 111 | * Create a scaffold based on a config file or URL. 112 | * 113 | * @param {string} pathOrUrl The path or URL to the config file 114 | * @param {Record} config Information needed before loading the config 115 | * @param {Partial>} overrides Any overrides to the loaded config 116 | * 117 | * @see {@link Scaffold} 118 | * @category Main 119 | * @return {Promise} A promise that resolves when the scaffold is complete 120 | */ 121 | Scaffold.fromConfig = async function ( 122 | /** The path or URL to the config file */ 123 | pathOrUrl: string, 124 | /** Information needed before loading the config */ 125 | config: MinimalConfig, 126 | /** Any overrides to the loaded config */ 127 | overrides?: Resolver>>, 128 | ): Promise { 129 | const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`) 130 | const _cmdConfig: ScaffoldCmdConfig = { 131 | dryRun: false, 132 | output: process.cwd(), 133 | logLevel: LogLevel.info, 134 | overwrite: false, 135 | templates: [], 136 | subdir: false, 137 | quiet: false, 138 | config: pathOrUrl, 139 | version: false, 140 | tmpDir: tmpPath, 141 | ...config, 142 | } 143 | const _overrides = resolve(overrides, _cmdConfig) 144 | const _config = await parseConfigFile(_cmdConfig) 145 | return Scaffold({ ..._config, ..._overrides }) 146 | } 147 | 148 | export default Scaffold 149 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { HelperDelegate } from "handlebars/runtime" 2 | 3 | /** 4 | * The config object for defining a scaffolding group. 5 | * 6 | * @see {@link https://chenasraf.github.io/simple-scaffold/docs/usage/node| Node.js usage} 7 | * @see {@link https://chenasraf.github.io/simple-scaffold/docs/usage/cli| CLI usage} 8 | * @see {@link DefaultHelpers} 9 | * @see {@link CaseHelpers} 10 | * @see {@link DateHelpers} 11 | * 12 | * @category Config 13 | */ 14 | export interface ScaffoldConfig { 15 | /** 16 | * Name to be passed to the generated files. `{{name}}` and `{{Name}}` inside contents and file names will be replaced 17 | * accordingly. 18 | */ 19 | name: string 20 | 21 | /** 22 | * Template files to use as input. You may provide multiple files, each of which can be a relative or absolute path, 23 | * or a glob pattern for multiple file matching easily. 24 | * 25 | * You may omit files from output by prepending a `!` to their glob pattern. 26 | * 27 | * For example, `["components/**", "!components/README.md"]` will include everything in the directory `components` 28 | * except the `README.md` file inside. 29 | * 30 | * @default Current working directory 31 | */ 32 | templates: string[] 33 | 34 | /** 35 | * Path to output to. If `subdir` is `true`, the subdir will be created inside this path. 36 | * 37 | * May also be a {@link FileResponseHandler} which returns a new output path to override the default one. 38 | * 39 | * @see {@link FileResponse} 40 | * @see {@link FileResponseHandler} 41 | */ 42 | output: FileResponse 43 | 44 | /** 45 | * Whether to create subdir with the input name. 46 | * 47 | * When `true`, you may also use {@link subdirHelper} to determine a pre-process helper on 48 | * the directory name. 49 | * 50 | * @default `false` 51 | */ 52 | subdir?: boolean 53 | 54 | /** 55 | * Add custom data to the templates. By default, only your app name is included as `{{name}}` and `{{Name}}`. 56 | * 57 | * This can be any object that will be usable by Handlebars. 58 | */ 59 | data?: Record 60 | 61 | /** 62 | * Enable to override output files, even if they already exist. 63 | * 64 | * You may supply a function to this option, which can take the arguments `(fullPath, baseDir, baseName)` and returns 65 | * a boolean for each file. 66 | * 67 | * May also be a {@link FileResponseHandler} which returns a boolean value per file. 68 | * 69 | * @see {@link FileResponse} 70 | * @see {@link FileResponseHandler} 71 | * 72 | * @default `false` 73 | */ 74 | overwrite?: FileResponse 75 | 76 | /** 77 | * Determine amount of logs to display. 78 | * 79 | * The values are: `0 (none) | 1 (debug) | 2 (info) | 3 (warn) | 4 (error)`. The provided level will display messages 80 | * of the same level or higher. 81 | * 82 | * @see {@link LogLevel} 83 | * 84 | * @default `2 (info)` 85 | */ 86 | logLevel?: LogLevel 87 | 88 | /** 89 | * Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write 90 | * actual file contents or create directories. 91 | * 92 | * @default `false` 93 | */ 94 | dryRun?: boolean 95 | 96 | /** 97 | * Additional helpers to add to the template parser. Provide an object whose keys are the name of the function to add, 98 | * and the value is the helper function itself. The signature of helpers is as follows: 99 | * ```typescript 100 | * (text: string, ...args: any[]) => string 101 | * ``` 102 | * 103 | * A full example might be: 104 | * 105 | * ```typescript 106 | * Scaffold({ 107 | * //... 108 | * helpers: { 109 | * upperKebabCase: (text) => kebabCase(text).toUpperCase() 110 | * } 111 | * }) 112 | * ``` 113 | * 114 | * Which will allow: 115 | * 116 | * ``` 117 | * {{ upperKebabCase "my value" }} 118 | * ``` 119 | * 120 | * To transform to: 121 | * 122 | * ``` 123 | * MY-VALUE 124 | * ``` 125 | * 126 | * See {@link DefaultHelpers} for a list of all the built-in available helpers. 127 | * 128 | * Simple Scaffold uses Handlebars.js, so all the syntax from there is supported. See 129 | * [their docs](https://handlebarsjs.com/guide/#custom-helpers) for more information. 130 | * 131 | * @see {@link DefaultHelpers} 132 | * @see {@link CaseHelpers} 133 | * @see {@link DateHelpers} 134 | * @see {@link https://chenasraf.github.io/simple-scaffold/docs/usage/templates| Templates} 135 | * */ 136 | helpers?: Record 137 | 138 | /** 139 | * Default transformer to apply to subdir name when using `subdir: true`. Can be one of the default 140 | * capitalization helpers, or a custom one you provide to `helpers`. Defaults to `undefined`, which means no 141 | * transformation is done. 142 | * 143 | * @see {@link subdir} 144 | * @see {@link CaseHelpers} 145 | * @see {@link DefaultHelpers} 146 | */ 147 | subdirHelper?: DefaultHelpers | string 148 | 149 | /** 150 | * This callback runs right before content is being written to the disk. If you supply this function, you may return 151 | * a string that represents the final content of your file, you may process the content as you see fit. For example, 152 | * you may run formatters on a file, fix output in edge-cases not supported by helpers or data, etc. 153 | * 154 | * If the return value of this function is `undefined`, the original content will be used. 155 | * 156 | * @param content The original template after token replacement 157 | * @param rawContent The original template before token replacement 158 | * @param outputPath The final output path of the processed file 159 | * 160 | * @returns {Promise | String | Buffer | undefined} The final output of the file 161 | * contents-only, after further modifications - or `undefined` to use the original content (i.e. `content.toString()`) 162 | */ 163 | beforeWrite?( 164 | content: Buffer, 165 | rawContent: Buffer, 166 | outputPath: string, 167 | ): string | Buffer | undefined | Promise 168 | 169 | /** @internal */ 170 | tmpDir?: string 171 | } 172 | 173 | /** 174 | * The names of the available helper functions that relate to text capitalization. 175 | * 176 | * These are available for `subdirHelper`. 177 | * 178 | * | Helper name | Example code | Example output | 179 | * | ------------ | ----------------------- | -------------- | 180 | * | [None] | `{{ name }}` | my name | 181 | * | `camelCase` | `{{ camelCase name }}` | myName | 182 | * | `snakeCase` | `{{ snakeCase name }}` | my_name | 183 | * | `startCase` | `{{ startCase name }}` | My Name | 184 | * | `kebabCase` | `{{ kebabCase name }}` | my-name | 185 | * | `hyphenCase` | `{{ hyphenCase name }}` | my-name | 186 | * | `pascalCase` | `{{ pascalCase name }}` | MyName | 187 | * | `upperCase` | `{{ upperCase name }}` | MY NAME | 188 | * | `lowerCase` | `{{ lowerCase name }}` | my name | 189 | * 190 | * @see {@link DefaultHelpers} 191 | * @see {@link DateHelpers} 192 | * @see {@link ScaffoldConfig} 193 | * @see {@link ScaffoldConfig.subdirHelper} 194 | * 195 | * @category Helpers 196 | */ 197 | export type CaseHelpers = 198 | | "camelCase" 199 | | "hyphenCase" 200 | | "kebabCase" 201 | | "lowerCase" 202 | | "pascalCase" 203 | | "snakeCase" 204 | | "startCase" 205 | | "upperCase" 206 | 207 | /** 208 | * The names of the available helper functions that relate to dates. 209 | * 210 | * | Helper name | Description | Example code | Example output | 211 | * | -------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ | 212 | * | `now` | Current date with format | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` | 213 | * | `now` (with offset) | Current date with format, and with offset | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` | 214 | * | `date` | Custom date with format | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` | 215 | * | `date` (with offset) | Custom date with format, and with offset | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" -1 "days" }}` | `2041-31-12 15:00` | 216 | * | `date` (with date from `--data`) | Custom date with format, with data from the `data` config option | `{{ date myCustomDate "yyyy-MM-dd HH:mm" }}` | `2042-01-01 12:00` | 217 | * 218 | * Further details: 219 | * 220 | * - We use [`date-fns`](https://date-fns.org/docs/) for parsing/manipulating the dates. If you want 221 | * more information on the date tokens to use, refer to 222 | * [their format documentation](https://date-fns.org/docs/format). 223 | * 224 | * - The date helper format takes the following arguments: 225 | * 226 | * ```typescript 227 | * ( 228 | * date: string, 229 | * format: string, 230 | * offsetAmount?: number, 231 | * offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds" 232 | * ) 233 | * ``` 234 | * 235 | * - **The now helper** (for current time) takes the same arguments, minus the first one (`date`) as it is implicitly 236 | * the current date. 237 | * 238 | * @see {@link DefaultHelpers} 239 | * @see {@link CaseHelpers} 240 | * @see {@link ScaffoldConfig} 241 | * 242 | * @category Helpers 243 | */ 244 | export type DateHelpers = "date" | "now" 245 | 246 | /** 247 | * The names of all the available helper functions in templates. 248 | * Simple-Scaffold provides some built-in text transformation filters usable by Handlebars.js. 249 | * 250 | * For example, you may use `{{ snakeCase name }}` inside a template file or filename, and it will 251 | * replace `My Name` with `my_name` when producing the final value. 252 | * 253 | * @see {@link CaseHelpers} 254 | * @see {@link DateHelpers} 255 | * @see {@link ScaffoldConfig} 256 | * 257 | * @category Helpers 258 | */ 259 | export type DefaultHelpers = CaseHelpers | DateHelpers 260 | 261 | /** 262 | * Helper function, see https://handlebarsjs.com/guide/#custom-helpers 263 | * 264 | * @category Helpers 265 | */ 266 | export type Helper = HelperDelegate 267 | 268 | /** 269 | * The amount of information to log when generating scaffold. 270 | * When not `none`, the selected level will be the lowest level included. 271 | * 272 | * For example, level `info` will include `info`, `warning` and `error`, but not `debug`; and `warning` will only 273 | * show `warning` and `error`, but not `info` or `debug`. 274 | * 275 | * @default `info` 276 | * 277 | * @category Logging (const) 278 | */ 279 | 280 | export const LogLevel = { 281 | /** Silent output */ 282 | none: "none", 283 | /** Debugging information. Very verbose and only recommended for troubleshooting. */ 284 | debug: "debug", 285 | /** 286 | * The regular level of logging. Major actions are logged to show the scaffold progress. 287 | * 288 | * @default 289 | */ 290 | info: "info", 291 | /** Warnings such as when file fails to replace token values properly in template. */ 292 | warning: "warning", 293 | /** Errors, such as missing files, bad replacement token syntax, or un-writable directories. */ 294 | error: "error", 295 | } as const 296 | 297 | /** 298 | * The amount of information to log when generating scaffold. 299 | * When not `none`, the selected level will be the lowest level included. 300 | * 301 | * For example, level `info` will include `info`, `warning` and `error`, but not `debug`; and `warning` will only 302 | * show `warning` and `error`, but not `info` or `debug`. 303 | * 304 | * @default `info` 305 | * 306 | * @category Logging (type) 307 | */ 308 | export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel] 309 | 310 | /** 311 | * A function that takes path information about file, and returns a value of type `T` 312 | * 313 | * @template T The return type for the function 314 | * @param {string} fullPath The full path of the current file 315 | * @param {string} basedir The directory containing the current file 316 | * @param {string} basename The name of the file 317 | * 318 | * @returns {T} A return value 319 | * 320 | * @category Config 321 | */ 322 | export type FileResponseHandler = (fullPath: string, basedir: string, basename: string) => T 323 | 324 | /** 325 | * Represents a response for file path information. 326 | * Can either be: 327 | * 328 | * 1. `T` - static value 329 | * 2. A function with the following signature which returns `T`: 330 | * ```typescript 331 | * (fullPath: string, basedir: string, basename: string) => T 332 | * ``` 333 | * 334 | * @see {@link FileResponseHandler} 335 | * 336 | * @category Config 337 | * */ 338 | export type FileResponse = T | FileResponseHandler 339 | 340 | /** 341 | * The Scaffold config for CLI 342 | * Contains less and more specific options than {@link ScaffoldConfig}. 343 | * 344 | * For more information about each option, see {@link ScaffoldConfig}. 345 | */ 346 | export type ScaffoldCmdConfig = { 347 | /** The name of the scaffold template to use. */ 348 | name: string 349 | /** The templates to use for generation */ 350 | templates: string[] 351 | /** The output path to write to */ 352 | output: string 353 | /** Whether to create subdir with the input name */ 354 | subdir: boolean 355 | /** Default transformer to apply to subdir name when using `subdir: true` */ 356 | subdirHelper?: string 357 | /** Add custom data to the templates */ 358 | data?: Record 359 | /** Add custom data to the template in a CLI-friendly syntax (and not JSON) */ 360 | appendData?: Record 361 | /** Enable to override output files, even if they already exist */ 362 | overwrite: boolean 363 | /** Silence logs, same as `logLevel: "none"` */ 364 | quiet: boolean 365 | /** 366 | * Determine amount of logs to display. 367 | * 368 | * @see {@link LogLevel} 369 | */ 370 | logLevel: LogLevel 371 | /** Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. */ 372 | dryRun: boolean 373 | /** Config file path to use */ 374 | config?: string 375 | /** The key of the template to use */ 376 | key?: string 377 | /** The git repository to use to fetch the config file */ 378 | git?: string 379 | /** Display version */ 380 | version: boolean 381 | /** Run a script before writing the files. This can be a command or a path to a file. The file contents will be passed to the given command. */ 382 | beforeWrite?: string 383 | /** @internal */ 384 | tmpDir?: string 385 | } 386 | 387 | /** 388 | * A mapping of scaffold template keys to their configurations. 389 | * 390 | * Each configuration is a {@link ScaffoldConfig} object. 391 | * 392 | * The key is the name of the template, and the value is the configuration for that template. 393 | * 394 | * When no template key is provided to the scaffold command, the "default" template is used. 395 | * 396 | * @see {@link ScaffoldConfig} 397 | * 398 | * @category Config 399 | */ 400 | export type ScaffoldConfigMap = Record 401 | 402 | /** 403 | * The scaffold config file is either: 404 | * - A {@link ScaffoldConfigMap} object 405 | * - A function that returns a {@link ScaffoldConfigMap} object 406 | * - A promise that resolves to a {@link ScaffoldConfigMap} object 407 | * - A function that returns a promise that resolves to a {@link ScaffoldConfigMap} object 408 | * 409 | * @category Config 410 | */ 411 | export type ScaffoldConfigFile = AsyncResolver 412 | 413 | /** @internal */ 414 | export type Resolver = R | ((value: T) => R) 415 | 416 | /** @internal */ 417 | export type AsyncResolver = Resolver | R> 418 | 419 | /** @internal */ 420 | export type LogConfig = Pick 421 | 422 | /** @internal */ 423 | export type ConfigLoadConfig = LogConfig & Pick 424 | 425 | /** @internal */ 426 | export type RemoteConfigLoadConfig = LogConfig & Pick 427 | 428 | /** @internal */ 429 | export type MinimalConfig = Pick 430 | 431 | export type ListCommandCliOptions = Pick 432 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "./types" 2 | 3 | export function handleErr(err: NodeJS.ErrnoException | null): void { 4 | if (err) throw err 5 | } 6 | 7 | export function resolve(resolver: Resolver, arg: T): R { 8 | return typeof resolver === "function" ? (resolver as (value: T) => R)(arg) : (resolver as R) 9 | } 10 | 11 | export function wrapNoopResolver(value: Resolver): Resolver { 12 | if (typeof value === "function") { 13 | return value 14 | } 15 | 16 | return (_) => value 17 | } 18 | 19 | const colorMap = { 20 | reset: 0, 21 | dim: 2, 22 | bold: 1, 23 | italic: 3, 24 | underline: 4, 25 | red: 31, 26 | green: 32, 27 | yellow: 33, 28 | blue: 34, 29 | magenta: 35, 30 | cyan: 36, 31 | white: 37, 32 | gray: 90, 33 | } as const 34 | 35 | export type TermColor = keyof typeof colorMap 36 | 37 | function _colorize(text: string, color: TermColor): string { 38 | const c = colorMap[color]! 39 | let r = 0 40 | 41 | if (c > 1 && c < 30) { 42 | r = c + 20 43 | } else if (c === 1) { 44 | r = 23 45 | } else { 46 | r = 0 47 | } 48 | 49 | return `\x1b[${c}m${text}\x1b[${r}m` 50 | } 51 | 52 | function isTemplateStringArray(template: TemplateStringsArray | unknown): template is TemplateStringsArray { 53 | return Array.isArray(template) && typeof template[0] === "string" 54 | } 55 | 56 | const createColorize = 57 | (color: TermColor) => 58 | (template: TemplateStringsArray | unknown, ...params: unknown[]): string => { 59 | return isTemplateStringArray(template) 60 | ? _colorize( 61 | (template as TemplateStringsArray).reduce((acc, str, i) => acc + str + (params[i] ?? ""), ""), 62 | color, 63 | ) 64 | : _colorize(String(template), color) 65 | } 66 | 67 | type TemplateStringsFn = ReturnType & ((text: string) => string) 68 | type TemplateStringsFns = { [key in TermColor]: TemplateStringsFn } 69 | 70 | export const colorize: typeof _colorize & TemplateStringsFns = Object.assign( 71 | _colorize, 72 | Object.entries(colorMap).reduce( 73 | (acc, [key]) => { 74 | acc[key as TermColor] = createColorize(key as TermColor) 75 | return acc 76 | }, 77 | {} as Record, 78 | ), 79 | ) 80 | -------------------------------------------------------------------------------- /tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from "mock-fs" 2 | import FileSystem from "mock-fs/lib/filesystem" 3 | import { Console } from "console" 4 | import { LogLevel, ScaffoldCmdConfig } from "../src/types" 5 | import * as config from "../src/config" 6 | import { resolve } from "../src/utils" 7 | // @ts-ignore 8 | import * as configFile from "../scaffold.config" 9 | import { findConfigFile } from "../src/config" 10 | 11 | jest.mock("../src/git", () => { 12 | return { 13 | __esModule: true, 14 | ...jest.requireActual("../src/git"), 15 | getGitConfig: () => { 16 | return Promise.resolve(blankCliConf) 17 | }, 18 | } 19 | }) 20 | 21 | const { githubPartToUrl, parseAppendData, parseConfigFile } = config 22 | 23 | const blankCliConf: ScaffoldCmdConfig = { 24 | logLevel: LogLevel.none, 25 | name: "", 26 | output: "", 27 | templates: [], 28 | data: { name: "test" }, 29 | overwrite: false, 30 | subdir: false, 31 | dryRun: false, 32 | quiet: false, 33 | version: false, 34 | } 35 | 36 | const blankConfig: ScaffoldCmdConfig = { 37 | ...blankCliConf, 38 | data: {}, 39 | } 40 | 41 | describe("config", () => { 42 | describe("parseAppendData", () => { 43 | test('works for "key=value"', () => { 44 | expect(parseAppendData("key=value", blankCliConf)).toEqual({ key: "value", name: "test" }) 45 | }) 46 | 47 | test('works for "key:=value"', () => { 48 | expect(parseAppendData("key:=123", blankCliConf)).toEqual({ key: 123, name: "test" }) 49 | }) 50 | 51 | test("overwrites existing value", () => { 52 | expect(parseAppendData("name:=123", blankCliConf)).toEqual({ name: 123 }) 53 | }) 54 | 55 | test("works with quotes", () => { 56 | expect(parseAppendData('key="value test"', blankCliConf)).toEqual({ key: "value test", name: "test" }) 57 | }) 58 | }) 59 | 60 | describe("githubPartToUrl", () => { 61 | test("works", () => { 62 | expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual("https://github.com/chenasraf/simple-scaffold.git") 63 | expect(githubPartToUrl("chenasraf/simple-scaffold.git")).toEqual( 64 | "https://github.com/chenasraf/simple-scaffold.git", 65 | ) 66 | }) 67 | }) 68 | 69 | describe("parseConfigFile", () => { 70 | test("normal config does not change", async () => { 71 | const tmpDir = `/tmp/scaffold-config-${Date.now()}` 72 | const { quiet, tmpDir: _tmpDir, version, ...conf } = blankCliConf 73 | expect( 74 | await parseConfigFile({ 75 | ...blankCliConf, 76 | name: "-", 77 | tmpDir, 78 | }), 79 | ).toEqual({ ...conf, name: "-", tmpDir, subdirHelper: undefined, beforeWrite: undefined }) 80 | }) 81 | describe("appendData", () => { 82 | test("appends", async () => { 83 | const result = await parseConfigFile({ 84 | ...blankCliConf, 85 | name: "-", 86 | appendData: { key: "value" }, 87 | tmpDir: `/tmp/scaffold-config-${Date.now()}`, 88 | }) 89 | expect(result?.data?.key).toEqual("value") 90 | }) 91 | test("overwrites existing value", async () => { 92 | const result = await parseConfigFile({ 93 | ...blankCliConf, 94 | name: "-", 95 | data: { num: "123" }, 96 | appendData: { num: "1234" }, 97 | tmpDir: `/tmp/scaffold-config-${Date.now()}`, 98 | }) 99 | expect(result?.data?.num).toEqual("1234") 100 | }) 101 | }) 102 | }) 103 | 104 | describe("getConfig", () => { 105 | test("gets git config", async () => { 106 | const resultFn = await config.getRemoteConfig({ 107 | git: "https://github.com/chenasraf/simple-scaffold.git", 108 | logLevel: LogLevel.none, 109 | tmpDir: `/tmp/scaffold-config-${Date.now()}`, 110 | }) 111 | const result = await resolve(resultFn, blankCliConf) 112 | expect(result).toEqual(blankCliConf) 113 | }) 114 | 115 | test("gets local file config", async () => { 116 | const resultFn = await config.getLocalConfig({ 117 | config: "scaffold.config.js", 118 | logLevel: LogLevel.none, 119 | }) 120 | const result = await resolve(resultFn, {} as any) 121 | expect(result).toEqual(configFile) 122 | }) 123 | }) 124 | 125 | describe("findConfigFile", () => { 126 | const struct1 = { 127 | "scaffold.config.js": `module.exports = '${JSON.stringify(blankConfig)}'`, 128 | } 129 | const struct2 = { 130 | "scaffold.js": `module.exports = '${JSON.stringify(blankConfig)}'`, 131 | } 132 | const struct3 = { 133 | "scaffold.cjs": `module.exports = '${JSON.stringify(blankConfig)}'`, 134 | } 135 | const struct4 = { 136 | "scaffold.json": JSON.stringify(blankConfig), 137 | } 138 | 139 | function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction { 140 | return () => { 141 | beforeEach(() => { 142 | // console.log("Mocking:", fileStruct) 143 | console = new Console(process.stdout, process.stderr) 144 | 145 | mockFs(fileStruct) 146 | // logMock = jest.spyOn(console, 'log').mockImplementation((...args) => { 147 | // logsTemp.push(args) 148 | // }) 149 | }) 150 | testFn() 151 | afterEach(() => { 152 | // console.log("Restoring mock") 153 | mockFs.restore() 154 | }) 155 | } 156 | } 157 | 158 | for (const struct of [struct1, struct2, struct3, struct4]) { 159 | const [k] = Object.keys(struct) 160 | describe(`finds config file ${k}`, () => { 161 | withMock(struct, async () => { 162 | const result = await findConfigFile(process.cwd()) 163 | expect(result).toEqual(k) 164 | }) 165 | }) 166 | } 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { ScaffoldConfig } from "../src/types" 2 | import path from "node:path" 3 | import * as dateFns from "date-fns" 4 | import { dateHelper, defaultHelpers, handlebarsParse, nowHelper } from "../src/parser" 5 | 6 | const blankConf: ScaffoldConfig = { 7 | logLevel: "none", 8 | name: "", 9 | output: "", 10 | templates: [], 11 | data: { name: "test" }, 12 | } 13 | 14 | describe("parser", () => { 15 | describe("handlebarsParse", () => { 16 | let origSep: any 17 | describe("windows paths", () => { 18 | beforeAll(() => { 19 | origSep = path.sep 20 | Object.defineProperty(path, "sep", { value: "\\" }) 21 | }) 22 | afterAll(() => { 23 | Object.defineProperty(path, "sep", { value: origSep }) 24 | }) 25 | test("should work for windows paths", async () => { 26 | expect(handlebarsParse(blankConf, "C:\\exports\\{{name}}.txt", { asPath: true }).toString()).toEqual( 27 | "C:\\exports\\test.txt", 28 | ) 29 | }) 30 | }) 31 | describe("non-windows paths", () => { 32 | beforeAll(() => { 33 | origSep = path.sep 34 | Object.defineProperty(path, "sep", { value: "/" }) 35 | }) 36 | afterAll(() => { 37 | Object.defineProperty(path, "sep", { value: origSep }) 38 | }) 39 | test("should work for non-windows paths", async () => { 40 | expect(handlebarsParse(blankConf, "/home/test/{{name}}.txt", { asPath: true })).toEqual( 41 | Buffer.from("/home/test/test.txt"), 42 | ) 43 | }) 44 | }) 45 | test("should not do path escaping on non-path compiles", async () => { 46 | expect( 47 | handlebarsParse( 48 | { ...blankConf, data: { ...blankConf.data, escaped: "value" } }, 49 | "/home/test/{{name}} \\{{escaped}}.txt", 50 | { 51 | asPath: false, 52 | }, 53 | ), 54 | ).toEqual(Buffer.from("/home/test/test {{escaped}}.txt")) 55 | }) 56 | }) 57 | 58 | describe("Helpers", () => { 59 | describe("string helpers", () => { 60 | test("camelCase", () => { 61 | expect(defaultHelpers.camelCase("test string")).toEqual("testString") 62 | expect(defaultHelpers.camelCase("test_string")).toEqual("testString") 63 | expect(defaultHelpers.camelCase("test-string")).toEqual("testString") 64 | expect(defaultHelpers.camelCase("testString")).toEqual("testString") 65 | expect(defaultHelpers.camelCase("TestString")).toEqual("testString") 66 | expect(defaultHelpers.camelCase("Test____String")).toEqual("testString") 67 | }) 68 | test("pascalCase", () => { 69 | expect(defaultHelpers.pascalCase("test string")).toEqual("TestString") 70 | expect(defaultHelpers.pascalCase("test_string")).toEqual("TestString") 71 | expect(defaultHelpers.pascalCase("test-string")).toEqual("TestString") 72 | expect(defaultHelpers.pascalCase("testString")).toEqual("TestString") 73 | expect(defaultHelpers.pascalCase("TestString")).toEqual("TestString") 74 | expect(defaultHelpers.pascalCase("Test____String")).toEqual("TestString") 75 | }) 76 | test("snakeCase", () => { 77 | expect(defaultHelpers.snakeCase("test string")).toEqual("test_string") 78 | expect(defaultHelpers.snakeCase("test_string")).toEqual("test_string") 79 | expect(defaultHelpers.snakeCase("test-string")).toEqual("test_string") 80 | expect(defaultHelpers.snakeCase("testString")).toEqual("test_string") 81 | expect(defaultHelpers.snakeCase("TestString")).toEqual("test_string") 82 | expect(defaultHelpers.snakeCase("Test____String")).toEqual("test_string") 83 | }) 84 | test("kebabCase", () => { 85 | expect(defaultHelpers.kebabCase("test string")).toEqual("test-string") 86 | expect(defaultHelpers.kebabCase("test_string")).toEqual("test-string") 87 | expect(defaultHelpers.kebabCase("test-string")).toEqual("test-string") 88 | expect(defaultHelpers.kebabCase("testString")).toEqual("test-string") 89 | expect(defaultHelpers.kebabCase("TestString")).toEqual("test-string") 90 | expect(defaultHelpers.kebabCase("Test____String")).toEqual("test-string") 91 | }) 92 | test("startCase", () => { 93 | expect(defaultHelpers.startCase("test string")).toEqual("Test String") 94 | expect(defaultHelpers.startCase("test_string")).toEqual("Test String") 95 | expect(defaultHelpers.startCase("test-string")).toEqual("Test String") 96 | expect(defaultHelpers.startCase("testString")).toEqual("Test String") 97 | expect(defaultHelpers.startCase("TestString")).toEqual("Test String") 98 | expect(defaultHelpers.startCase("Test____String")).toEqual("Test String") 99 | }) 100 | }) 101 | describe("date helpers", () => { 102 | describe("now", () => { 103 | test("should work without extra params", () => { 104 | const now = new Date() 105 | const fmt = "yyyy-MM-dd HH:mm" 106 | 107 | expect(nowHelper(fmt)).toEqual(dateFns.format(now, fmt)) 108 | }) 109 | }) 110 | 111 | describe("date", () => { 112 | test("should work with no offset params", () => { 113 | const now = new Date() 114 | const fmt = "yyyy-MM-dd HH:mm" 115 | 116 | expect(dateHelper(now.toISOString(), fmt)).toEqual(dateFns.format(now, fmt)) 117 | }) 118 | 119 | test("should work with offset params", () => { 120 | const now = new Date() 121 | const fmt = "yyyy-MM-dd HH:mm" 122 | 123 | expect(dateHelper(now.toISOString(), fmt, -1, "days")).toEqual( 124 | dateFns.format(dateFns.add(now, { days: -1 }), fmt), 125 | ) 126 | expect(dateHelper(now.toISOString(), fmt, 1, "months")).toEqual( 127 | dateFns.format(dateFns.add(now, { months: 1 }), fmt), 128 | ) 129 | }) 130 | }) 131 | }) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /tests/scaffold.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from "mock-fs" 2 | import FileSystem from "mock-fs/lib/filesystem" 3 | import Scaffold from "../src/scaffold" 4 | import { readdirSync, readFileSync } from "fs" 5 | import { Console } from "console" 6 | import { defaultHelpers } from "../src/parser" 7 | import { join } from "path" 8 | import * as dateFns from "date-fns" 9 | import crypto from "crypto" 10 | 11 | const fileStructNormal = { 12 | input: { 13 | "{{name}}.txt": "Hello, my app is {{name}}", 14 | }, 15 | output: {}, 16 | } 17 | const fileStructWithBinary = { 18 | input: { 19 | "{{name}}.txt": "Hello, my app is {{name}}", 20 | "{{name}}.bin": crypto.randomBytes(10000), 21 | }, 22 | output: {}, 23 | } 24 | 25 | const fileStructWithData = { 26 | input: { 27 | "{{name}}.txt": "Hello, my value is {{value}}", 28 | }, 29 | output: {}, 30 | } 31 | 32 | const fileStructNested = { 33 | input: { 34 | "{{name}}-1.txt": "This should be in root", 35 | "{{pascalCase name}}": { 36 | "{{name}}-2.txt": "Hello, my value is {{value}}", 37 | moreNesting: { 38 | "{{name}}-3.txt": "Hi! My value is actually NOT {{value}}!", 39 | }, 40 | }, 41 | }, 42 | output: {}, 43 | } 44 | const fileStructSubdirTransformer = { 45 | input: { 46 | "{{name}}.txt": "Hello, my app is {{name}}", 47 | }, 48 | output: {}, 49 | } 50 | 51 | const defaultHelperNames = Object.keys(defaultHelpers) 52 | const fileStructHelpers = { 53 | input: { 54 | defaults: defaultHelperNames.reduce>( 55 | (all, cur) => ({ ...all, [cur + ".txt"]: `{{ ${cur} name }}` }), 56 | {}, 57 | ), 58 | custom: { 59 | "add1.txt": "{{ add1 name }}", 60 | }, 61 | }, 62 | output: {}, 63 | } 64 | 65 | const fileStructDates = { 66 | input: { 67 | "now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}", 68 | "offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}", 69 | "custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}", 70 | }, 71 | output: {}, 72 | } 73 | 74 | const fileStructExcludes = { 75 | input: { 76 | "include.txt": "This file should be included", 77 | "exclude.txt": "This file should be excluded", 78 | }, 79 | output: {}, 80 | } 81 | 82 | function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction { 83 | return () => { 84 | beforeEach(() => { 85 | // console.log("Mocking:", fileStruct) 86 | console = new Console(process.stdout, process.stderr) 87 | 88 | mockFs(fileStruct) 89 | // logMock = jest.spyOn(console, 'log').mockImplementation((...args) => { 90 | // logsTemp.push(args) 91 | // }) 92 | }) 93 | testFn() 94 | afterEach(() => { 95 | // console.log("Restoring mock") 96 | mockFs.restore() 97 | }) 98 | } 99 | } 100 | 101 | describe("Scaffold", () => { 102 | describe( 103 | "create subdir", 104 | withMock(fileStructNormal, () => { 105 | test("should not create by default", async () => { 106 | await Scaffold({ 107 | name: "app_name", 108 | output: "output", 109 | templates: ["input"], 110 | logLevel: "none", 111 | }) 112 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 113 | expect(data.toString()).toEqual("Hello, my app is app_name") 114 | }) 115 | 116 | test("should create with config", async () => { 117 | await Scaffold({ 118 | name: "app_name", 119 | output: "output", 120 | templates: ["input"], 121 | subdir: true, 122 | logLevel: "none", 123 | }) 124 | 125 | const data = readFileSync(join(process.cwd(), "output", "app_name", "app_name.txt")) 126 | expect(data.toString()).toEqual("Hello, my app is app_name") 127 | }) 128 | }), 129 | ) 130 | 131 | describe( 132 | "binary files", 133 | withMock(fileStructWithBinary, () => { 134 | test("should copy as-is", async () => { 135 | await Scaffold({ 136 | name: "app_name", 137 | output: "output", 138 | templates: ["input"], 139 | logLevel: "none", 140 | }) 141 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 142 | expect(data.toString()).toEqual("Hello, my app is app_name") 143 | const dataBin = readFileSync(join(process.cwd(), "output", "app_name.bin")) 144 | expect(dataBin).toEqual(fileStructWithBinary.input["{{name}}.bin"]) 145 | }) 146 | }), 147 | ) 148 | 149 | describe( 150 | "overwrite", 151 | withMock(fileStructWithData, () => { 152 | test("should not overwrite by default", async () => { 153 | await Scaffold({ 154 | name: "app_name", 155 | output: "output", 156 | templates: ["input"], 157 | data: { value: "1" }, 158 | logLevel: "none", 159 | }) 160 | 161 | await Scaffold({ 162 | name: "app_name", 163 | output: "output", 164 | templates: ["input"], 165 | data: { value: "2" }, 166 | logLevel: "none", 167 | }) 168 | 169 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 170 | expect(data.toString()).toEqual("Hello, my value is 1") 171 | }) 172 | 173 | test("should overwrite with config", async () => { 174 | await Scaffold({ 175 | name: "app_name", 176 | output: "output", 177 | templates: ["input"], 178 | data: { value: "1" }, 179 | logLevel: "none", 180 | }) 181 | 182 | await Scaffold({ 183 | name: "app_name", 184 | output: "output", 185 | templates: ["input"], 186 | data: { value: "2" }, 187 | overwrite: true, 188 | logLevel: "none", 189 | }) 190 | 191 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 192 | expect(data.toString()).toEqual("Hello, my value is 2") 193 | }) 194 | }), 195 | ) 196 | 197 | describe( 198 | "errors", 199 | withMock(fileStructNormal, () => { 200 | let consoleMock1: jest.SpyInstance 201 | beforeAll(() => { 202 | consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0) 203 | }) 204 | 205 | afterAll(() => { 206 | consoleMock1.mockRestore() 207 | }) 208 | 209 | test("should throw for bad input", async () => { 210 | await expect( 211 | Scaffold({ 212 | name: "app_name", 213 | output: "output", 214 | templates: ["non-existing-input"], 215 | data: { value: "1" }, 216 | logLevel: "none", 217 | }), 218 | ).rejects.toThrow() 219 | 220 | await expect( 221 | Scaffold({ 222 | name: "app_name", 223 | output: "output", 224 | templates: ["non-existing-input/non-existing-file.txt"], 225 | data: { value: "1" }, 226 | logLevel: "none", 227 | }), 228 | ).rejects.toThrow() 229 | 230 | expect(() => readFileSync(join(process.cwd(), "output", "app_name.txt"))).toThrow() 231 | }) 232 | }), 233 | ) 234 | 235 | describe( 236 | "dry run", 237 | withMock(fileStructNormal, () => { 238 | let consoleMock1: jest.SpyInstance 239 | beforeAll(() => { 240 | consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0) 241 | }) 242 | 243 | afterAll(() => { 244 | consoleMock1.mockRestore() 245 | }) 246 | 247 | test("should not write to disk", async () => { 248 | Scaffold({ 249 | name: "app_name", 250 | output: "output", 251 | templates: ["input"], 252 | data: { value: "1" }, 253 | logLevel: "none", 254 | dryRun: true, 255 | }) 256 | 257 | expect(() => readFileSync(join(process.cwd(), "output", "app_name.txt"))).toThrow() 258 | }) 259 | }), 260 | ) 261 | 262 | describe( 263 | "outputPath override", 264 | withMock(fileStructNormal, () => { 265 | test("should allow override function", async () => { 266 | await Scaffold({ 267 | name: "app_name", 268 | output: (_, __, basename) => join("custom-output", `${basename.split(".")[0]}`), 269 | templates: ["input"], 270 | data: { value: "1" }, 271 | logLevel: "none", 272 | }) 273 | const data = readFileSync(join(process.cwd(), "/custom-output/app_name/app_name.txt")) 274 | expect(data.toString()).toEqual("Hello, my app is app_name") 275 | }) 276 | }), 277 | ) 278 | 279 | describe("output structure", () => { 280 | withMock(fileStructNested, () => { 281 | test("should maintain input structure on output", async () => { 282 | await Scaffold({ 283 | name: "app_name", 284 | output: "output", 285 | templates: ["input"], 286 | data: { value: "1" }, 287 | logLevel: "none", 288 | }) 289 | 290 | const rootDir = readdirSync(join(process.cwd(), "output")) 291 | const dir = readdirSync(join(process.cwd(), "output", "AppName")) 292 | const nestedDir = readdirSync(join(process.cwd(), "output", "AppName", "moreNesting")) 293 | expect(rootDir).toHaveProperty("length") 294 | expect(dir).toHaveProperty("length") 295 | expect(nestedDir).toHaveProperty("length") 296 | 297 | const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt")) 298 | const oneDeepFile = readFileSync(join(process.cwd(), "output", "AppName/app_name-2.txt")) 299 | const twoDeepFile = readFileSync(join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt")) 300 | expect(rootFile.toString()).toEqual("This should be in root") 301 | expect(oneDeepFile.toString()).toEqual("Hello, my value is 1") 302 | expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!") 303 | }) 304 | }) 305 | 306 | withMock(fileStructExcludes, () => { 307 | test("should exclude files", async () => { 308 | await Scaffold({ 309 | name: "app_name", 310 | output: "output", 311 | templates: ["input", "!exclude.txt"], 312 | data: { value: "1" }, 313 | logLevel: "none", 314 | }) 315 | const includeFile = readFileSync(join(process.cwd(), "output", "app_name.txt")) 316 | expect(includeFile.toString()).toEqual("This file should be included") 317 | expect(() => readFileSync(join(process.cwd(), "output", "exclude.txt"))).toThrow() 318 | }) 319 | }) 320 | }) 321 | 322 | describe( 323 | "capitalization helpers", 324 | withMock(fileStructHelpers, () => { 325 | const _helpers: Record string> = { 326 | add1: (text) => text + " 1", 327 | } 328 | 329 | test("should work", async () => { 330 | await Scaffold({ 331 | name: "app_name", 332 | output: "output", 333 | templates: ["input"], 334 | logLevel: "none", 335 | helpers: _helpers, 336 | }) 337 | 338 | const results = { 339 | camelCase: "appName", 340 | snakeCase: "app_name", 341 | startCase: "App Name", 342 | kebabCase: "app-name", 343 | hyphenCase: "app-name", 344 | pascalCase: "AppName", 345 | lowerCase: "app_name", 346 | upperCase: "APP_NAME", 347 | } 348 | for (const key in results) { 349 | const file = readFileSync(join(process.cwd(), "output", "defaults", `${key}.txt`)) 350 | expect(file.toString()).toEqual(results[key as keyof typeof results]) 351 | } 352 | }) 353 | }), 354 | ) 355 | describe( 356 | "date helpers", 357 | withMock(fileStructDates, () => { 358 | test("should work", async () => { 359 | const now = new Date() 360 | const yesterday = dateFns.add(new Date(), { days: -1 }) 361 | const customDate = dateFns.formatISO(dateFns.add(new Date(), { days: -1 })) 362 | 363 | await Scaffold({ 364 | name: "app_name", 365 | output: "output", 366 | templates: ["input"], 367 | logLevel: "none", 368 | data: { customDate }, 369 | }) 370 | 371 | const nowFile = readFileSync(join(process.cwd(), "output", "now.txt")) 372 | const offsetFile = readFileSync(join(process.cwd(), "output", "offset.txt")) 373 | const customFile = readFileSync(join(process.cwd(), "output", "custom.txt")) 374 | 375 | // "now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}", 376 | // "offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}", 377 | // "custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}", 378 | 379 | expect(nowFile.toString()).toEqual( 380 | `Today is ${dateFns.format(now, "mmm")}, time is ${dateFns.format(now, "HH:mm")}`, 381 | ) 382 | expect(offsetFile.toString()).toEqual( 383 | `Yesterday was ${dateFns.format(yesterday, "mmm")}, time is ${dateFns.format(yesterday, "HH:mm")}`, 384 | ) 385 | expect(customFile.toString()).toEqual( 386 | `Custom date is ${dateFns.format(dateFns.parseISO(customDate), "mmm")}, time is ${dateFns.format( 387 | dateFns.parseISO(customDate), 388 | "HH:mm", 389 | )}`, 390 | ) 391 | }) 392 | }), 393 | ) 394 | describe( 395 | "custom helpers", 396 | withMock(fileStructHelpers, () => { 397 | const _helpers: Record string> = { 398 | add1: (text) => text + " 1", 399 | } 400 | test("should work", async () => { 401 | await Scaffold({ 402 | name: "app_name", 403 | output: "output", 404 | templates: ["input"], 405 | logLevel: "none", 406 | helpers: _helpers, 407 | }) 408 | 409 | const results = { 410 | add1: "app_name 1", 411 | } 412 | for (const key in results) { 413 | const file = readFileSync(join(process.cwd(), "output", "custom", `${key}.txt`)) 414 | expect(file.toString()).toEqual(results[key as keyof typeof results]) 415 | } 416 | }) 417 | }), 418 | ) 419 | describe( 420 | "transform subdir", 421 | withMock(fileStructSubdirTransformer, () => { 422 | test("should work with no helper", async () => { 423 | await Scaffold({ 424 | name: "app_name", 425 | output: "output", 426 | templates: ["input"], 427 | subdir: true, 428 | logLevel: "none", 429 | }) 430 | 431 | const data = readFileSync(join(process.cwd(), "output", "app_name", "app_name.txt")) 432 | expect(data.toString()).toEqual("Hello, my app is app_name") 433 | }) 434 | 435 | test("should work with default helper", async () => { 436 | await Scaffold({ 437 | name: "app_name", 438 | output: "output", 439 | templates: ["input"], 440 | subdir: true, 441 | logLevel: "none", 442 | subdirHelper: "upperCase", 443 | }) 444 | 445 | const data = readFileSync(join(process.cwd(), "output", "APP_NAME", "app_name.txt")) 446 | expect(data.toString()).toEqual("Hello, my app is app_name") 447 | }) 448 | 449 | test("should work with custom helper", async () => { 450 | await Scaffold({ 451 | name: "app_name", 452 | output: "output", 453 | templates: ["input"], 454 | subdir: true, 455 | logLevel: "none", 456 | subdirHelper: "test", 457 | helpers: { 458 | test: () => "REPLACED", 459 | }, 460 | }) 461 | 462 | const data = readFileSync(join(process.cwd(), "output", "REPLACED", "app_name.txt")) 463 | expect(data.toString()).toEqual("Hello, my app is app_name") 464 | }) 465 | }), 466 | ) 467 | describe( 468 | "before write", 469 | withMock(fileStructNormal, () => { 470 | test("should work with no callback", async () => { 471 | await Scaffold({ 472 | name: "app_name", 473 | output: "output", 474 | templates: ["input"], 475 | logLevel: "none", 476 | data: { 477 | value: "value", 478 | }, 479 | }) 480 | 481 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 482 | expect(data.toString()).toEqual("Hello, my app is app_name") 483 | }) 484 | 485 | test("should work with custom callback", async () => { 486 | await Scaffold({ 487 | name: "app_name", 488 | output: "output", 489 | templates: ["input"], 490 | logLevel: "none", 491 | data: { 492 | value: "value", 493 | }, 494 | beforeWrite: (content, beforeContent, outputPath) => 495 | [content.toString().toUpperCase(), beforeContent, outputPath].join(", "), 496 | }) 497 | 498 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 499 | expect(data.toString()).toEqual( 500 | [ 501 | "Hello, my app is app_name".toUpperCase(), 502 | fileStructNormal.input["{{name}}.txt"], 503 | join(process.cwd(), "output", "app_name.txt"), 504 | ].join(", "), 505 | ) 506 | }) 507 | test("should work with undefined response custom callback", async () => { 508 | await Scaffold({ 509 | name: "app_name", 510 | output: "output", 511 | templates: ["input"], 512 | logLevel: "none", 513 | data: { 514 | value: "value", 515 | }, 516 | beforeWrite: () => undefined, 517 | }) 518 | 519 | const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) 520 | expect(data.toString()).toEqual("Hello, my app is app_name") 521 | }) 522 | }), 523 | ) 524 | }) 525 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { handleErr, resolve, colorize, TermColor } from "../src/utils" 2 | describe("utils", () => { 3 | describe("resolve", () => { 4 | test("should resolve function", () => { 5 | expect(resolve(() => 1, null)).toBe(1) 6 | expect(resolve((x) => x, 2)).toBe(2) 7 | }) 8 | test("should resolve value", () => { 9 | expect(resolve(1, null)).toBe(1) 10 | expect(resolve(2, 1)).toBe(2) 11 | }) 12 | }) 13 | 14 | describe("handleErr", () => { 15 | test("should throw error", () => { 16 | expect(() => handleErr({ name: "test", message: "test" })).toThrow() 17 | expect(() => handleErr(null as never)).not.toThrow() 18 | }) 19 | }) 20 | }) 21 | 22 | describe("colorize", () => { 23 | it("should colorize text with red color", () => { 24 | const result = colorize("Hello", "red") 25 | expect(result).toBe("\x1b[31mHello\x1b[0m") 26 | }) 27 | 28 | it("should colorize text with bold", () => { 29 | const result = colorize("Hello", "bold") 30 | expect(result).toBe("\x1b[1mHello\x1b[23m") 31 | }) 32 | 33 | it("should reset color", () => { 34 | const result = colorize("Hello", "reset") 35 | expect(result).toBe("\x1b[0mHello\x1b[0m") 36 | }) 37 | 38 | it("should have all color functions", () => { 39 | const colors: TermColor[] = [ 40 | "reset", 41 | "dim", 42 | "bold", 43 | "italic", 44 | "underline", 45 | "red", 46 | "green", 47 | "yellow", 48 | "blue", 49 | "magenta", 50 | "cyan", 51 | "white", 52 | "gray", 53 | ] 54 | colors.forEach((color) => { 55 | expect(typeof colorize[color]).toBe("function") 56 | }) 57 | }) 58 | 59 | it("should colorize text using colorize.red", () => { 60 | const result = colorize.red("Hello") 61 | expect(result).toBe("\x1b[31mHello\x1b[0m") 62 | }) 63 | 64 | it("should colorize text using template strings with colorize.blue", () => { 65 | const result = colorize.blue`Hello ${"World"}` 66 | expect(result).toBe("\x1b[34mHello World\x1b[0m") 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "ESNext" 10 | ], 11 | "declaration": true, 12 | "outDir": "dist", 13 | "strict": true, 14 | "sourceMap": true, 15 | "removeComments": false, 16 | "paths": { 17 | "@/*": [ 18 | "./src/*" 19 | ], 20 | }, 21 | }, 22 | "include": [ 23 | "src/index.ts", 24 | "src/cmd.ts" 25 | ], 26 | "exclude": [ 27 | "tests/*" 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /typedoc.config.js: -------------------------------------------------------------------------------- 1 | const path = require("node:path") 2 | 3 | /** @type {import('typedoc').TypeDocOptions} */ 4 | module.exports = { 5 | name: "Simple Scaffold", 6 | entryPoints: ["src/index.ts"], 7 | includeVersion: true, 8 | categorizeByGroup: false, 9 | sort: ["visibility"], 10 | categoryOrder: ["Main", "*"], 11 | media: "media", 12 | githubPages: true, 13 | entryPointStrategy: "expand", 14 | out: "docs", 15 | excludePrivate: true, 16 | excludeProtected: true, 17 | excludeInternal: true, 18 | gaID: "GTM-KHQS9TQ", 19 | validation: { 20 | invalidLink: true, 21 | }, 22 | plugin: ["@knodes/typedoc-plugin-pages"], 23 | customCss: "src/docs.css", 24 | options: "typedoc.config.js", 25 | logLevel: "Verbose", 26 | pluginPages: { 27 | logLevel: "Verbose", 28 | pages: [ 29 | { 30 | name: "Configuration", 31 | source: "README.md", 32 | childrenDir: path.join(process.cwd(), "pages"), 33 | childrenOutputDir: "./", 34 | children: [ 35 | { 36 | name: "CLI usage", 37 | source: "cli.md", 38 | }, 39 | { 40 | name: "Node.js usage", 41 | source: "node.md", 42 | }, 43 | { 44 | name: "Templates", 45 | source: "templates.md", 46 | }, 47 | { 48 | name: "Configuration Files", 49 | source: "configuration_files.md", 50 | }, 51 | { 52 | name: "Migrating v0.x to v1.x", 53 | source: "migration.md", 54 | }, 55 | ], 56 | }, 57 | { 58 | name: "Changelog", 59 | source: "./CHANGELOG.md", 60 | }, 61 | ], 62 | }, 63 | } 64 | --------------------------------------------------------------------------------