├── .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 |
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 | 
12 | 
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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------