Compare commits

..

92 Commits

Author SHA1 Message Date
dependabot[bot]
bfb6c40ba5 build(deps): bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).
Bumps the npm_and_yarn group with 1 update in the /docs directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `vite` from 8.0.3 to 8.0.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 23:50:50 +03:00
8834e49085 chore(master): release 3.1.1 2026-03-27 23:24:03 +03:00
c797893eb5 docs: rewrite docs 2026-03-26 23:56:54 +02:00
5b5a1fa9a4 docs: rewrite readme 2026-03-26 23:42:45 +02:00
f5ed38ad95 fix(deps): update dependencies 2026-03-26 23:30:21 +02:00
6f15f3db14 build: update release workflows 2026-03-23 17:28:49 +02:00
02dcbdaf13 chore(master): release 3.1.0 2026-03-23 17:21:02 +02:00
970e8e0b37 build: fix build warnings & reduce bundle size 2026-03-23 17:17:23 +02:00
c860e4644a fix: interactive inputs with existing config/cli options 2026-03-23 17:14:25 +02:00
bb0248f91a feat: update all ouput logging 2026-03-23 17:02:33 +02:00
972d199fbb feat: config validation 2026-03-23 16:53:55 +02:00
b5fd1df821 feat: scaffoldignore 2026-03-23 16:41:08 +02:00
f6408f221d feat: select, confirm and number input types 2026-03-23 16:32:41 +02:00
0a4ead17c0 feat: after scaffold hook 2026-03-23 16:28:40 +02:00
7926b15053 feat: init command 2026-03-23 16:22:43 +02:00
9cdea1c5ea build: add lint-staged 2026-03-23 16:18:21 +02:00
4a79961ae5 build: update node version 2026-03-23 14:39:31 +02:00
51f422cc90 chore(master): release 3.0.0 2026-03-23 14:25:33 +02:00
93f3a4caaf chore(deps): update docs dependencies 2026-03-23 12:37:27 +02:00
04e7e895d7 feat: add .scaffold.{ext} as auto-detected file 2026-03-23 12:34:48 +02:00
68e6d17fa9 feat: auto-detect config file
Release-As: 3.0.0
2026-03-23 12:29:57 +02:00
d64dd4f0e7 feat: predefined data inputs 2026-03-23 12:27:55 +02:00
519ef273ac feat: interactive inputs 2026-03-23 12:16:24 +02:00
1431fda3db docs: fixes 2026-03-23 12:16:24 +02:00
2229a9cda1 refactor: reorganize and simplify all logic 2026-03-23 11:57:05 +02:00
1f80a50185 docs: update README.md 2026-03-23 11:49:18 +02:00
e82827d909 build: update workflows 2026-03-23 11:45:52 +02:00
2e49448e59 docs: update docs build 2026-03-23 11:39:47 +02:00
0ffd7ef788 build: migrate to vite+vitest 2026-03-23 10:45:57 +02:00
d16fb17c38 test: add comprehensive tests 2026-03-23 10:38:00 +02:00
af33c059b9 fix: string helpers to words parts conversion 2026-03-23 10:37:54 +02:00
d487d36b04 chore(deps): update dependencies 2026-03-23 10:24:27 +02:00
429f12d1b8 chore(master): release 2.3.3 2025-06-19 07:50:55 +03:00
dcba30689b chore: update formatting & lints 2025-06-19 01:28:15 +03:00
7745385573 chore: update dependencies 2025-06-19 01:28:03 +03:00
29f2afe097 test: add cli precedence test 2025-06-19 01:27:50 +03:00
4b0b4e7380 fix: config CLI precedence over file 2025-06-19 00:51:40 +03:00
Chen Asraf
c1536839e3 chore(master): release 2.3.2 2024-10-27 03:32:33 +02:00
7e029fd122 chore: update deps 2024-10-27 01:18:18 +02:00
41f4ca52f1 fix: template config from CLI 2024-10-27 01:12:43 +02:00
Chen Asraf
78d6bf186d chore(master): release 2.3.1 2024-10-03 14:16:18 +03:00
Chen Asraf
80c92bfe84 fix: strip tmpDir from output dir (#108)
* fix: strip tmpDir from output dir

* ci: test PRs

* fix: cmd

* fix: use relative path for replacement

* chore: remove todo
2024-10-03 14:12:30 +03:00
162cc8cec1 ci: remove develop branch 2024-09-18 00:27:51 +03:00
Chen Asraf
db6177c200 chore(develop): release 2.3.0 2024-09-18 00:12:00 +03:00
ae64db846f chore: add coverage script 2024-09-18 00:12:00 +03:00
89dc43c73d fix: exclude globs
refactor: split main file iteration to 2 steps
2024-09-17 23:57:48 +03:00
2c43dc4daf build: update target 2024-09-17 23:57:48 +03:00
f4c907e6c9 ci: fix build 2024-09-17 23:57:48 +03:00
a275e688d4 ci: use release-please 2024-09-17 23:57:48 +03:00
ff4ebf0a5b test: add color tests 2024-09-17 23:57:48 +03:00
ab9322e1ab feat: remove chalk dependency 2024-09-17 23:57:48 +03:00
semantic-release-bot
35f0d014d9 chore(release): 2.2.2 [skip ci]
## [2.2.2](https://github.com/chenasraf/simple-scaffold/compare/v2.2.1...v2.2.2) (2024-08-27)

### Bug Fixes

* homepage url ([daaefaf](daaefaf54e))
2024-08-27 13:07:31 +00:00
8ad8cb4be1 chore: update dependencies 2024-08-27 16:06:56 +03:00
daaefaf54e fix: homepage url 2024-08-27 16:06:56 +03:00
aefba4b773 docs: fix property names 2024-08-27 16:06:56 +03:00
dependabot[bot]
8457f0996a build(deps): bump ws
Bumps the npm_and_yarn group with 1 update in the /docs directory: [ws](https://github.com/websockets/ws).


Updates `ws` from 7.5.9 to 7.5.10
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.9...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-27 16:06:56 +03:00
semantic-release-bot
adc95809ba chore(release): 2.2.1 [skip ci]
## [2.2.1](https://github.com/chenasraf/simple-scaffold/compare/v2.2.0...v2.2.1) (2024-04-21)

### Bug Fixes

* beforeWrite from config files ([98b326c](98b326c843))
* use console.info for handlebars parse warning ([19e7b0f](19e7b0f0c3))
2024-04-21 20:38:09 +00:00
98b326c843 fix: beforeWrite from config files 2024-04-21 23:37:35 +03:00
ddc115a037 chore: update dependencies 2024-04-21 23:37:35 +03:00
19e7b0f0c3 fix: use console.info for handlebars parse warning 2024-04-21 23:37:35 +03:00
dependabot[bot]
f883571daa build(deps): bump express from 4.18.2 to 4.19.2 in /docs
Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-21 23:37:35 +03:00
Chen Asraf
be3068a533 ci: update doc workflow permissions 2024-04-21 23:37:35 +03:00
dependabot[bot]
8acc660dea build(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /docs directory: [follow-redirects](https://github.com/follow-redirects/follow-redirects) and [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware).


Updates `follow-redirects` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

Updates `webpack-dev-middleware` from 5.3.3 to 5.3.4
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
  dependency-group: npm_and_yarn-security-group
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-21 23:37:35 +03:00
semantic-release-bot
df6c351cb0 chore(release): 2.2.0 [skip ci]
# [2.2.0](https://github.com/chenasraf/simple-scaffold/compare/v2.1.0...v2.2.0) (2024-02-23)

### Features

* `list` command ([d579c09](d579c09c11))
* add `--before-write` cli option ([#89](https://github.com/chenasraf/simple-scaffold/issues/89)) ([5f810e2](5f810e2116))
2024-02-23 22:42:32 +00:00
Chen Asraf
5f810e2116 feat: add --before-write cli option (#89)
* feat: add `--before-write` cli option

* refactor: cleanup before write wrapper

* docs: add before-write docs
2024-02-24 00:41:56 +02:00
d579c09c11 feat: list command 2024-02-24 00:41:56 +02:00
3765398ab9 ci: attemp to fix pack cmd 2024-02-24 00:41:56 +02:00
semantic-release-bot
9be8a8b71b chore(release): 2.1.0 [skip ci]
# [2.1.0](https://github.com/chenasraf/simple-scaffold/compare/v2.0.2...v2.1.0) (2024-02-12)

### Features

* support directory in --config flag ([e48b832](e48b832e0b))
* support providing name in config ([4e7ac34](4e7ac34db9))
2024-02-12 23:39:35 +00:00
5cb8c3c081 docs: update navbar logo 2024-02-13 01:39:04 +02:00
3b52c255f0 chore: dry 2024-02-13 01:39:04 +02:00
80cf2076b0 chore: update dependencies 2024-02-13 01:39:04 +02:00
4fd710b763 test: fix tests 2024-02-13 01:39:04 +02:00
4e7ac34db9 feat: support providing name in config 2024-02-13 01:39:04 +02:00
e48b832e0b feat: support directory in --config flag 2024-02-13 01:39:04 +02:00
06ffa656ae docs: update templates page 2024-02-13 01:39:04 +02:00
919fd54ebb docs: usage page order 2024-02-13 01:39:04 +02:00
semantic-release-bot
dbc3283d5a chore(release): 2.0.2 [skip ci]
## [2.0.2](https://github.com/chenasraf/simple-scaffold/compare/v2.0.1...v2.0.2) (2024-02-04)

### Bug Fixes

* try to await scaffold before finally ([1b70897](1b70897f98))
2024-02-04 14:20:58 +00:00
Chen Asraf
0e567ec56f chore: show handlebars fail on default log level
fixes #86
2024-02-04 16:20:25 +02:00
semantic-release-bot
0ec671fe83 chore(release): 2.0.2-pre.1 [skip ci]
## [2.0.2-pre.1](https://github.com/chenasraf/simple-scaffold/compare/v2.0.1...v2.0.2-pre.1) (2024-02-03)

### Bug Fixes

* try to await scaffold before finally ([b0a6bf7](b0a6bf7021))
2024-02-04 16:20:25 +02:00
1b70897f98 fix: try to await scaffold before finally 2024-02-04 16:20:25 +02:00
43c9e8748f docs: update readme 2024-02-04 16:20:25 +02:00
65b14bc707 docs: update logo 2024-02-04 16:20:25 +02:00
f583af662c docs: update readme 2024-02-04 16:20:25 +02:00
600cc78186 docs: add logo, update docs 2024-02-04 16:20:25 +02:00
b4aea804cb docs: update readme 2024-02-04 16:20:25 +02:00
795635dc61 docs: update readme 2024-02-04 16:20:25 +02:00
6a026ce1a1 chore: version output + docs update 2024-02-04 16:20:25 +02:00
268e4d973c docs: fix github link 2024-02-04 16:20:25 +02:00
a403d9df9b ci: clean up release config 2024-02-04 16:20:25 +02:00
298beff355 docs: fix edit link 2024-02-04 16:20:25 +02:00
8f1d58f2c2 ci: fix pack step order 2024-02-04 16:20:25 +02:00
b10d69d2fe chore: update package.json spec 2024-02-04 16:20:25 +02:00
64 changed files with 18082 additions and 12724 deletions

View File

@@ -1,33 +0,0 @@
name: Documentation
on:
push:
branches: [master, pre, develop]
jobs:
docs:
name: Build Documentation
runs-on: ubuntu-latest
# if: "contains(github.event.head_commit.message, 'chore(release)')"
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20.x"
- name: Install PNPM
run: npm i -g pnpm
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
cd docs && pnpm install --frozen-lockfile
- name: Build Docs
run: pnpm docs:build
- name: Deploy on GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build

View File

@@ -1,26 +0,0 @@
name: Pull Requests
on:
pull_request:
branches: [master, pre, develop]
jobs:
build:
name: Test & Build PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20.x"
- name: Install PNPM
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Tests
run: pnpm test
- name: Build Package
run: pnpm build

View File

@@ -1,38 +1,102 @@
name: Release
on:
pull_request:
branches:
- master
push:
branches: [master, pre, develop]
branches:
- master
permissions:
contents: read # for checkout
contents: write
pull-requests: write
id-token: write
jobs:
release:
name: Release
test:
name: Test
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to enable use of OIDC for npm provenance
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install PNPM
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Tests
run: pnpm test
- name: Build Package
run: pnpm build
- name: Semantic Release
run: npx semantic-release
env:
NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
release:
name: Release Please
if: github.event_name == 'push'
needs:
- build
- test
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
release-type: node
target-branch: master
publish:
name: NPM Publish
needs: release
runs-on: ubuntu-latest
if: ${{ needs.release.outputs.release_created }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: "https://registry.npmjs.org"
- run: |
echo "node: $(node --version)"
echo "npm: $(npm --version)"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: npm publish ./dist --provenance
docs:
name: Deploy Documentation
needs: release
runs-on: ubuntu-latest
if: ${{ needs.release.outputs.release_created }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cd docs && pnpm install --frozen-lockfile
- run: pnpm docs:build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm lint-staged

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
docs/docs/api/
examples/
.github/
CHANGELOG.md
pnpm-lock.yaml

View File

@@ -1,8 +1,9 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2,
"printWidth": 100,
"overrides": [
{
"files": "*.md",

View File

@@ -1,5 +1,115 @@
# Change Log
## [3.1.1](https://github.com/chenasraf/simple-scaffold/compare/v3.1.0...v3.1.1) (2026-03-26)
### Bug Fixes
* **deps:** update dependencies ([f5ed38a](https://github.com/chenasraf/simple-scaffold/commit/f5ed38ad950e3738b173cd39baf2acc9e35bf8de))
## [3.1.0](https://github.com/chenasraf/simple-scaffold/compare/v3.0.0...v3.1.0) (2026-03-23)
### Features
* after scaffold hook ([0a4ead1](https://github.com/chenasraf/simple-scaffold/commit/0a4ead17c013b5410e8eec8000e83fa7b7186fbb))
* config validation ([972d199](https://github.com/chenasraf/simple-scaffold/commit/972d199fbb7167506f76eba697fbea6acea9de56))
* init command ([7926b15](https://github.com/chenasraf/simple-scaffold/commit/7926b15053726d5eb9e5772b5f04c3989e567b2c))
* scaffoldignore ([b5fd1df](https://github.com/chenasraf/simple-scaffold/commit/b5fd1df821eba7e13e5ceedc53da550c8dd7cd8e))
* select, confirm and number input types ([f6408f2](https://github.com/chenasraf/simple-scaffold/commit/f6408f221d84d84c5671737af63ff8079093fd27))
* update all ouput logging ([bb0248f](https://github.com/chenasraf/simple-scaffold/commit/bb0248f91a012c297e408f15f0846c5272166543))
### Bug Fixes
* interactive inputs with existing config/cli options ([c860e46](https://github.com/chenasraf/simple-scaffold/commit/c860e4644a3f3d4bd6b7dab9974accdf8bae9463))
## [3.0.0](https://github.com/chenasraf/simple-scaffold/compare/v2.3.3...v3.0.0) (2026-03-23)
### Features
* add .scaffold.{ext} as auto-detected file ([04e7e89](https://github.com/chenasraf/simple-scaffold/commit/04e7e895d7d97e29cf1ded2f2d0ac8e6a237b997))
* auto-detect config file ([68e6d17](https://github.com/chenasraf/simple-scaffold/commit/68e6d17fa9898dffbc620eba0140748a90bc007f))
* interactive inputs ([519ef27](https://github.com/chenasraf/simple-scaffold/commit/519ef273ac3db4b7a1e71c8e1c456aa1334d6fbd))
* predefined data inputs ([d64dd4f](https://github.com/chenasraf/simple-scaffold/commit/d64dd4f0e775d3bff3efb074d0af23d54edcbaab))
### Bug Fixes
* string helpers to words parts conversion ([af33c05](https://github.com/chenasraf/simple-scaffold/commit/af33c059b91d3f463a5d174ab3a0119c577880c5))
## [2.3.3](https://github.com/chenasraf/simple-scaffold/compare/v2.3.2...v2.3.3) (2025-06-18)
### Bug Fixes
* config CLI precedence over file ([4b0b4e7](https://github.com/chenasraf/simple-scaffold/commit/4b0b4e73803ff741120b18767ded88db324a8844))
## [2.3.2](https://github.com/chenasraf/simple-scaffold/compare/v2.3.1...v2.3.2) (2024-10-27)
### Bug Fixes
* template config from CLI ([41f4ca5](https://github.com/chenasraf/simple-scaffold/commit/41f4ca52f12d3477e1a9a15757dc816fb99b6743))
## [2.3.1](https://github.com/chenasraf/simple-scaffold/compare/v2.3.0...v2.3.1) (2024-10-03)
### Bug Fixes
* strip tmpDir from output dir ([#108](https://github.com/chenasraf/simple-scaffold/issues/108)) ([80c92bf](https://github.com/chenasraf/simple-scaffold/commit/80c92bfe84dc896412ef98bce222e1d26cdb4e91))
## [2.3.0](https://github.com/chenasraf/simple-scaffold/compare/v2.2.2...v2.3.0) (2024-09-17)
### Features
* remove chalk dependency ([ab9322e](https://github.com/chenasraf/simple-scaffold/commit/ab9322e1ab9c0a07cdab7275f3398286dee67a64))
### Bug Fixes
* exclude globs ([89dc43c](https://github.com/chenasraf/simple-scaffold/commit/89dc43c73d9a8640f45ae77e5c89e4f08f7f99ad))
## [2.2.2](https://github.com/chenasraf/simple-scaffold/compare/v2.2.1...v2.2.2) (2024-08-27)
### Bug Fixes
* homepage url ([daaefaf](https://github.com/chenasraf/simple-scaffold/commit/daaefaf54e8c8887e6f210d02fd5f96c6ff4aa21))
## [2.2.1](https://github.com/chenasraf/simple-scaffold/compare/v2.2.0...v2.2.1) (2024-04-21)
### Bug Fixes
* beforeWrite from config files ([98b326c](https://github.com/chenasraf/simple-scaffold/commit/98b326c84346162f379af46bc5aefb69df8be515))
* use console.info for handlebars parse warning ([19e7b0f](https://github.com/chenasraf/simple-scaffold/commit/19e7b0f0c35c1b79a98781bdec9f54354123d8e0))
# [2.2.0](https://github.com/chenasraf/simple-scaffold/compare/v2.1.0...v2.2.0) (2024-02-23)
### Features
* `list` command ([d579c09](https://github.com/chenasraf/simple-scaffold/commit/d579c09c11f2149fe7bb4515297c1287fa67083e))
* add `--before-write` cli option ([#89](https://github.com/chenasraf/simple-scaffold/issues/89)) ([5f810e2](https://github.com/chenasraf/simple-scaffold/commit/5f810e21160816bc683cc0f375de318ff874871c))
# [2.1.0](https://github.com/chenasraf/simple-scaffold/compare/v2.0.2...v2.1.0) (2024-02-12)
### Features
* support directory in --config flag ([e48b832](https://github.com/chenasraf/simple-scaffold/commit/e48b832e0b72a084d33fa2cbcca332e8209a734f))
* support providing name in config ([4e7ac34](https://github.com/chenasraf/simple-scaffold/commit/4e7ac34db9bf67d012bbd1c06c1a26bc5ac93559))
## [2.0.2](https://github.com/chenasraf/simple-scaffold/compare/v2.0.1...v2.0.2) (2024-02-04)
### Bug Fixes
* try to await scaffold before finally ([1b70897](https://github.com/chenasraf/simple-scaffold/commit/1b70897f9840e6365ff800490fbb813b9840177d))
## [2.0.1](https://github.com/chenasraf/simple-scaffold/compare/v2.0.0...v2.0.1) (2024-02-02)

376
README.md
View File

@@ -13,17 +13,12 @@
</h2>
Looking to streamline your workflow and get your projects up and running quickly? Look no further
than Simple Scaffold - the easy-to-use NPM package that simplifies the process of organizing and
copying your commonly-created files.
Simple Scaffold is a file scaffolding tool. You define templates once, then generate files from them
whenever you need — whether it's a single component or an entire app boilerplate.
With its agnostic and un-opinionated approach, Simple Scaffold can handle anything from a few simple
files to an entire app boilerplate setup. Plus, with the power of **Handlebars.js** syntax, you can
easily replace custom data and personalize your files to fit your exact needs. But that's not all -
you can also use it to loop through data, use conditions, and write custom functions using helpers.
Don't waste any more time manually copying and pasting files - let Simple Scaffold do the heavy
lifting for you and start building your projects faster and more efficiently today!
Templates use **Handlebars.js** syntax, so you can inject data, loop over lists, use conditionals,
and write custom helpers. It works as a CLI or as a Node.js library, and it doesn't care what kind
of files you're generating.
<div align="center">
@@ -33,149 +28,292 @@ lifting for you and start building your projects faster and more efficiently tod
---
## Documentation
> **Full documentation is available at
> [chenasraf.github.io/simple-scaffold](https://chenasraf.github.io/simple-scaffold)** — including
> detailed guides on [CLI usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli),
> [Node.js API](https://chenasraf.github.io/simple-scaffold/docs/usage/node),
> [templates](https://chenasraf.github.io/simple-scaffold/docs/usage/templates),
> [configuration files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files),
> [examples](https://chenasraf.github.io/simple-scaffold/docs/usage/examples), and
> [migration from v1/v2](https://chenasraf.github.io/simple-scaffold/docs/usage/migration).
See full documentation [here](https://chenasraf.github.io/simple-scaffold).
## Table of Contents
- [Command Line Interface (CLI) usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli)
- [Node.js usage](https://chenasraf.github.io/simple-scaffold/docs/usage/node)
- [Templates](https://chenasraf.github.io/simple-scaffold/docs/usage/templates)
- [Configuration Files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files)
- [Migration](https://chenasraf.github.io/simple-scaffold/docs/usage/migration)
- [Getting Started](#getting-started)
- [Configuration Files](#configuration-files)
- [Templates](#templates)
- [Interactive Mode & Inputs](#interactive-mode--inputs)
- [Remote Templates](#remote-templates)
- [CLI Reference](#cli-reference)
- [Node.js API](#nodejs-api)
- [Built-in Helpers](#built-in-helpers)
- [Contributing](#contributing)
## Getting Started
### Cheat Sheet
A quick rundown of common usage scenarios:
- Remote template config file on GitHub:
```sh
npx simple-scaffold -g username/repository -c scaffold.js -k component NewComponentName
```
- Local template config file:
```sh
npx simple-scaffold -c scaffold.js -k component NewComponentName
```
- Local one-time usage:
```sh
npx simple-scaffold -t templates/component -o src/components NewComponentName
```
### Remote Configurations
The fastest way to get started is to is to re-use someone else's (or your own) work using a template
repository.
A remote config can be loaded in one of these ways:
- For templates hosted on GitHub, the syntax is `-g user/repository_name`
- For other Git platforms like GitLab, use `-g https://example.com/user/repository_name.git`
These remote configurations support multiple scaffold groups, which can be specified using the
`--key` or `-k` argument:
### Install
```sh
$ npx simple-scaffold \
-g chenasraf/simple-scaffold \
-k component \
PageWrapper
# equivalent to:
$ npx simple-scaffold \
-g https://github.com/chenasraf/simple-scaffold.git \
-c scaffold.config.js \
-k component \
PageWrapper
npm install -D simple-scaffold
# or use directly with npx
npx simple-scaffold
```
By default, the template name is set to `default` when the `--key` option is not provided.
### Initialize a Project
See information about each option and flag using the `--help` flag, or read the
[CLI documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/cli). For information
about how configuration files work, [see below](#configuration-files).
Run `init` to create a config file and an example template:
### Configuration Files
```sh
npx simple-scaffold init
```
You can use a config file to more easily maintain all your scaffold definitions.
This creates `scaffold.config.js` and `templates/default/{{name}}.md`. Now generate files:
`scaffold.config.js`
```sh
npx simple-scaffold MyProject
```
### One-off Usage (No Config)
Generate files from a template directory without a config file:
```sh
npx simple-scaffold -t templates/component -o src/components MyComponent
```
## Configuration Files
Config files let you define reusable scaffold definitions. Simple Scaffold **auto-detects** config
files in the current directory — no `--config` flag needed.
It searches for these files in order:
`scaffold.config.{mjs,cjs,js,json}`, `scaffold.{mjs,cjs,js,json}`, `.scaffold.{mjs,cjs,js,json}`
### Example
```js
// scaffold.config.js
module.exports = {
// use "default" to avoid needing to specify key
// in this case the key is "component"
component: {
templates: ["templates/component"],
output: "src/components",
data: {
// ...
},
page: {
templates: ["templates/page"],
output: "src/pages",
subdir: true,
},
}
```
Then run:
```sh
npx simple-scaffold -k component MyComponent
npx simple-scaffold -k page Dashboard
```
Use the key `default` to skip the `-k` flag entirely.
### Listing Available Templates
```sh
npx simple-scaffold list
npx simple-scaffold list -c path/to/config.js
```
## Templates
Templates are regular files in a directory. Both **file names** and **file contents** support
Handlebars syntax. Simple Scaffold preserves the directory structure of your template folder.
### Example Template
`templates/component/{{pascalCase name}}.tsx`
```tsx
// Created: {{ now 'yyyy-MM-dd' }}
import React from "react"
export default {{ pascalCase name }}: React.FC = (props) => {
return (
<div className="{{ camelCase name }}">{{ pascalCase name }} Component</div>
)
}
```
Running `npx simple-scaffold -t templates/component -o src/components PageWrapper` produces
`src/components/PageWrapper.tsx` with all tokens replaced.
### Glob Patterns & Exclusions
Template paths support globs and negation:
```js
{
templates: ["templates/component/**", "!templates/component/README.md"]
}
```
### .scaffoldignore
Place a `.scaffoldignore` file in your template directory to exclude files. It works like
`.gitignore` — one pattern per line, `#` for comments.
## Interactive Mode & Inputs
When running in a terminal, Simple Scaffold prompts for any missing required values (name, output,
template key). Config files can also define **inputs** — custom fields that are prompted
interactively:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { type: "text", message: "Author name", required: true },
license: { type: "select", message: "License", options: ["MIT", "Apache-2.0", "GPL-3.0"] },
isPublic: { type: "confirm", message: "Public package?" },
priority: { type: "number", message: "Priority level", default: 1 },
},
},
}
```
Then call your scaffold like this:
**Input types:** `text` (default), `select`, `confirm`, `number`
Pre-fill inputs from the command line to skip prompts:
```sh
$ npx simple-scaffold -c scaffold.config.js PageWrapper
npx simple-scaffold -k component -D author=John -D license=MIT MyComponent
```
This will allow you to avoid needing to remember which configs are needed or to store them in a
one-liner in `package.json` which can get pretty long and messy, and harder to maintain.
## Remote Templates
Also, this allows you to define more complex scaffolds with logic without having to use the Node.js
API directly. (Of course you always have the option to still do so if you wish)
More information can be found at the
[Configuration Files documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files).
### Templates Structure
Templates are **any file** in the a directory given to `--templates`.
Simple Scaffold will maintain any file and directory structure you try to generate, while replacing
any tokens such as `{{ name }}` or other custom-data using
[Handlebars.js](https://handlebarsjs.com/).
`templates/component/{{ pascalName name }}.tsx`
```tsx
// Created: {{ now 'yyyy-MM-dd' }}
import React from 'react'
export default {{pascalCase name}}: React.FC = (props) => {
return (
<div className="{{camelCase name}}">{{pascalCase name}} Component</div>
)
}
```
To generate the template output once without saving a configuration file, run:
Use templates from any Git repository:
```sh
# generate single component
$ npx simple-scaffold \
-t templates/component \
-o src/components \
PageWrapper
# GitHub shorthand
npx simple-scaffold -g username/repo -k component MyComponent
# Full Git URL (GitLab, Bitbucket, etc.)
npx simple-scaffold -g https://gitlab.com/user/repo.git -k component MyComponent
```
This will immediately create the following file: `src/components/PageWrapper.tsx`
The repository is cloned to a temporary directory, used, and cleaned up automatically.
```tsx
// Created: 2077-01-01
import React from 'react'
## CLI Reference
export default PageWrapper: React.FC = (props) => {
return (
<div className="pageWrapper">PageWrapper Component</div>
)
### Commands
| Command | Description |
| ------------- | ---------------------------------------- |
| `[name]` | Generate files from a template (default) |
| `init` | Create config file and example template |
| `list` / `ls` | List available template keys in a config |
### Options
| Flag | Short | Description | Default |
| ------------------------------ | --------- | ---------------------------------------------------- | ----------- |
| `--config` | `-c` | Path to config file or directory | auto-detect |
| `--git` | `-g` | Git URL or GitHub shorthand | |
| `--key` | `-k` | Template key from config | `default` |
| `--output` | `-o` | Output directory | |
| `--templates` | `-t` | Template file paths or globs | |
| `--data` | `-d` | Custom JSON data | |
| `--append-data` | `-D` | Key-value data (`key=string`, `key:=raw`) | |
| `--subdir`/`--no-subdir` | `-s`/`-S` | Create parent directory with input name | `false` |
| `--subdir-helper` | `-H` | Helper to transform subdir name | |
| `--overwrite`/`--no-overwrite` | `-w`/`-W` | Overwrite existing files | `false` |
| `--dry-run` | `-dr` | Preview output without writing files | `false` |
| `--before-write` | `-B` | Script to run before each file is written | |
| `--after-scaffold` | `-A` | Shell command to run after scaffolding | |
| `--quiet` | `-q` | Suppress output | |
| `--log-level` | `-l` | Log level (`none`, `debug`, `info`, `warn`, `error`) | `info` |
| `--version` | `-v` | Show version | |
| `--help` | `-h` | Show help | |
## Node.js API
```js
import Scaffold from "simple-scaffold"
// Basic usage
const scaffold = new Scaffold({
name: "MyComponent",
templates: ["templates/component"],
output: "src/components",
})
await scaffold.run()
// Load from config file
const scaffold = await Scaffold.fromConfig("scaffold.config.js", {
key: "component",
name: "MyComponent",
})
await scaffold.run()
```
### Config Options
| Option | Type | Description |
| --------------- | -------------------------- | ------------------------------------- |
| `name` | `string` | Name for generated files (required) |
| `templates` | `string[]` | Template paths or globs (required) |
| `output` | `string \| Function` | Output directory or per-file function |
| `data` | `Record<string, unknown>` | Custom template data |
| `inputs` | `Record<string, Input>` | Interactive input definitions |
| `helpers` | `Record<string, Function>` | Custom Handlebars helpers |
| `subdir` | `boolean` | Create parent directory with name |
| `subdirHelper` | `string` | Helper for subdir name transformation |
| `overwrite` | `boolean \| Function` | Overwrite existing files |
| `dryRun` | `boolean` | Preview without writing |
| `logLevel` | `string` | Log verbosity |
| `beforeWrite` | `Function` | Async hook before each file write |
| `afterScaffold` | `Function \| string` | Hook after all files are written |
## Built-in Helpers
All helpers work in both file names and file contents.
### Case Helpers
| Helper | Example Input | Output |
| ------------ | ------------- | --------- |
| `camelCase` | `my name` | `myName` |
| `pascalCase` | `my name` | `MyName` |
| `snakeCase` | `my name` | `my_name` |
| `kebabCase` | `my name` | `my-name` |
| `hyphenCase` | `my name` | `my-name` |
| `startCase` | `my name` | `My Name` |
| `upperCase` | `my name` | `MY NAME` |
| `lowerCase` | `My Name` | `my name` |
### Date Helpers
```handlebars
{{now "yyyy-MM-dd"}}
{{now "yyyy-MM-dd HH:mm" -1 "hours"}}
{{date myDateVar "yyyy-MM-dd"}}
{{date "2077-01-01T00:00:00Z" "yyyy-MM-dd" 7 "days"}}
```
### Custom Helpers
Add your own via config:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
helpers: {
shout: (str) => str.toUpperCase() + "!!!",
},
},
}
```
@@ -189,7 +327,7 @@ just a small amount to help sustain this project, I would be very very thankful!
<img
height='36'
src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3'
alt='Buy Me a Coffee at ko-fi.com'
alt='Buy Me a Coffee at ko-fi.com'
/>
</a>

View File

@@ -0,0 +1,134 @@
---
title: Templates
---
# Templates
Templates are regular files in a directory. Both **file names** and **file contents** support
[Handlebars.js](https://handlebarsjs.com/) syntax for token replacement.
## How Templates Are Resolved
Each path in the `templates` array is resolved individually. If a path points to a directory or glob
pattern containing multiple files, the first directory up the tree becomes the base path — files are
then copied recursively into `output`, preserving their relative structure.
> In the examples below, `name` is `AppName` and `output` is `src`.
| Template path | Files found | Output |
| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| `./templates/{{ name }}.txt` | `./templates/{{ name }}.txt` | `src/AppName.txt` |
| `./templates/directory` | `outer/{{name}}.txt`,<br />`outer2/inner/{{name}}.txt` | `src/outer/AppName.txt`,<br />`src/outer2/inner/AppName.txt` |
| `./templates/others/**/*.txt` | `outer/{{name}}.jpg`,<br />`outer2/inner/{{name}}.txt` | `src/outer2/inner/AppName.txt` |
### Glob Patterns & Exclusions
Template paths support glob patterns and negation with `!`:
```js
{
templates: ["templates/component/**", "!templates/component/README.md"]
}
```
## Ignoring Files
Place a `.scaffoldignore` file in your template directory to exclude files. It works like
`.gitignore` — one pattern per line, `#` for comments.
```text
# .scaffoldignore
*.log
node_modules
README.md
dist/**
```
The `.scaffoldignore` file itself is never copied to the output.
Patterns are matched against both the file's basename and its path relative to the template
directory, so `README.md` matches at any depth while `dist/**` only matches a `dist` directory at
the template root.
## Token Replacement
Handlebars expressions like `{{ name }}` are replaced in both file names and file contents. The
`name` variable is always available — it's the name you pass when running the scaffold.
Any additional data from `--data`, `-D`, `data` config, or [inputs](configuration_files#inputs) is
also available.
```bash
npx simple-scaffold \
-t templates/component/{{name}}.jsx \
-o src/components \
MyComponent
```
This produces `src/components/MyComponent.jsx`, with all tokens inside the file replaced as well.
All standard Handlebars features work — `{{#if}}`, `{{#each}}`, `{{#with}}`, and more. See
[Handlebars.js Language Features](https://handlebarsjs.com/guide/#language-features) for details.
## Built-in Helpers
Simple Scaffold includes helpers you can use in templates and file names. Helpers can also be
nested: `{{ pascalCase (snakeCase name) }}`.
### Case Helpers
| Helper | Usage | `my name` becomes |
| ------------ | ----------------------- | ----------------- |
| _(none)_ | `{{ name }}` | `my name` |
| `camelCase` | `{{ camelCase name }}` | `myName` |
| `pascalCase` | `{{ pascalCase name }}` | `MyName` |
| `snakeCase` | `{{ snakeCase name }}` | `my_name` |
| `kebabCase` | `{{ kebabCase name }}` | `my-name` |
| `hyphenCase` | `{{ hyphenCase name }}` | `my-name` |
| `startCase` | `{{ startCase name }}` | `My Name` |
| `upperCase` | `{{ upperCase name }}` | `MY NAME` |
| `lowerCase` | `{{ lowerCase name }}` | `my name` |
### Date Helpers
Both `now` and `date` use [`date-fns`](https://date-fns.org/docs/format) format tokens.
| Helper | Example | Output |
| -------------------- | ---------------------------------------------------------------- | ------------------- |
| `now` | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
| `now` (with offset) | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` |
| `date` | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
| `date` (with offset) | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" -1 "days" }}` | `2041-12-31 15:00` |
| `date` (from data) | `{{ date myCustomDate "yyyy-MM-dd HH:mm" }}` | _(depends on data)_ |
**Signatures:**
```typescript
now(format: string, offsetAmount?: number, offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds")
date(date: string, format: string, offsetAmount?: number, offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds")
```
## Custom Helpers
You can register custom Handlebars helpers via the `helpers` config option:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
helpers: {
shout: (text) => text.toUpperCase() + "!!!",
},
},
}
```
Then use in templates: `{{ shout name }}`.
All helpers (built-in and custom) are also available as values for `subdirHelper` (`--subdir-helper`
/ `-H`).
For more on Handlebars helpers, see the
[Handlebars.js docs](https://handlebarsjs.com/guide/#custom-helpers).

View File

@@ -0,0 +1,205 @@
---
title: Configuration Files
---
# Configuration Files
Config files let you define reusable scaffold definitions — template paths, output directories,
custom data, inputs, and hooks — all in one place.
## Creating a Config File
The fastest way is to run `init`:
```sh
npx simple-scaffold init
```
This creates a `scaffold.config.js` and an example template in `templates/default/`. See
[`init` command](cli#init) for options.
### Config Structure
A config file exports an object mapping **template keys** to scaffold configurations:
```js
// scaffold.config.js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
},
page: {
templates: ["templates/page"],
output: "src/pages",
subdir: true,
},
}
```
For the full list of options, see [ScaffoldConfig](../api/interfaces/ScaffoldConfig) or the
[Node.js API](node) page.
### Dynamic Configs
JS config files can export a **function** that receives the CLI config and returns the scaffold map.
This lets you pre-process arguments or add logic:
```js
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
module.exports = (config) => ({
component: {
templates: ["templates/component"],
output: "src/components",
data: { timestamp: Date.now() },
},
})
```
The function can also be `async`.
### Supported Formats
- `.js` (CommonJS)
- `.cjs` (CommonJS, explicit)
- `.mjs` (ESM)
- `.json`
:::note The correct extension may depend on your `package.json` `"type"` field. Packages with
`"type": "module"` may require `.mjs` or `.cjs` instead of `.js`. :::
## Using a Config File
### Auto-detection
Simple Scaffold automatically searches the current directory for a config file — no `--config` flag
needed. The following names are tried in order:
1. `scaffold.config.{mjs,cjs,js,json}`
2. `scaffold.{mjs,cjs,js,json}`
3. `.scaffold.{mjs,cjs,js,json}`
```sh
# Just run from the project root — config is found automatically
npx simple-scaffold -k component MyComponent
```
### Explicit Path
Use `--config` (`-c`) to point to a specific file or directory:
```sh
npx simple-scaffold -c path/to/scaffold.config.js -k component MyComponent
```
When a directory is given, the auto-detection order above is used within that directory.
### Default Template Key
If you don't provide `--key`, the `default` key is used:
```js
module.exports = {
default: {
templates: ["templates/default"],
output: "src",
},
}
```
```sh
# Uses the "default" key — no -k needed
npx simple-scaffold MyProject
```
If multiple keys exist and no `--key` is provided, you'll be prompted to select one interactively.
### Providing a Default Name
If your template doesn't need a dynamic name (e.g. common config files), set `name` in the config:
```js
module.exports = {
eslint: {
name: ".eslintrc",
templates: ["templates/eslint"],
output: ".",
},
}
```
The name can still be overridden with `--name` on the command line.
## Inputs
Inputs define custom fields that are prompted interactively and become template data variables:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private?", default: false },
port: { type: "number", message: "Port", default: 3000 },
},
},
}
```
Use them in templates as `{{ author }}`, `{{ license }}`, `{{ private }}`, `{{ port }}`.
**Input types:**
| Type | Description | Value type |
| --------- | ------------------------------ | ---------- |
| `text` | Free-form text input (default) | `string` |
| `select` | Choose from a list of options | `string` |
| `confirm` | Yes/no prompt | `boolean` |
| `number` | Numeric input | `number` |
**Behavior:**
- **Required** inputs are prompted if not provided via `--data` or `-D`
- **Select and confirm** inputs are always prompted unless pre-provided
- **Optional** inputs with a `default` use that value silently
- In non-interactive environments (CI, piped input), only defaults are applied
Pre-fill inputs from the CLI:
```sh
npx simple-scaffold -k component -D author=John -D license=MIT MyComponent
```
## Remote Templates (Git)
Load config files and templates from any Git repository:
```sh
# GitHub shorthand
npx simple-scaffold -g username/repo -k component MyComponent
# Full Git URL (GitLab, Bitbucket, etc.)
npx simple-scaffold -g https://gitlab.com/user/repo.git -k component MyComponent
```
When `--config` is omitted, the standard auto-detection order is used within the cloned repo. The
repository is cloned to a temporary directory and cleaned up automatically.
### From Node.js
```ts
import Scaffold from "simple-scaffold"
const scaffold = await Scaffold.fromConfig(
"https://github.com/user/repo.git", // or a local file path
{ name: "MyComponent", key: "component" },
)
await scaffold.run()
```

162
docs/docs/usage/03-cli.md Normal file
View File

@@ -0,0 +1,162 @@
---
title: CLI
---
# CLI
```sh
npx simple-scaffold [options] [name]
```
Use `--help` (`-h`) to see all available options at any time.
## Commands
| Command | Description |
| ------------- | ----------------------------------------- |
| _(default)_ | Generate files from a template |
| `init` | Create a config file and example template |
| `list` / `ls` | List available template keys in a config |
### `init`
Scaffolds a config file and example template directory to get you started:
```sh
npx simple-scaffold init
npx simple-scaffold init --format mjs
npx simple-scaffold init --dir packages/my-lib
```
| Option | Description |
| ----------------- | ----------------------------------------------------------------- |
| `--dir` / `-d` | Directory to create the config in (defaults to current directory) |
| `--format` / `-f` | Config format: `js`, `mjs`, or `json` (prompts if omitted) |
Creates:
- A config file (`scaffold.config.js` by default) with a `default` template key
- A `templates/default/` directory with an example `{{name}}.md` template
Existing files are never overwritten.
### `list`
Lists all template keys defined in a config file:
```sh
npx simple-scaffold list
npx simple-scaffold list -c path/to/config.js
npx simple-scaffold list -g username/repo
```
## Options
| Flag | Short | Description | Default |
| -------------------------------- | --------- | ----------------------------------------------------- | --------------- |
| `--name` | `-n` | Name for generated files (can also be positional arg) | |
| `--config` | `-c` | Path to config file or directory | _(auto-detect)_ |
| `--git` | `-g` | Git URL or GitHub shorthand (`user/repo`) | |
| `--key` | `-k` | Template key from config | `default` |
| `--output` | `-o` | Output directory | |
| `--templates` | `-t` | Template paths or glob patterns (repeatable) | |
| `--data` | `-d` | Custom data as JSON string | |
| `--append-data` | `-D` | Key-value data (`key=string`, `key:=raw`), repeatable | |
| `--subdir` / `--no-subdir` | `-s`/`-S` | Create parent directory with the input name | `false` |
| `--subdir-helper` | `-H` | Helper to transform subdir name | |
| `--overwrite` / `--no-overwrite` | `-w`/`-W` | Overwrite existing files | `false` |
| `--dry-run` | `-dr` | Preview output without writing files | `false` |
| `--before-write` | `-B` | Command to run before each file is written | |
| `--after-scaffold` | `-A` | Shell command to run after all files are written | |
| `--quiet` | `-q` | Suppress output (same as `--log-level none`) | |
| `--log-level` | `-l` | Log level: `none`, `debug`, `info`, `warn`, `error` | `info` |
| `--version` | `-v` | Show version | |
| `--help` | `-h` | Show help | |
## Interactive Mode
When running in a terminal (TTY), Simple Scaffold prompts for any missing required values:
- **Name** — if `--name` is not provided
- **Template key** — if `--key` is not provided and the config has multiple templates
- **Output directory** — if `--output` is not provided
- **Template paths** — if `--templates` is not provided (comma-separated)
[Inputs](configuration_files#inputs) defined in config files are also prompted interactively.
In non-interactive environments (CI, piped input), missing required values cause an error.
## Hooks
### Before Write
Runs a command before each file is written. The command receives a temporary file path and should
output the final content to stdout.
```sh
# Appends file path automatically
npx simple-scaffold -c . --before-write prettier
# Use tokens for explicit control
npx simple-scaffold -c . --before-write 'cat {{path}} | my-linter'
```
**Tokens:**
- `{{path}}` — temporary file path with Handlebars-processed contents
- `{{rawpath}}` — temporary file path with raw (unprocessed) contents
If no tokens are found, `{{path}}` is appended automatically. Returning an empty string (after
trimming) discards the result and writes the original contents.
### After Scaffold
Runs a shell command after all files are written. The command executes in the output directory:
```sh
npx simple-scaffold -c . --after-scaffold 'npm install'
npx simple-scaffold -c . --after-scaffold 'git init && git add .'
```
See the [Node.js API](node#after-scaffold-hook) for the function-based equivalent.
## CLI Examples
```sh
# Use auto-detected config, default key
npx simple-scaffold MyProject
# Specify config and key
npx simple-scaffold -c scaffold.config.js -k component MyComponent
# GitHub remote template
npx simple-scaffold -g username/repo -k component MyComponent
# Full Git URL
npx simple-scaffold -g https://gitlab.com/user/repo.git -k component MyComponent
# One-off (no config file)
npx simple-scaffold -t templates/component -o src/components MyComponent
# With custom data
npx simple-scaffold -k component -D author=John -D license:='"MIT"' MyComponent
# Dry run
npx simple-scaffold -k component --dry-run MyComponent
```
### package.json Scripts
```json
{
"scripts": {
"scaffold": "simple-scaffold -k component",
"scaffold:page": "simple-scaffold -k page"
}
}
```
```sh
npm run scaffold -- MyComponent
npm run scaffold:page -- Dashboard
```

208
docs/docs/usage/04-node.md Normal file
View File

@@ -0,0 +1,208 @@
---
title: Node.js API
---
# Node.js API
Use Simple Scaffold programmatically for more complex workflows, custom logic, or integration into
build tools.
## Basic Usage
```typescript
import Scaffold from "simple-scaffold"
const scaffold = new Scaffold({
name: "MyComponent",
templates: ["templates/component"],
output: "src/components",
})
await scaffold.run()
```
## Loading from a Config File
```typescript
import Scaffold from "simple-scaffold"
const scaffold = await Scaffold.fromConfig(
"scaffold.config.js", // local path or HTTPS Git URL
{ name: "MyComponent", key: "component" },
{
/* optional config overrides */
},
)
await scaffold.run()
```
## Config Interface
```typescript
interface ScaffoldConfig {
name: string
templates: string[]
output: FileResponse<string>
subdir?: boolean
subdirHelper?: string
data?: Record<string, unknown>
overwrite?: FileResponse<boolean>
logLevel?: "none" | "debug" | "info" | "warn" | "error"
dryRun?: boolean
helpers?: Record<string, Helper>
inputs?: Record<string, ScaffoldInput>
beforeWrite?(
content: Buffer,
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
afterScaffold?: AfterScaffoldHook
}
```
For the full API reference, see [ScaffoldConfig](../api/interfaces/ScaffoldConfig).
### Options
| Option | Type | Description |
| --------------- | ------------------------------- | ------------------------------------------------------------------------ |
| `name` | `string` | Name for generated files _(required)_ |
| `templates` | `string[]` | Template paths or globs; prefix with `!` to exclude _(required)_ |
| `output` | `string \| Function` | Output directory, or a function for per-file control |
| `data` | `Record<string, unknown>` | Custom data available in templates |
| `inputs` | `Record<string, ScaffoldInput>` | Interactive input definitions (see [Inputs](configuration_files#inputs)) |
| `helpers` | `Record<string, Function>` | Custom Handlebars helpers |
| `subdir` | `boolean` | Create a parent directory with the input name (default: `false`) |
| `subdirHelper` | `string` | Helper to transform the subdir name (e.g. `"pascalCase"`) |
| `overwrite` | `boolean \| Function` | Overwrite existing files (default: `false`); function for per-file logic |
| `dryRun` | `boolean` | Preview without writing files (default: `false`) |
| `logLevel` | `string` | Log verbosity (default: `"info"`) |
| `beforeWrite` | `Function` | Async hook before each file is written |
| `afterScaffold` | `Function \| string` | Hook after all files are written |
## Hooks
### Before Write
Runs before each file is written. Return a `string` or `Buffer` to replace the file contents, or
`undefined` to keep the default (Handlebars-processed) output.
```typescript
await new Scaffold({
name: "MyComponent",
templates: ["templates/component"],
output: "src/components",
beforeWrite: async (content, rawContent, outputPath) => {
// Format the output, transform it, or return undefined to keep as-is
return content.toString().trim()
},
}).run()
```
### After Scaffold Hook
Runs after all files have been written. Pass a **function** for full control, or a **string** to run
a shell command in the output directory.
**Function:**
```typescript
await new Scaffold({
name: "my-app",
templates: ["templates/app"],
output: ".",
afterScaffold: async ({ config, files }) => {
console.log(`Created ${files.length} files`)
// e.g. run npm install, git init, open editor, etc.
},
}).run()
```
**Shell command:**
```typescript
await new Scaffold({
name: "my-app",
templates: ["templates/app"],
output: "my-app",
afterScaffold: "npm install && git init",
}).run()
```
The context object:
```typescript
interface AfterScaffoldContext {
config: ScaffoldConfig
files: string[] // absolute paths of written files
}
```
In dry-run mode, the hook is still called but the `files` array will be empty.
## Inputs
Define interactive prompts that merge into template data:
```typescript
await new Scaffold({
name: "component",
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private package?", default: false },
port: { type: "number", message: "Dev server port", default: 3000 },
},
}).run()
// In templates: {{ author }}, {{ license }}, {{ private }}, {{ port }}
```
```typescript
interface ScaffoldInput {
type?: "text" | "select" | "confirm" | "number"
message?: string
required?: boolean
default?: string | boolean | number
options?: (string | { name: string; value: string })[] // for type: "select"
}
```
- **Required** inputs are prompted if not already in `data`
- **Optional** inputs with a `default` are applied silently
- Pre-providing values in `data` skips the prompt for that input
## Full Example
```typescript
import path from "path"
import Scaffold from "simple-scaffold"
await new Scaffold({
name: "MyComponent",
templates: [path.join(__dirname, "scaffolds", "component")],
output: path.join(__dirname, "src", "components"),
subdir: true,
subdirHelper: "pascalCase",
data: {
property: "value",
},
helpers: {
twice: (text) => [text, text].join(" "),
},
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
beforeWrite: (content, rawContent, outputPath) => {
return content.toString().toUpperCase()
},
afterScaffold: async ({ config, files }) => {
console.log(`Created ${files.length} files in ${config.output}`)
},
}).run()
```

View File

@@ -0,0 +1,186 @@
---
title: Examples
---
# Examples
## React Component
### Template
**File:** `templates/component/{{pascalCase name}}.tsx`
```tsx
/**
* Author: {{ author }}
* Date: {{ now "yyyy-MM-dd" }}
*/
import React from "react"
export default {{ pascalCase name }}: React.FC = (props) => {
return (
<div className="{{ camelCase name }}">{{ pascalCase name }} Component</div>
)
}
```
### Config
```js
// scaffold.config.js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
data: {
author: "My Name",
},
},
}
```
### Running
```sh
npx simple-scaffold -k component MyComponent
```
### Output
**File:** `src/components/MyComponent.tsx`
```tsx
/**
* Author: My Name
* Date: 2077-01-01
*/
import React from "react"
export default MyComponent: React.FC = (props) => {
return (
<div className="myComponent">MyComponent Component</div>
)
}
```
## Subdir Variations
Given the template and config above, the output path changes based on `subdir` settings:
| Setting | Output path |
| ------------------------------------------- | -------------------------------------------- |
| `subdir: false` (default) | `src/components/MyComponent.tsx` |
| `subdir: true` | `src/components/MyComponent/MyComponent.tsx` |
| `subdir: true`, `subdirHelper: "upperCase"` | `src/components/MYCOMPONENT/MyComponent.tsx` |
## CLI One-liner (No Config)
```sh
npx simple-scaffold \
-t templates/component/**/* \
-o src/components \
-d '{"author": "My Name"}' \
MyComponent
```
## Node.js Equivalent
```typescript
import Scaffold from "simple-scaffold"
await new Scaffold({
name: "MyComponent",
templates: ["templates/component"],
output: "src/components",
data: {
author: "My Name",
},
}).run()
```
## Reusable Config Files
### CommonJS (`scaffold.config.js`)
```js
module.exports = {
default: {
templates: ["templates/component"],
output: "src/components",
},
}
```
### ESM (`scaffold.config.mjs`)
```js
export default {
default: {
templates: ["templates/component"],
output: "src/components",
},
}
```
### Dynamic Config (with function)
```js
module.exports = (config) => ({
default: {
templates: ["templates/component"],
output: "src/components",
data: {
generatedAt: new Date().toISOString(),
},
},
})
```
## With Inputs
```js
// scaffold.config.js
module.exports = {
package: {
templates: ["templates/package"],
output: "packages",
subdir: true,
inputs: {
description: { message: "Package description", required: true },
author: { message: "Author", default: "Team" },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "ISC"],
},
private: { type: "confirm", message: "Private package?", default: true },
},
},
}
```
```sh
# Interactive — prompts for each input
npx simple-scaffold -k package my-lib
# Non-interactive — provide all values upfront
npx simple-scaffold -k package -D description="A utility library" -D author=John my-lib
```
## With Hooks
```js
module.exports = {
app: {
templates: ["templates/app"],
output: ".",
subdir: true,
afterScaffold: "cd {{name}} && npm install && git init",
},
}
```
```sh
npx simple-scaffold -k app my-app
# Files are generated, then npm install and git init run automatically
```

View File

@@ -0,0 +1,64 @@
---
title: Migration
---
# Migration
## v1.x to v2.x
### CLI Changes
**Remote config syntax:**
- The `:template_key` suffix syntax has been removed. Use `-k template_key` instead.
- `--github` (`-gh`) is now `--git` (`-g`) and supports any Git URL. GitHub shorthand still works:
`-g username/project`.
- The `#template_file` suffix syntax has been removed. Use `--config` (`-c`) to specify which file
to look for inside the Git project.
**Renamed flags:**
| v1.x | v2.x |
| ---------------------------------- | -------------------------------------------------------- |
| `--create-sub-folder` / `-s` | `--subdir` / `-s` |
| `--sub-folder-name-helper` / `-sh` | `--subdir-helper` / `-H` |
| `--verbose` (true/false) | `--log-level` (`debug`, `info`, `warn`, `error`, `none`) |
**Boolean flags** no longer take a value. Use `-q` instead of `-q true`, `-s` instead of `-s 1`,
etc.
### Behavior Changes
**`{{ Name }}` removed.** The auto-populated `Name` (PascalCase) variable is gone. Use
`{{ pascalCase name }}` in your templates instead. If you need the old behavior, inject it manually
via `data`:
```js
module.exports = {
default: {
templates: ["templates/default"],
output: "src",
data: { Name: "{{ pascalCase name }}" }, // or set it programmatically
},
}
```
## v0.x to v1.x
In v1.0, the codebase was overhauled but usage remained mostly the same.
### API Changes
| v0.x | v1.x |
| -------------------------------- | ------------------------------------------ |
| `new SimpleScaffold(opts).run()` | `SimpleScaffold(opts)` (returns a Promise) |
| `locals` option | `data` option |
| `--locals` / `-l` flag | `--data` / `-d` flag |
### Template Syntax
Templates still use Handlebars.js. v1.x added **built-in helpers** (case transformations, date
formatting), removing the need to pre-process template data for common operations like `camelCase`,
`snakeCase`, etc.
See [Templates](templates#built-in-helpers) for the full list of available helpers.

View File

@@ -1,90 +0,0 @@
---
title: CLI Usage
---
## Available flags
```text
Usage: simple-scaffold [options]
```
To see this and more information anytime, add the `-h` or `--help` flag to your call, e.g.
`npx simple-scaffold@latest -h`.
| Command \| alias | |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--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. |
| `--config`\|`-c` | Filename to load config from instead of passing arguments to CLI or using a Node.js script. See examples for syntax. This can also work in conjunction with `--git` or `--github` to point to remote files, and with `--key` to denote which key to select from the file., |
| `--git`\|`-g` | Git URL to load config from instead of passing arguments to CLI or using a Node.js script. See examples for syntax. |
| `--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`) |
| `--output` \| `-o` | Path to output to. If `--create-sub-folder` is enabled, the subfolder will be created inside this path. Default is current working directory. |
| `--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. |
| `--overwrite` \| `-w` | Enable to override output files, even if they already exist. |
| `--data` \| `-d` | Add custom data to the templates. By default, only your app name is included. |
| `--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` |
| `--create-sub-folder` \| `-s` | Create subfolder with the input name |
| `--sub-folder-name-helper` \| `-sh` | Default helper to apply to subfolder name when using `--create-sub-folder true`. |
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`) |
| `--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. |
| `--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. |
| `--help` \| `-h` | Show this help message |
| `--version` \| `-v` | Display version. |
## Examples:
> See
> [Configuration Files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files)
> for organizing multiple scaffold types into easy-to-maintain files
Usage with config file
```shell
$ simple-scaffold -c scaffold.cmd.js -k component MyComponent
```
Usage with GitHub config file
```shell
$ simple-scaffold -g chenasraf/simple-scaffold -k component MyComponent
```
Usage with https git URL (for non-GitHub)
```shell
$ simple-scaffold \
-g https://example.com/user/template.git \
-c scaffold.cmd.js \
-k component \
MyComponent
```
Full syntax with config path and template key (applicable to all above methods)
```shell
$ simple-scaffold -c scaffold.cmd.js -k component MyComponent
```
Excluded template key, assumes 'default' key
```shell
$ simple-scaffold -c scaffold.cmd.js MyComponent
```
Shortest syntax for GitHub, assumes file 'scaffold.cmd.js' and template key 'default'
```shell
$ simple-scaffold -g chenasraf/simple-scaffold MyComponent
```
You can also add this as a script in your `package.json`:
```json
{
"scripts": {
"scaffold-cfg": "npx simple-scaffold -c scaffold.cmd.js -k component",
"scaffold-gh": "npx simple-scaffold -g chenasraf/simple-scaffold -k component",
"scaffold": "npx simple-scaffold@latest -t scaffolds/component/**/* -o src/components -d '{\"myProp\": \"propName\", \"myVal\": 123}'"
"scaffold-component": "npx simple-scaffold -c scaffold.cmd.js -k"
}
}
```

View File

@@ -1,184 +0,0 @@
---
title: Configuration Files
---
If you want to have reusable configurations which are complex and don't fit into command lines
easily, or just want to manage your templates easier, you can use configuration files to load your
scaffolding configurations.
## Creating config files
Configuration files should be valid `.js`/`.mjs`/`.cjs`/`.json` files that contain valid Scaffold
configurations.
Each file hold multiple scaffolds. Each scaffold is a key, and its value is the configuration. For
example:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
},
}
```
The configuration contents are identical to the
[Node.js configuration structure](https://chenasraf.github.io/simple-scaffold/docs/usage/node):
```ts
interface ScaffoldConfig {
name: string
templates: string[]
output: FileResponse<string>
subdir?: boolean
git?: string
config?: string
key?: string
data?: Record<string, any>
overwrite?: FileResponse<boolean>
quiet?: boolean
verbose?: LogLevel
dryRun?: boolean
helpers?: Record<string, Helper>
subdirHelper?: DefaultHelpers | string
beforeWrite?(
content: Buffer,
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
}
```
If you want to supply functions inside the configurations, you must use a `.js`/`.cjs`/`.mjs` file
as JSON does not support non-primitives.
A `.js` file can be just like a `.json` file, make sure to export the final configuration:
```js
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
},
}
```
Another feature of using a JS file is you can export a function which will be loaded with the CMD
config provided to Simple Scaffold. The `extra` key contains any values not consumed by built-in
flags, so you can pre-process your args before outputting a config:
```js
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
module.exports = (config) => {
console.log("Config:", config)
return {
component: {
templates: ["templates/component"],
output: "src/components",
},
}
}
```
## Using a config file
Once your config is created, you can use it by providing the file name to the `--config` (or `-c`
for brevity), optionally alongside `--key` or `-k`, denoting the key to use as the config object, as
you define in your config:
```sh
simple-scaffold -c <file> -k <template_key>
```
For example:
```sh
simple-scaffold -c scaffold.json -k component MyComponentName
```
If you don't want to supply a template/config name (e.g. `component`), `default` will be used:
```js
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
module.exports = {
default: {
// ...
},
}
```
And then:
```sh
# will use 'default' template
simple-scaffold -c scaffold.json MyComponentName
```
- When the filename is omitted, the following files will be tried in order:
- `scaffold.config.*`
- `scaffold.*`
Where `*` denotes any supported file extension, in the priority listed in
[Supported file types](#supported-file-types)
- When the `template_key` is ommitted, `default` will be used as default.
### Supported file types
Any importable file is supported, depending on your build process.
Common files include:
- `*.mjs`
- `*.cjs`
- `*.js`
- `*.json`
When filenames are ommited when loading configs, these are the file extensions that will be
automatically tried, by the specified order of priority.
Note that you might need to find the correct extension of `.js`, `.cjs` or `.mjs` depending on your
build process and your package type (for example, packages with `"type": "module"` in their
`package.json` might be required to use `.mjs`.)
### Git/GitHub Templates
You may specify a git or GitHub url to use remote templates.
The command line option is `--git` or `-g`.
- You may specify a full git or HTTPS git URL, which will be tried
- You may specify a git username and project if the project is on GitHub
```sh
# GitHub shorthand
simple-scaffold -g <username>/<project_name> [-c <filename>] [-k <template_key>]
# Any git URL, git:// and https:// are supported
simple-scaffold -g git://gitlab.com/<username>/<project_name> [-c <filename>] [-k <template_key>]
simple-scaffold -g https://gitlab.com/<username>/<project_name>.git [-c <filename>] [-k <template_key>]
```
## Use In Node.js
You can also start a scaffold from Node.js with a remote file or URL config.
Just use the `Scaffold.fromConfig` function:
```ts
Scaffold.fromConfig(
"scaffold.config.js", // file or HTTPS git URL
{
// name of the generated component
name: "My Component",
// key to load from the config
key: "component",
},
{
// other config overrides
},
)
```

View File

@@ -1,141 +0,0 @@
---
title: Examples
---
## Example files
### Input
- Input file path:
```text
project → scaffold → {{Name}}.js → src → components
```
- Input file contents:
```typescript
/**
* Author: {{ author }}
* Date: {{ now "yyyy-MM-dd" }}
*/
import React from 'react'
export default {{camelCase name}}: React.FC = (props) => {
return (
<div className="{{className}}">{{camelCase name}} Component</div>
)
}
```
### Output
- Output file path:
- With `subdir = false` (default):
```text
project → src → components → MyComponent.js
```
- With `subdir = true`:
```text
project → src → components → MyComponent → MyComponent.js
```
- With `subdir = true` and `subdirHelper = 'upperCase'`:
```text
project → src → components → MYCOMPONENT → MyComponent.js
```
- Output file contents:
```typescript
/**
* Author: My Name
* Date: 2077-01-01
*/
import React from 'react'
export default MyComponent: React.FC = (props) => {
return (
<div className="myClassName">MyComponent Component</div>
)
}
```
## Example run commands
### Command Example
```bash
simple-scaffold \
-t project/scaffold/**/* \
-o src/components \
-d '{"className": "myClassName","author": "My Name"}'
MyComponent
```
### Equivalent Node Module Example
```typescript
import Scaffold from "simple-scaffold"
async function main() {
await Scaffold({
name: "MyComponent",
templates: ["project/scaffold/**/*"],
output: ["src/components"],
data: {
className: "myClassName",
author: "My Name",
},
})
console.log("Done.")
}
```
### Re-usable config
#### Shell
```bash
# cjs
simple-scaffold -c scaffold.cjs MyComponent \
-d '{"className": "myClassName","author": "My Name"}'
# mjs
simple-scaffold -c scaffold.mjs MyComponent \
-d '{"className": "myClassName","author": "My Name"}'
```
#### scaffold.cjs
```js
module.exports = (config) => ({
default: {
templates: ["project/scaffold/**/*"],
output: ["src/components"],
data: {
className: "myClassName",
author: "My Name",
},
},
})
```
#### scaffold.mjs
```js
export default (config) => ({
default: {
templates: ["project/scaffold/**/*"],
output: ["src/components"],
data: {
className: "myClassName",
author: "My Name",
},
},
})
```

View File

@@ -1,10 +1,18 @@
---
title: Usage
sidebar_position: 0
---
- [CLI Usage](cli)
- [Configuration Files](configuration_files)
- [Examples](examples)
- [Migration](migration)
- [Node.js Usage](node)
- [Template Files](templates)
# Usage
Simple Scaffold can be used as a **CLI tool** or as a **Node.js library**. Both approaches share the
same core concepts: templates, configuration files, and Handlebars-based token replacement.
- [Templates](./usage/templates) — how template files and directories work, built-in helpers, and
custom helpers
- [Configuration Files](./usage/configuration_files) — reusable scaffold definitions,
auto-detection, inputs, and remote Git templates
- [CLI](./usage/cli) — all commands, flags, interactive mode, and hooks
- [Node.js API](./usage/node) — programmatic usage, full config interface, and hooks
- [Examples](./usage/examples) — end-to-end examples for CLI and Node.js
- [Migration](./usage/migration) — upgrading from v1.x or v0.x to the latest version

View File

@@ -1,61 +0,0 @@
---
title: Migration
---
## v1.x to v2.x
### CLI option changes
- Several changes to how remote configs are loaded via CLI:
- The `:template_key` syntax has been removed. You can still use `-k template_key` to achieve the
same result.
- The `--github` (`-gh`) flag has been replaced by a generic `--git` (`-g`) one, which handles any
git URL. Providing a partial GitHub path will default to trying to find the project on GitHub,
e.g. `-g username/project`
- The `#template_file` syntax has been removed, you may use `--config` or `-c` to tell Simple
Scaffold which file to look for inside the git project. There is a default file priority list
which can find the file for you if it is in one of the supported filenames.
- `verbose` can now take the names `debug`, `info`, `warn`, `error` or `none` (case insensitive).
- `--create-sub-folder` (`-s`) has been renamed to `--subdir` (`-s`) in the CLI. The Node.js names
have been changed as well.
- `--sub-folder-name-helper` (`-sh`) has been renamed to `--subdir-helper` (`-sh`). The Node.js
names have been changed as well.
- All boolean flags no longer take a value. `-q` instead of `-q 1` or `-q true`, `-s` instead of
`-s 1`, `-w` instead of `-w 1`, etc.
### Behavior changes
- Data is no longer auto-populated with `Name` (PascalCase) by default. You can just use the helper
in your templates contents and file names, simply use `{{ pascalCase name }}` instead of
`{{ Name }}`. `Name` was arbitrary and it is confusing (is it `Title Case`? `PascalCase`? only
reading the docs can tell). Alternatively, you can inject the transformed name into your `data`
manually using a scaffold config file, by using the Node API or by appending the data to the CLI
invocation.
## v0.x to v1.x
In Simple Scaffold v1.0, the entire codebase was overhauled, yet usage remains mostly the same
between versions. With these notable exceptions:
- Some of the argument names have changed
- Template syntax has been improved
- The command to run Scaffold has been simplified from `new SimpleScaffold(opts).run()` to
`SimpleScaffold(opts)`, which now returns a promise that you can await to know when the process
has been completed.
### Argument changes
- `locals` has been renamed to `data`. The appropriate command line args have been updated as well
to `--data` | `-d`.
- Additional options have been added to both CLI and Node interfaces. See
[Command Line Interface (CLI) usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli)
and [Node.js usage](https://chenasraf.github.io/simple-scaffold/docs/usage/node) for more
information.
### Template syntax changes
Simple Scaffold still uses Handlebars.js to handle template content and file names. However, helpers
have been added to remove the need for you to pre-process the template data on simple use-cases such
as case type manipulation (converting to camel case, snake case, etc)
See the readme for the full information on how to use these helpers and which are available.

View File

@@ -1,57 +0,0 @@
---
title: Node.js Usage
---
You can build the scaffold yourself, if you want to create more complex arguments, scaffold groups,
etc - simply pass a config object to the Scaffold function when you are ready to start.
The config takes similar arguments to the command line. The full type definitions can be found in
[src/types.ts](https://github.com/chenasraf/simple-scaffold/blob/develop/src/types.ts#L13).
See the full
[documentation](https://chenasraf.github.io/simple-scaffold/interfaces/ScaffoldConfig.html) for the
configuration options and their behavior.
```ts
interface ScaffoldConfig {
name: string
templates: string[]
output: FileResponse<string>
createSubFolder?: boolean
data?: Record<string, any>
overwrite?: FileResponse<boolean>
quiet?: boolean
verbose?: LogLevel
dryRun?: boolean
helpers?: Record<string, Helper>
subFolderNameHelper?: DefaultHelpers | string
beforeWrite?(
content: Buffer,
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
}
```
This is an example of loading a complete scaffold via Node.js:
```typescript
import Scaffold from "simple-scaffold"
const config = {
name: "component",
templates: [path.join(__dirname, "scaffolds", "component")],
output: path.join(__dirname, "src", "components"),
createSubFolder: true,
subFolderNameHelper: "upperCase"
data: {
property: "value",
},
helpers: {
twice: (text) => [text, text].join(" ")
},
beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase()
}
const scaffold = Scaffold(config)
```

View File

@@ -1,131 +0,0 @@
---
title: Template Files
---
# Preparing template files
Put your template files anywhere, and fill them with tokens for replacement.
Each template (not file) in the config array is parsed individually, and copied to the output
directory. If a single template path contains multiple files (e.g. if you use a folder path or a
glob pattern), the first directory up the tree of that template will become the base inside the
defined output path for that template, while copying files recursively and maintaining their
relative structure.
Examples:
> In the following examples, the config `name` is `AppName`, and the config `output` is `src`.
| Input template | Files in template | Output path(s) |
| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| `./templates/{{ name }}.txt` | `./templates/{{ name }}.txt` | `src/AppName.txt` |
| `./templates/directory` | `outer/{{name}}.txt`,<br />`outer2/inner/{{name}}.txt` | `src/outer/AppName.txt`,<br />`src/outer2/inner/AppName.txt` |
| `./templates/others/**/*.txt` | `outer/{{name}}.jpg`,<br />`outer2/inner/{{name}}.txt` | `src/outer2/inner/AppName.txt` |
## Variable/token replacement
Scaffolding will replace `{{ varName }}` in both the file name and its contents and put the
transformed files in the output directory.
The data available for the template parser is the data you pass to the `data` config option (or
`--data` argument in CLI).
For example, using the following command:
```bash
npx simple-scaffold@latest \
--templates templates/components/{{name}}.jsx \
--output src/components \
--create-sub-folder true \
MyComponent
```
Will output a file with the path:
```text
<working_dir>/src/components/MyComponent.jsx
```
The contents of the file will be transformed in a similar fashion.
Your `data` will be pre-populated with the following:
- `{{Name}}`: PascalCase of the component name
- `{{name}}`: raw name of the component as you entered it
> Simple-Scaffold uses [Handlebars.js](https://handlebarsjs.com/) for outputting the file contents.
> Any `data` you add in the config will be available for use with their names wrapped in `{{` and
> `}}`. Other Handlebars built-ins such as `each`, `if` and `with` are also supported, see
> [Handlebars.js Language Features](https://handlebarsjs.com/guide/#language-features) for more
> information.
## Helpers
### Built-in Helpers
Simple-Scaffold provides some built-in text transformation filters usable by Handlebars.
For example, you may use `{{ snakeCase name }}` inside a template file or filename, and it will
replace `My Name` with `my_name` when producing the final value.
#### Capitalization Helpers
| Helper name | Example code | Example output |
| ------------ | ----------------------- | -------------- |
| [None] | `{{ name }}` | my name |
| `camelCase` | `{{ camelCase name }}` | myName |
| `snakeCase` | `{{ snakeCase name }}` | my_name |
| `startCase` | `{{ startCase name }}` | My Name |
| `kebabCase` | `{{ kebabCase name }}` | my-name |
| `hyphenCase` | `{{ hyphenCase name }}` | my-name |
| `pascalCase` | `{{ pascalCase name }}` | MyName |
| `upperCase` | `{{ upperCase name }}` | MY NAME |
| `lowerCase` | `{{ lowerCase name }}` | my name |
#### Date helpers
| Helper name | Description | Example code | Example output |
| -------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ |
| `now` | Current date with format | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
| `now` (with offset) | Current date with format, and with offset | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` |
| `date` | Custom date with format | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
| `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` |
| `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` |
Further details:
- We use [`date-fns`](https://date-fns.org/docs/) for parsing/manipulating the dates. If you want
more information on the date tokens to use, refer to
[their format documentation](https://date-fns.org/docs/format).
- The date helper format takes the following arguments:
```typescript
(
date: string,
format: string,
offsetAmount?: number,
offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds"
)
```
- **The now helper** (for current time) takes the same arguments, minus the first one (`date`) as it
is implicitly the current date.
### Custom Helpers
You may also add your own custom helpers using the `helpers` options when using the JS API (rather
than the CLI). The `helpers` option takes an object whose keys are helper names, and values are the
transformation functions. For example, `upperCase` is implemented like so:
```typescript
config.helpers = {
upperCase: (text) => text.toUpperCase(),
}
```
All of the above helpers (built in and custom) will also be available to you when using
`subdirHelper` (`--sub-dir-helper`/`-H`) as a possible value.
> To see more information on how helpers work and more features, see
> [Handlebars.js docs](https://handlebarsjs.com/guide/#custom-helpers).

View File

@@ -19,7 +19,12 @@ const config: Config = {
projectName: "simple-scaffold", // Usually your repo name.
onBrokenLinks: "warn",
onBrokenMarkdownLinks: "warn",
markdown: {
hooks: {
onBrokenMarkdownLinks: "warn",
},
},
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
@@ -47,8 +52,12 @@ const config: Config = {
categorizeByGroup: false,
sort: ["visibility"],
categoryOrder: ["Main", "*"],
media: "media",
entryPointStrategy: "expand",
pageTitleTemplates: {
index: "{projectName}",
member: "`{rawName}`",
module: "{name}",
},
validation: {
invalidLink: true,
},
@@ -83,7 +92,7 @@ const config: Config = {
title: "Simple Scaffold",
logo: {
alt: "Simple Scaffold",
src: "img/logo.svg",
src: "img/favicon.svg",
},
items: [
{
@@ -134,8 +143,12 @@ const config: Config = {
title: "Docs",
items: [
{
label: "Tutorial",
to: "/docs/intro",
label: "Usage",
to: "/docs/usage",
},
{
label: "API",
to: "/docs/api",
},
],
},

View File

@@ -15,23 +15,23 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/plugin-google-tag-manager": "^3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@docusaurus/core": "^3.9.2",
"@docusaurus/plugin-google-tag-manager": "^3.9.2",
"@docusaurus/preset-classic": "^3.9.2",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.1.1",
"@docusaurus/tsconfig": "3.1.1",
"@docusaurus/types": "3.1.1",
"docusaurus-plugin-typedoc": "^0.22.0",
"typedoc": "^0.25.7",
"typedoc-plugin-markdown": "^3.17.1",
"typescript": "~5.2.2"
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/tsconfig": "^3.9.2",
"@docusaurus/types": "^3.9.2",
"docusaurus-plugin-typedoc": "^1.4.2",
"typedoc": "^0.28.18",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "~6.0.2"
},
"browserslist": {
"production": [

16220
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,28 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
// docs: [{ type: "autogenerated", dirName: "." }],
usage: ["usage/index"],
api: ["api/index"],
docs: [{ type: "autogenerated", dirName: "." }],
// docs: [
// {
// type: "category",
// label: "Guides",
// link: {
// type: "generated-index",
// title: "Docusaurus Guides",
// description: "Learn about the most important Docusaurus concepts!",
// slug: "/category/docusaurus-guides",
// keywords: ["guides"],
// image: "/img/docusaurus.png",
// },
// items: ["pages", "docs", "blog", "search"],
// },
// ],
// usage: [{ type: "autogenerated", dirName: "usage" }],
// api: [{ type: "autogenerated", dirName: "api" }],
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
usage: [{ type: "autogenerated", dirName: "usage" }],
api: [
{ type: "doc", id: "api/index", label: "Overview" },
{
type: 'category',
label: 'Tutorial',
items: ['tutorial-basics/create-a-document'],
type: "category",
label: "Functions",
items: [{ type: "autogenerated", dirName: "api/functions" }],
},
{
type: "category",
label: "Types",
items: [
{ type: "autogenerated", dirName: "api/interfaces" },
{ type: "autogenerated", dirName: "api/type-aliases" },
],
},
{
type: "category",
label: "Variables",
items: [{ type: "autogenerated", dirName: "api/variables" }],
},
],
*/
}
export default sidebars

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
export default [
...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended),
{
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
{
ignores: ['node_modules/', 'build/', 'dist/', 'gen/'],
},
]

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as css from "./{{pascalCae name}}.css"
import * as css from "./{{pascalCase name}}.css"
class {{pascalCae name}} extends React.Component<any> {
class {{pascalCase name}} extends React.Component<any> {
private {{ property }}
constructor(props: any) {
@@ -10,8 +10,8 @@ class {{pascalCae name}} extends React.Component<any> {
}
public render() {
return <div className={ css.{{pascalCae name}} } />
return <div className={ css.{{pascalCase name}} } />
}
}
export default {pascalCae nName}}
export default {{pascalCase name}}

View File

@@ -1,203 +0,0 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
export default {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/q9/0mns8fgd00b4t5j5lq2wh2yh0000gn/T/jest_dx",
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ["/node_modules/", "scaffold.config.js"],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// 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.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// moduleNameMapper: {
// chalk: "<rootDir>/node_modules/chalk/source/index.js",
// "#ansi-styles": "<rootDir>/node_modules/chalk/source/vendor/ansi-styles/index.js",
// "#supports-color": "<rootDir>/node_modules/chalk/source/vendor/supports-color/index.js",
// },
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
modulePathIgnorePatterns: ["<rootDir>/dist"],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: "ts-jest",
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// transform: {
// "^.+\\.ts?$": "ts-jest",
// },
// transformIgnorePatterns: ["<rootDir>/node_modules/"],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
verbose: true,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

View File

@@ -1,8 +1,8 @@
{
"name": "simple-scaffold",
"version": "2.0.2-pre.1",
"version": "3.1.1",
"description": "Generate any file structure - from single components to entire app boilerplates, with a single command.",
"homepage": "https: //chenasraf.github.io/simple-scaffold",
"homepage": "https://chenasraf.github.io/simple-scaffold",
"repository": {
"type": "git",
"url": "https://github.com/chenasraf/simple-scaffold.git"
@@ -13,7 +13,7 @@
"bin": {
"simple-scaffold": "cmd.js"
},
"packageManager": "pnpm@8.15.1",
"packageManager": "pnpm@9.9.0",
"keywords": [
"javascript",
"cli",
@@ -26,41 +26,57 @@
"scaffolding"
],
"scripts": {
"clean": "rm -rf dist/",
"build": "pnpm clean && tsc && chmod -R +x ./dist && cp ./package.json ./README.md ./dist/",
"dev": "tsc --watch",
"start": "ts-node src/scaffold.ts",
"test": "jest",
"cmd": "ts-node src/cmd.ts",
"clean": "rimraf dist",
"build": "pnpm clean && vite build && tsc --emitDeclarationOnly && chmod -R +x ./dist && cp ./package.json ./README.md ./dist/",
"dev": "vite build --watch",
"start": "vite-node src/scaffold.ts",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage && open coverage/lcov-report/index.html",
"cmd": "vite-node src/cmd.ts",
"docs:build": "cd docs && pnpm build",
"docs:watch": "cd docs && pnpm start",
"audit-fix": "pnpm audit --fix",
"changelog": "conventional-changelog -i CHANGELOG.md -s -r 0; echo \"# Change Log\n\n$(cat CHANGELOG.md)\" > CHANGELOG.md"
"ci": "pnpm install --frozen-lockfile",
"lint": "eslint src/ tests/",
"format": "prettier --write .",
"lint-staged": "lint-staged",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,mts,js,mjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml,css}": [
"prettier --write"
]
},
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^3.3.1",
"glob": "^10.3.10",
"handlebars": "^4.7.8",
"massarg": "2.0.0"
"@inquirer/confirm": "^6.0.10",
"@inquirer/input": "^5.0.10",
"@inquirer/number": "^4.0.10",
"@inquirer/select": "^5.1.2",
"date-fns": "^4.1.0",
"glob": "^13.0.6",
"handlebars": "^4.7.9",
"massarg": "2.1.1",
"minimatch": "^10.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/release-notes-generator": "^12.1.0",
"@types/jest": "^29.5.11",
"@types/mock-fs": "^4.13.4",
"@types/node": "^20.11.14",
"@types/semantic-release": "^20.0.6",
"conventional-changelog": "^5.1.0",
"conventional-changelog-cli": "^4.1.0",
"jest": "^29.7.0",
"mock-fs": "^5.2.0",
"semantic-release": "^23.0.0",
"semantic-release-conventional-commits": "^3.0.0",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
"@eslint/js": "^10.0.1",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.2",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"mock-fs": "^5.5.0",
"prettier": "^3.8.1",
"rimraf": "^6.1.3",
"typescript": "^6.0.2",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.3",
"vite-node": "^6.0.0",
"vitest": "^4.1.2"
}
}

6304
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +0,0 @@
const ref = process.env.GITHUB_REF || ""
const branch = ref.split("/").pop()
/**
* @type {import('semantic-release').GlobalConfig}
*/
module.exports = {
branches: ["master", { name: "pre", prerelease: true }],
plugins: [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/npm",
{
// only update the pkg version on root, don't publish
npmPublish: false,
},
],
// [
// '@semantic-release/npm',
// {
// // only update the pkg version on doc, don't publish
// npmPublish: false,
// pkgRoot: 'doc',
// },
// ]
[
"@semantic-release/exec",
{
publish: "cd ./dist && pnpm pack --pack-destination=../",
},
],
[
"@semantic-release/npm",
{
// publish from dist dir instead of root
pkgRoot: "dist",
},
],
[
"@semantic-release/github",
{
assets: ["*.tgz"],
},
],
branch === "master"
? [
"@semantic-release/changelog",
{
changelogFile: "CHANGELOG.md",
changelogTitle: "# Change Log",
},
]
: undefined,
[
"@semantic-release/git",
{
assets: ["package.json", "CHANGELOG.md"].filter(Boolean),
},
],
//
// [
// '@semantic-release/exec',
// {
// verifyReleaseCmd: 'echo ${nextRelease.version} > .VERSION',
// },
// ],
].filter(Boolean),
}

22
scaffold.config.cjs Normal file
View File

@@ -0,0 +1,22 @@
// @ts-check
/** @type {import('./dist').ScaffoldConfigFile} */
module.exports = () => {
// console.log("Config:", conf)
return {
default: {
templates: ["examples/test-input/Component"],
output: "examples/test-output",
data: { property: "myProp", value: "10" },
},
component: {
templates: ["examples/test-input/Component"],
output: "examples/test-output/component",
data: { property: "myProp", value: "10" },
},
configs: {
templates: ["examples/test-input/**/.*"],
output: "examples/test-output/configs",
name: "---",
},
}
}

86
src/before-write.ts Normal file
View File

@@ -0,0 +1,86 @@
import path from "node:path"
import fs from "node:fs/promises"
import { exec } from "node:child_process"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { log } from "./logger"
import { createDirIfNotExists, getUniqueTmpPath } from "./fs-utils"
/**
* Wraps a CLI beforeWrite command string into a beforeWrite callback function.
* The command receives the processed content via a temp file and can return modified content via stdout.
* @internal
*/
export function wrapBeforeWrite(
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
beforeWrite: string,
): ScaffoldConfig["beforeWrite"] {
return async (content, rawContent, outputFile) => {
const tmpDir = path.join(getUniqueTmpPath(), path.basename(outputFile))
await createDirIfNotExists(path.dirname(tmpDir), config)
const ext = path.extname(outputFile)
const rawTmpPath = tmpDir.replace(ext, ".raw" + ext)
try {
log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite)
const cmd = await prepareBeforeWriteCmd({
beforeWrite,
tmpDir,
content,
rawTmpPath,
rawContent,
})
const result = await new Promise<string | undefined>((resolve, reject) => {
log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd)
const proc = exec(cmd)
proc.stdout!.on("data", (data) => {
if (data.trim()) {
resolve(data.toString())
} else {
resolve(undefined)
}
})
proc.stderr!.on("data", (data) => {
reject(data.toString())
})
})
return result
} catch (e) {
log(config, LogLevel.debug, e)
log(config, LogLevel.warning, "Error running beforeWrite command, returning original content")
return undefined
} finally {
await fs.rm(tmpDir, { force: true })
await fs.rm(rawTmpPath, { force: true })
}
}
}
async function prepareBeforeWriteCmd({
beforeWrite,
tmpDir,
content,
rawTmpPath,
rawContent,
}: {
beforeWrite: string
tmpDir: string
content: Buffer
rawTmpPath: string
rawContent: Buffer
}): Promise<string> {
let cmd: string = ""
const pathReg = /\{\{\s*path\s*\}\}/gi
const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi
if (pathReg.test(beforeWrite)) {
await fs.writeFile(tmpDir, content)
cmd = beforeWrite.replaceAll(pathReg, tmpDir)
}
if (rawPathReg.test(beforeWrite)) {
await fs.writeFile(rawTmpPath, rawContent)
cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath)
}
if (!cmd) {
await fs.writeFile(tmpDir, content)
cmd = [beforeWrite, tmpDir].join(" ")
}
return cmd
}

View File

@@ -1,23 +1,26 @@
#!/usr/bin/env node
import os from "node:os"
import { massarg } from "massarg"
import chalk from "chalk"
import { LogLevel, ScaffoldCmdConfig } from "./types"
import { Scaffold } from "./scaffold"
import path from "node:path"
import fs from "node:fs/promises"
import { parseAppendData, parseConfigFile } from "./config"
import { massarg } from "massarg"
import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig, ScaffoldConfigMap } from "./types"
import { Scaffold } from "./scaffold"
import { findConfigFile, getConfigFile, parseAppendData, parseConfigFile } from "./config"
import { log } from "./logger"
import { MassargCommand } from "massarg/command"
import { getUniqueTmpPath as generateUniqueTmpPath } from "./file"
import { colorize } from "./colors"
import { promptBeforeConfig, promptAfterConfig, resolveInputs } from "./prompts"
import { initScaffold } from "./init"
export async function parseCliArgs(args = process.argv.slice(2)) {
const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false))
const pkgFile = await fs.readFile(path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json"))
const isProjectRoot = Boolean(
await fs.stat(path.join(__dirname, "package.json")).catch(() => false),
)
const pkgFile = await fs.readFile(
path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json"),
)
const pkg = JSON.parse(pkgFile.toString())
const isVersionFlag = args.includes("--version") || args.includes("-v")
const isConfigProvided =
args.includes("--config") || args.includes("-c") || args.includes("--git") || args.includes("-g") || isVersionFlag
return massarg<ScaffoldCmdConfig>({
name: pkg.name,
description: pkg.description,
@@ -27,15 +30,46 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
console.log(pkg.version)
return
}
log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`)
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)
log(config, LogLevel.debug, `Simple Scaffold v${pkg.version}`)
config.tmpDir = generateUniqueTmpPath()
try {
// Auto-detect config file in cwd — but only if the user didn't
// explicitly provide templates/output (which signals a one-time run)
const isOneTimeRun = config.templates?.length > 0 || config.output
if (!config.config && !config.git && !isOneTimeRun) {
try {
config.config = await findConfigFile(process.cwd())
log(config, LogLevel.debug, `Auto-detected config file: ${config.config}`)
} catch {
// No config file found — that's fine, continue without one
}
}
// Load config map early so we can prompt for name and template key
const hasConfigSource = Boolean(config.config || config.git)
let configMap: ScaffoldConfigMap | undefined
if (hasConfigSource) {
configMap = await getConfigFile(config)
}
// Phase 1: prompt for name + key (needed before parseConfigFile)
config = await promptBeforeConfig(config, configMap)
// Parse and merge the config file
log(config, LogLevel.debug, "Parsing config file...", config)
const parsed = await parseConfigFile(config, tmpPath)
await Scaffold(parsed)
const parsed = await parseConfigFile(config)
// Phase 2: prompt for anything still missing after config merge
const prompted = await promptAfterConfig(parsed)
const resolved = await resolveInputs(prompted)
await Scaffold(resolved)
} catch (e) {
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
log(config, LogLevel.error, message)
} finally {
log(config, LogLevel.debug, "Cleaning up temporary files...", tmpPath)
await fs.rm(tmpPath, { recursive: true, force: true })
log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir)
if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true })
}
})
.option({
@@ -43,39 +77,34 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
aliases: ["n"],
description:
"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.",
"contents and file names will be replaced accordingly. You may omit the `--name` or `-n` " +
"for this specific option. If omitted in an interactive terminal, you will be prompted.",
isDefault: true,
required: !isVersionFlag,
})
.option({
name: "config",
aliases: ["c"],
description:
"Filename to load config from instead of passing arguments to CLI or using a Node.js " +
"script. See examples for syntax. This can also work in conjunction with `--git` or `--github` to point " +
"to remote files, and with `--key` to denote which key to select from the file.",
description: "Filename or directory to load config from",
})
.option({
name: "git",
aliases: ["g"],
description:
"Git URL to load config from instead of passing arguments to CLI or using a Node.js script. See " +
"examples for syntax.",
description: "Git URL or GitHub path to load a template from.",
})
.option({
name: "key",
aliases: ["k"],
description:
"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)`",
"(e.g. `--config scaffold.cmd.js:component)`. If omitted and multiple templates are available, " +
"you will be prompted to select one.",
})
.option({
name: "output",
aliases: ["o"],
description:
"Path to output to. If `--subdir` is enabled, the subfolder will be created inside " +
"this path. Default is current working directory.",
required: !isConfigProvided,
"Path to output to. If `--subdir` is enabled, the subdir will be created inside " +
"this path. If omitted in an interactive terminal, you will be prompted.",
})
.option({
name: "templates",
@@ -84,8 +113,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
description:
"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.",
required: !isConfigProvided,
"or a glob pattern for multiple file matching easily. If omitted in an interactive terminal, " +
"you will be prompted for a comma-separated list.",
})
.flag({
name: "overwrite",
@@ -119,7 +148,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
.option({
name: "subdir-helper",
aliases: ["H"],
description: "Default helper to apply to subfolder name when using `--subdir`.",
description: "Default helper to apply to subdir name when using `--subdir`.",
})
.flag({
name: "quiet",
@@ -133,16 +162,34 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
defaultValue: LogLevel.info,
description:
"Determine amount of logs to display. The values are: " +
`${chalk.bold`\`none | debug | info | warn | error\``}. ` +
`${colorize.bold`\`none | debug | info | warn | error\``}. ` +
"The provided level will display messages of the same level or higher.",
parse: (v) => {
const val = v.toLowerCase()
if (!(val in LogLevel)) {
throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`)
throw new Error(
`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`,
)
}
return val
},
})
.option({
name: "before-write",
aliases: ["B"],
description:
"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.",
})
.option({
name: "after-scaffold",
aliases: ["A"],
description:
"Run a shell command after all files have been written. " +
"The command is executed in the output directory. " +
"For example: `--after-scaffold 'npm install'`",
})
.flag({
name: "dry-run",
aliases: ["dr"],
@@ -156,6 +203,103 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
aliases: ["v"],
description: "Display version.",
})
.command(
new MassargCommand<ListCommandCliOptions>({
name: "list",
aliases: ["ls"],
description:
"List all available templates for a given config. See `list -h` for more information.",
run: async (_config) => {
const config = {
templates: [],
name: "",
version: false,
output: "",
subdir: false,
overwrite: false,
dryRun: false,
tmpDir: generateUniqueTmpPath(),
..._config,
config: _config.config ?? (!_config.git ? process.cwd() : undefined),
}
try {
const file = await getConfigFile(config)
console.log(colorize.underline`Available templates:\n`)
console.log(Object.keys(file).join("\n"))
} catch (e) {
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
log(config, LogLevel.error, message)
} finally {
log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir)
if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true })
}
},
})
.option({
name: "config",
aliases: ["c"],
description:
"Filename or directory to load config from. Defaults to current working directory.",
})
.option({
name: "git",
aliases: ["g"],
description: "Git URL or GitHub path to load a template from.",
})
.option({
name: "log-level",
aliases: ["l"],
defaultValue: LogLevel.none,
description:
"Determine amount of logs to display. The values are: " +
`${colorize.bold`\`none | debug | info | warn | error\``}. ` +
"The provided level will display messages of the same level or higher.",
parse: (v) => {
const val = v.toLowerCase()
if (!(val in LogLevel)) {
throw new Error(
`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`,
)
}
return val
},
})
.help({
bindOption: true,
}),
)
.command(
new MassargCommand<{ dir?: string; format?: string }>({
name: "init",
aliases: [],
description:
"Initialize a new scaffold config file and example template in the current directory.",
run: async (config) => {
try {
await initScaffold({
dir: config.dir,
format: config.format as "js" | "mjs" | "json" | undefined,
})
} catch (e) {
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
console.error(colorize.red(message ?? "Unknown error"))
}
},
})
.option({
name: "dir",
aliases: ["d"],
description: "Directory to create the config in. Defaults to current working directory.",
})
.option({
name: "format",
aliases: ["f"],
description: "Config file format: js, mjs, or json. If omitted, you will be prompted.",
})
.help({
bindOption: true,
}),
)
.example({
description: "Usage with config file",
input: "simple-scaffold -c scaffold.cmd.js --key component",
@@ -166,7 +310,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
})
.example({
description: "Usage with https git URL (for non-GitHub)",
input: "simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component",
input:
"simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component",
})
.example({
description: "Excluded template key, assumes 'default' key",
@@ -181,7 +326,11 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
bindOption: true,
lineLength: 100,
useGlobalTableColumns: true,
usageText: [chalk.yellow`simple-scaffold`, chalk.gray`[options]`, chalk.cyan`<name>`].join(" "),
usageText: [
colorize.yellow`simple-scaffold`,
colorize.gray`[options]`,
colorize.cyan`<name>`,
].join(" "),
optionOptions: {
displayNegations: true,
},
@@ -189,9 +338,9 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
`Version: ${pkg.version}`,
`Copyright © Chen Asraf 2017-${new Date().getFullYear()}`,
``,
`Documentation: ${chalk.underline`https://chenasraf.github.io/simple-scaffold`}`,
`NPM: ${chalk.underline`https://npmjs.com/package/simple-scaffold`}`,
`GitHub: ${chalk.underline`https://github.com/chenasraf/simple-scaffold`}`,
`Documentation: ${colorize.underline`https://chenasraf.github.io/simple-scaffold`}`,
`NPM: ${colorize.underline`https://npmjs.com/package/simple-scaffold`}`,
`GitHub: ${colorize.underline`https://github.com/chenasraf/simple-scaffold`}`,
].join("\n"),
})
.parse(args)

74
src/colors.ts Normal file
View File

@@ -0,0 +1,74 @@
/** ANSI color code mapping for terminal output. */
const colorMap = {
reset: 0,
dim: 2,
bold: 1,
italic: 3,
underline: 4,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
gray: 90,
} as const
/** Available terminal color names. */
export type TermColor = keyof typeof colorMap
function _colorize(text: string, color: TermColor): string {
const c = colorMap[color]!
let r: number
if (c > 1 && c < 30) {
r = c + 20
} else if (c === 1) {
r = 23
} else {
r = 0
}
return `\x1b[${c}m${text}\x1b[${r}m`
}
function isTemplateStringArray(
template: TemplateStringsArray | unknown,
): template is TemplateStringsArray {
return Array.isArray(template) && typeof template[0] === "string"
}
const createColorize =
(color: TermColor) =>
(template: TemplateStringsArray | unknown, ...params: unknown[]): string => {
return isTemplateStringArray(template)
? _colorize(
(template as TemplateStringsArray).reduce(
(acc, str, i) => acc + str + (params[i] ?? ""),
"",
),
color,
)
: _colorize(String(template), color)
}
type TemplateStringsFn = ReturnType<typeof createColorize> & ((text: string) => string)
type TemplateStringsFns = { [key in TermColor]: TemplateStringsFn }
/**
* Colorize text for terminal output.
*
* Can be used as a function: `colorize("text", "red")`
* Or via named helpers: `colorize.red("text")` / `colorize.red\`template\``
*/
export const colorize: typeof _colorize & TemplateStringsFns = Object.assign(
_colorize,
Object.entries(colorMap).reduce(
(acc, [key]) => {
acc[key as TermColor] = createColorize(key as TermColor)
return acc
},
{} as Record<TermColor, TemplateStringsFn>,
),
)

View File

@@ -1,42 +1,27 @@
import path from "node:path"
import {
ConfigLoadConfig,
FileResponse,
FileResponseHandler,
LogConfig,
LogLevel,
RemoteConfigLoadConfig,
ScaffoldCmdConfig,
ScaffoldConfig,
ScaffoldConfigFile,
ScaffoldConfigMap,
} from "./types"
import { handlebarsParse } from "./parser"
import { log } from "./logger"
import { resolve, wrapNoopResolver } from "./utils"
import { getGitConfig } from "./git"
import { isDir, pathExists } from "./fs-utils"
import { wrapBeforeWrite } from "./before-write"
/** @internal */
export function getOptionValueForFile<T>(
config: ScaffoldConfig,
filePath: string,
fn: FileResponse<T>,
defaultValue?: T,
): T {
if (typeof fn !== "function") {
return defaultValue ?? (fn as T)
}
return (fn as FileResponseHandler<T>)(
filePath,
path.dirname(handlebarsParse(config, filePath, { isPath: true }).toString()),
path.basename(handlebarsParse(config, filePath, { isPath: true }).toString()),
)
}
// Re-export for backward compatibility (tests import from here)
export { getOptionValueForFile } from "./file"
/** @internal */
/** Parses CLI append-data syntax (`key=value` or `key:=jsonValue`) into a data object. @internal */
export function parseAppendData(value: string, options: ScaffoldCmdConfig): unknown {
const data = options.data ?? {}
const [key, val] = value.split(/\:?=/)
// raw
const [key, val] = value.split(/:?=/)
if (value.includes(":=") && !val.includes(":=")) {
return { ...data, [key]: JSON.parse(val) }
}
@@ -44,65 +29,109 @@ export function parseAppendData(value: string, options: ScaffoldCmdConfig): unkn
}
function isWrappedWithQuotes(string: string): boolean {
return (string.startsWith('"') && string.endsWith('"')) || (string.startsWith("'") && string.endsWith("'"))
return (
(string.startsWith('"') && string.endsWith('"')) ||
(string.startsWith("'") && string.endsWith("'"))
)
}
/** @internal */
export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string): Promise<ScaffoldConfig> {
let output: ScaffoldConfig = config
/** Loads and resolves a config file (local or remote). @internal */
export async function getConfigFile(config: ScaffoldCmdConfig): Promise<ScaffoldConfigMap> {
if (config.git && !config.git.includes("://")) {
log(config, LogLevel.debug, `Loading config from GitHub ${config.git}`)
config.git = githubPartToUrl(config.git)
}
const isGit = Boolean(config.git)
const configFilename = config.config
const configPath = isGit ? config.git : configFilename
log(config, LogLevel.debug, `Loading config from file ${configFilename}`)
const configPromise = await (isGit
? getRemoteConfig({
git: configPath,
config: configFilename,
logLevel: config.logLevel,
tmpDir: config.tmpDir!,
})
: getLocalConfig({ config: configFilename, logLevel: config.logLevel }))
let configImport = await resolve(configPromise, config)
if (typeof configImport.default === "function" || configImport.default instanceof Promise) {
log(config, LogLevel.debug, "Config is a function or promise, resolving...")
configImport = await resolve(configImport.default, config)
}
return configImport
}
/**
* Parses a CLI config into a full ScaffoldConfig by merging CLI args, config file values,
* and append-data overrides. @internal
*/
export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<ScaffoldConfig> {
let output: ScaffoldConfig = {
name: config.name,
templates: config.templates ?? [],
output: config.output,
logLevel: config.logLevel,
dryRun: config.dryRun,
data: config.data,
subdir: config.subdir,
overwrite: config.overwrite,
subdirHelper: config.subdirHelper,
beforeWrite: undefined,
tmpDir: config.tmpDir!,
}
if (config.quiet) {
config.logLevel = LogLevel.none
}
if (config.git && !config.git.includes("://")) {
log(config, LogLevel.info, `Loading config from GitHub ${config.git}`)
config.git = githubPartToUrl(config.git)
}
const shouldLoadConfig = config.config || config.git
const shouldLoadConfig = Boolean(config.config || config.git)
if (shouldLoadConfig) {
const isGit = Boolean(config.git)
const key = config.key ?? "default"
const configFilename = config.config
const configPath = isGit ? config.git : configFilename
log(config, LogLevel.info, `Loading config from ${configFilename} with key ${key}`)
const configPromise = await (isGit
? getRemoteConfig({ git: configPath, config: configFilename, logLevel: config.logLevel, tmpPath })
: getLocalConfig({ config: configFilename, logLevel: config.logLevel }))
// resolve the config
let configImport = await resolve(configPromise, config)
// If the config is a function or promise, return the output
if (typeof configImport.default === "function" || configImport.default instanceof Promise) {
configImport = await resolve(configImport.default, config)
}
const configImport = await getConfigFile(config)
if (!configImport[key]) {
throw new Error(`Template "${key}" not found in ${configFilename}`)
throw new Error(`Template "${key}" not found in ${config.config}`)
}
const importedKey = configImport[key]
const imported = configImport[key]
log(config, LogLevel.debug, "Imported result", imported)
output = {
...config,
...importedKey,
...output,
...imported,
beforeWrite: undefined,
templates: config.templates || imported.templates,
output: config.output || imported.output,
data: {
...(importedKey as any).data,
...imported.data,
...config.data,
},
}
}
output.data = { ...output.data, ...config.appendData }
delete config.appendData
const cmdBeforeWrite = config.beforeWrite
? wrapBeforeWrite(config, config.beforeWrite)
: undefined
output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite
if (config.afterScaffold) {
output.afterScaffold = config.afterScaffold
}
if (!output.name) {
throw new Error("simple-scaffold: Missing required option: name")
}
log(output, LogLevel.debug, "Parsed config", output)
return output
}
/** @internal */
/** Converts a GitHub shorthand (user/repo) to a full HTTPS git URL. @internal */
export function githubPartToUrl(part: string): string {
const gitUrl = new URL(`https://github.com/${part}`)
if (!gitUrl.pathname.endsWith(".git")) {
@@ -111,22 +140,42 @@ export function githubPartToUrl(part: string): string {
return gitUrl.toString()
}
/** @internal */
export async function getLocalConfig(config: ConfigLoadConfig & Partial<LogConfig>): Promise<ScaffoldConfigFile> {
/** Loads a scaffold config from a local file or directory. @internal */
export async function getLocalConfig(
config: ConfigLoadConfig & Partial<LogConfig>,
): Promise<ScaffoldConfigFile> {
const { config: configFile, ...logConfig } = config as Required<typeof config>
log(logConfig, LogLevel.info, `Loading config from file ${configFile}`)
const absolutePath = path.resolve(process.cwd(), configFile)
const _isDir = await isDir(absolutePath)
if (_isDir) {
log(logConfig, LogLevel.debug, `Resolving config file from directory ${absolutePath}`)
const file = await findConfigFile(absolutePath)
const exists = await pathExists(file)
if (!exists) {
throw new Error(`Could not find config file in directory ${absolutePath}`)
}
log(logConfig, LogLevel.debug, `Loading config from: ${path.resolve(absolutePath, file)}`)
return wrapNoopResolver(import(path.resolve(absolutePath, file)))
}
log(logConfig, LogLevel.debug, `Loading config from: ${absolutePath}`)
return wrapNoopResolver(import(absolutePath))
}
/** @internal */
/** Loads a scaffold config from a remote git repository. @internal */
export async function getRemoteConfig(
config: RemoteConfigLoadConfig & Partial<LogConfig>,
): Promise<ScaffoldConfigFile> {
const { config: configFile, git, tmpPath, ...logConfig } = config as Required<typeof config>
const { config: configFile, git, tmpDir, ...logConfig } = config as Required<typeof config>
log(logConfig, LogLevel.info, `Loading config from remote ${git}, file ${configFile}`)
log(
logConfig,
LogLevel.debug,
`Loading config from remote ${git}, config file ${configFile || "<auto-detect>"}`,
)
const url = new URL(git!)
const isHttp = url.protocol === "http:" || url.protocol === "https:"
@@ -136,5 +185,22 @@ export async function getRemoteConfig(
throw new Error(`Unsupported protocol ${url.protocol}`)
}
return getGitConfig(url, configFile, tmpPath, logConfig)
return getGitConfig(url, configFile, tmpDir, logConfig)
}
/** Searches for a scaffold config file in the given directory, trying known filenames in order. @internal */
export async function findConfigFile(root: string): Promise<string> {
const allowed = ["mjs", "cjs", "js", "json"].reduce((acc, ext) => {
acc.push(`scaffold.config.${ext}`)
acc.push(`scaffold.${ext}`)
acc.push(`.scaffold.${ext}`)
return acc
}, [] as string[])
for (const file of allowed) {
const exists = await pathExists(path.resolve(root, file))
if (exists) {
return file
}
}
throw new Error(`Could not find config file in git repo`)
}

View File

@@ -1,107 +1,93 @@
import path from "node:path"
import { F_OK } from "node:constants"
import { LogLevel, ScaffoldConfig } from "./types"
import fs from "node:fs/promises"
import { FileResponse, FileResponseHandler, LogLevel, ScaffoldConfig } from "./types"
import { glob, hasMagic } from "glob"
import { log } from "./logger"
import { getOptionValueForFile } from "./config"
import { handlebarsParse } from "./parser"
import { handleErr } from "./utils"
import { createDirIfNotExists, pathExists, isDir } from "./fs-utils"
import { removeGlob } from "./path-utils"
const { stat, access, mkdir, readFile, writeFile } = fs
const { readFile, writeFile } = fs
export async function createDirIfNotExists(dir: string, config: ScaffoldConfig): Promise<void> {
if (config.dryRun) {
log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`)
return
}
const parentDir = path.dirname(dir)
if (!(await pathExists(parentDir))) {
await createDirIfNotExists(parentDir, config)
}
if (!(await pathExists(dir))) {
try {
log(config, LogLevel.debug, `Creating dir ${dir}`)
await mkdir(dir)
return
} catch (e: any) {
if (e.code !== "EEXIST") {
throw e
}
return
}
// Re-export extracted utilities for backward compatibility (tests import from here)
export { createDirIfNotExists, pathExists, isDir, getUniqueTmpPath } from "./fs-utils"
export { removeGlob, makeRelativePath, getBasePath } from "./path-utils"
/**
* Resolves a config option that may be either a static value or a per-file function.
* For function values, the file path is parsed through Handlebars before being passed.
* @internal
*/
export function getOptionValueForFile<T>(
config: ScaffoldConfig,
filePath: string,
fn: FileResponse<T>,
defaultValue?: T,
): T {
if (typeof fn !== "function") {
return defaultValue ?? (fn as T)
}
return (fn as FileResponseHandler<T>)(
filePath,
path.dirname(handlebarsParse(config, filePath, { asPath: true }).toString()),
path.basename(handlebarsParse(config, filePath, { asPath: true }).toString()),
)
}
export async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath, F_OK)
return true
} catch (e: any) {
if (e.code === "ENOENT") {
return false
}
throw e
}
/** Information about a template glob pattern and how it was resolved. */
export interface GlobInfo {
/** The template path with glob wildcards stripped. */
baseTemplatePath: string
/** The original template string as provided by the user. */
origTemplate: string
/** Whether the template is a directory or contains glob patterns. */
isDirOrGlob: boolean
/** Whether the template contains glob wildcard characters. */
isGlob: boolean
/** The final resolved template path (with `**\/*` appended for directories). */
template: string
}
export async function isDir(path: string): Promise<boolean> {
const tplStat = await stat(path)
return tplStat.isDirectory()
}
export function removeGlob(template: string): string {
return path.normalize(template.replace(/\*/g, ""))
}
export function makeRelativePath(str: string): string {
return str.startsWith(path.sep) ? str.slice(1) : str
}
export function getBasePath(relPath: string): string {
return path
.resolve(process.cwd(), relPath)
.replace(process.cwd() + path.sep, "")
.replace(process.cwd(), "")
}
export async function getFileList(_config: ScaffoldConfig, template: string): Promise<string[]> {
template = template.replaceAll(/[\\]+/g, "/")
log(_config, LogLevel.debug, `Getting file list for ${template}`)
/** Expands a list of glob patterns into a flat list of matching file paths. */
export async function getFileList(config: ScaffoldConfig, templates: string[]): Promise<string[]> {
log(config, LogLevel.debug, `Getting file list for glob list: ${templates}`)
return (
await glob(template, {
await glob(templates, {
dot: true,
nodir: true,
})
).map(path.normalize)
}
export interface GlobInfo {
nonGlobTemplate: string
origTemplate: string
isDirOrGlob: boolean
isGlob: boolean
template: string
}
/** Analyzes a template path to determine if it's a glob, directory, or single file. */
export async function getTemplateGlobInfo(
config: ScaffoldConfig,
template: string,
): Promise<GlobInfo> {
const _isGlob = hasMagic(template)
log(config, LogLevel.debug, "before isDir", "isGlob:", _isGlob, template)
export async function getTemplateGlobInfo(config: ScaffoldConfig, template: string): Promise<GlobInfo> {
const isGlob = hasMagic(template)
log(config, LogLevel.debug, "before isDir", "isGlob:", isGlob, template)
let _template = template
let nonGlobTemplate = isGlob ? removeGlob(template) : template
nonGlobTemplate = path.normalize(nonGlobTemplate)
const isDirOrGlob = isGlob ? true : await isDir(template)
const _shouldAddGlob = !isGlob && isDirOrGlob
log(config, LogLevel.debug, "after", { isDirOrGlob, _shouldAddGlob })
const origTemplate = template
if (_shouldAddGlob) {
_template = path.join(template, "**", "*")
let resolvedTemplate = template
let baseTemplatePath = _isGlob ? removeGlob(template) : template
baseTemplatePath = path.normalize(baseTemplatePath)
const isDirOrGlob = _isGlob ? true : await isDir(template)
const shouldAddGlob = !_isGlob && isDirOrGlob
log(config, LogLevel.debug, "after", { isDirOrGlob, shouldAddGlob })
if (shouldAddGlob) {
resolvedTemplate = path.join(template, "**", "*")
}
return {
baseTemplatePath,
origTemplate: template,
isDirOrGlob,
isGlob: _isGlob,
template: resolvedTemplate,
}
return { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template: _template }
}
/** Complete information about a template file's output destination. */
export interface OutputFileInfo {
inputPath: string
outputPathOpt: string
@@ -110,20 +96,24 @@ export interface OutputFileInfo {
exists: boolean
}
/** Computes the full output path and metadata for a single template file. */
export async function getTemplateFileInfo(
config: ScaffoldConfig,
{ templatePath, basePath }: { templatePath: string; basePath: string },
): Promise<OutputFileInfo> {
const inputPath = path.resolve(process.cwd(), templatePath)
const outputPathOpt = getOptionValueForFile(config, inputPath, config.output)
const outputDir = getOutputDir(config, outputPathOpt, basePath)
const outputPath = handlebarsParse(config, path.join(outputDir, path.basename(inputPath)), {
isPath: true,
}).toString()
const outputDir = getOutputDir(config, outputPathOpt, basePath.replace(config.tmpDir!, "./"))
const rawOutputPath = path.join(outputDir, path.basename(inputPath))
const outputPath = handlebarsParse(config, rawOutputPath, { asPath: true }).toString()
const exists = await pathExists(outputPath)
return { inputPath, outputPathOpt, outputDir, outputPath, exists }
}
/**
* Reads a template file, applies Handlebars parsing, runs the beforeWrite hook,
* and writes the result to the output path.
*/
export async function copyFileTransformed(
config: ScaffoldConfig,
{
@@ -140,26 +130,32 @@ export async function copyFileTransformed(
): Promise<void> {
if (!exists || overwrite) {
if (exists && overwrite) {
log(config, LogLevel.info, `File ${outputPath} exists, overwriting`)
log(config, LogLevel.debug, `Overwriting ${outputPath}`)
}
log(config, LogLevel.debug, `Processing file ${inputPath}`)
const templateBuffer = await readFile(inputPath)
const unprocessedOutputContents = handlebarsParse(config, templateBuffer)
const finalOutputContents =
(await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ?? unprocessedOutputContents
(await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ??
unprocessedOutputContents
if (!config.dryRun) {
await writeFile(outputPath, finalOutputContents)
log(config, LogLevel.info, "Done.")
} else {
log(config, LogLevel.info, "Dry Run. Output should be:")
log(config, LogLevel.info, finalOutputContents.toString())
log(config, LogLevel.debug, "Dry run — output would be:")
log(config, LogLevel.debug, finalOutputContents.toString())
}
} else if (exists) {
log(config, LogLevel.info, `File ${outputPath} already exists, skipping`)
log(config, LogLevel.debug, `Skipped ${outputPath} (already exists)`)
}
}
export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string {
/** Computes the output directory for a file, combining the output path, base path, and optional subdir. */
export function getOutputDir(
config: ScaffoldConfig,
outputPathOpt: string,
basePath: string,
): string {
return path.resolve(
process.cwd(),
...([
@@ -174,38 +170,45 @@ export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, base
)
}
/**
* Processes a single template file: resolves output paths, creates directories,
* and writes the transformed output.
* Returns the output path if the file was written, or null if skipped.
*/
export async function handleTemplateFile(
config: ScaffoldConfig,
{ templatePath, basePath }: { templatePath: string; basePath: string },
): Promise<void> {
return new Promise(async (resolve, reject) => {
try {
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(config, {
): Promise<string | null> {
try {
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(
config,
{
templatePath,
basePath,
})
const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false)
},
)
const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false)
log(
config,
LogLevel.debug,
`\nParsing ${templatePath}`,
`\nBase path: ${basePath}`,
`\nFull input path: ${inputPath}`,
`\nOutput Path Opt: ${outputPathOpt}`,
`\nFull output dir: ${outputDir}`,
`\nFull output path: ${outputPath}`,
`\n`,
)
log(
config,
LogLevel.debug,
`\nParsing ${templatePath}`,
`\nBase path: ${basePath}`,
`\nFull input path: ${inputPath}`,
`\nOutput Path Opt: ${outputPathOpt}`,
`\nFull output dir: ${outputDir}`,
`\nFull output path: ${outputPath}`,
`\n`,
)
await createDirIfNotExists(path.dirname(outputPath), config)
await createDirIfNotExists(path.dirname(outputPath), config)
log(config, LogLevel.info, `Writing to ${outputPath}`)
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
resolve()
} catch (e: any) {
handleErr(e)
reject(e)
}
})
const shouldWrite = (!exists || overwrite) && !config.dryRun
log(config, LogLevel.debug, `Writing to ${outputPath}`)
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
return shouldWrite ? outputPath : null
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
throw e
}
}

64
src/fs-utils.ts Normal file
View File

@@ -0,0 +1,64 @@
import os from "node:os"
import path from "node:path"
import fs from "node:fs/promises"
import { F_OK } from "node:constants"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { log } from "./logger"
const { stat, access, mkdir } = fs
/** Recursively creates a directory and its parents if they don't exist. */
export async function createDirIfNotExists(
dir: string,
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
): Promise<void> {
if (config.dryRun) {
log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`)
return
}
const parentDir = path.dirname(dir)
if (!(await pathExists(parentDir))) {
await createDirIfNotExists(parentDir, config)
}
if (!(await pathExists(dir))) {
try {
log(config, LogLevel.debug, `Creating dir ${dir}`)
await mkdir(dir)
return
} catch (e: unknown) {
if (e && (e as NodeJS.ErrnoException).code !== "EEXIST") {
throw e
}
return
}
}
}
/** Checks whether a file or directory exists at the given path. */
export async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath, F_OK)
return true
} catch (e: unknown) {
if (e && (e as NodeJS.ErrnoException).code === "ENOENT") {
return false
}
throw e
}
}
/** Returns true if the given path is a directory. */
export async function isDir(dirPath: string): Promise<boolean> {
const tplStat = await stat(dirPath)
return tplStat.isDirectory()
}
/** Generates a unique temporary directory path for scaffold operations. @internal */
export function getUniqueTmpPath(): string {
return path.resolve(
os.tmpdir(),
`scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
}

View File

@@ -1,9 +1,9 @@
import path from "node:path"
import fs from "node:fs/promises"
import { log } from "./logger"
import { AsyncResolver, LogConfig, LogLevel, ScaffoldCmdConfig, ScaffoldConfigMap } from "./types"
import { spawn } from "node:child_process"
import { resolve, wrapNoopResolver } from "./utils"
import { findConfigFile } from "./config"
export async function getGitConfig(
url: URL,
@@ -13,7 +13,7 @@ export async function getGitConfig(
): Promise<AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>> {
const repoUrl = `${url.protocol}//${url.host}${url.pathname}`
log(logConfig, LogLevel.info, `Cloning git repo ${repoUrl}`)
log(logConfig, LogLevel.debug, `Cloning git repo ${repoUrl}`)
return new Promise((res, reject) => {
log(logConfig, LogLevel.debug, `Cloning git repo to ${tmpPath}`)
@@ -43,13 +43,16 @@ export async function loadGitConfig({
file: string
tmpPath: string
}): Promise<AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>> {
log(logConfig, LogLevel.info, `Loading config from git repo: ${repoUrl}`)
log(logConfig, LogLevel.debug, `Loading config from git repo: ${repoUrl}`)
const filename = file || (await findConfigFile(tmpPath))
const absolutePath = path.resolve(tmpPath, filename)
log(logConfig, LogLevel.debug, `Resolving config file: ${absolutePath}`)
const loadedConfig = await resolve(async () => (await import(absolutePath)).default as ScaffoldConfigMap, logConfig)
const loadedConfig = await resolve(
async () => (await import(absolutePath)).default as ScaffoldConfigMap,
logConfig,
)
log(logConfig, LogLevel.info, `Loaded config from git`)
log(logConfig, LogLevel.debug, `Loaded config from git`)
log(logConfig, LogLevel.debug, `Raw config:`, loadedConfig)
const fixedConfig: ScaffoldConfigMap = {}
for (const [k, v] of Object.entries(loadedConfig)) {
@@ -60,22 +63,3 @@ export async function loadGitConfig({
}
return wrapNoopResolver(fixedConfig)
}
/** @internal */
export async function findConfigFile(root: string): Promise<string> {
const allowed = ["mjs", "cjs", "js", "json"].reduce((acc, ext) => {
acc.push(`scaffold.config.${ext}`)
acc.push(`scaffold.${ext}`)
return acc
}, [] as string[])
for (const file of allowed) {
const exists = await fs
.stat(path.resolve(root, file))
.then(() => true)
.catch(() => false)
if (exists) {
return file
}
}
throw new Error(`Could not find config file in git repo`)
}

67
src/ignore.ts Normal file
View File

@@ -0,0 +1,67 @@
import path from "node:path"
import fs from "node:fs/promises"
import { minimatch } from "minimatch"
import { pathExists } from "./fs-utils"
const IGNORE_FILENAME = ".scaffoldignore"
/**
* Reads a `.scaffoldignore` file from the given directory and returns
* the parsed patterns for filtering.
*
* Lines starting with `#` are comments. Empty lines are skipped.
*
* @param dir The directory to search for `.scaffoldignore`
* @returns Array of glob patterns to ignore
*/
export async function loadIgnorePatterns(dir: string): Promise<string[]> {
const ignorePath = path.resolve(dir, IGNORE_FILENAME)
if (!(await pathExists(ignorePath))) {
return []
}
const content = await fs.readFile(ignorePath, "utf-8")
return parseIgnoreFile(content)
}
/**
* Parses the contents of a `.scaffoldignore` file into glob patterns.
* @internal
*/
export function parseIgnoreFile(content: string): string[] {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
}
/**
* Filters a list of file paths, removing any that match the ignore patterns.
* Patterns are matched against the relative path from baseDir.
* Also always excludes `.scaffoldignore` itself.
*/
export function filterIgnoredFiles(
files: string[],
ignorePatterns: string[],
baseDir: string,
): string[] {
return files.filter((file) => {
const basename = path.basename(file)
if (basename === IGNORE_FILENAME) {
return false
}
const relPath = path.relative(baseDir, file)
for (const pattern of ignorePatterns) {
if (
minimatch(relPath, pattern, { dot: true }) ||
minimatch(basename, pattern, { dot: true })
) {
return false
}
}
return true
})
}

View File

@@ -1,5 +1,6 @@
export * from "./scaffold"
export * from "./types"
export { validateConfig, assertConfigValid, scaffoldConfigSchema } from "./validate"
import Scaffold from "./scaffold"
export default Scaffold

111
src/init.ts Normal file
View File

@@ -0,0 +1,111 @@
import path from "node:path"
import fs from "node:fs/promises"
import select from "@inquirer/select"
import { colorize } from "./colors"
import { pathExists } from "./fs-utils"
const CONFIG_EXTENSIONS = {
js: "scaffold.config.js",
mjs: "scaffold.config.mjs",
json: "scaffold.config.json",
} as const
type ConfigFormat = keyof typeof CONFIG_EXTENSIONS
const CONFIG_TEMPLATES: Record<ConfigFormat, string> = {
js: `/** @type {import('simple-scaffold').ScaffoldConfigMap} */
module.exports = {
default: {
templates: ["templates/default"],
output: ".",
// inputs: {
// author: { message: "Author name", required: true },
// license: { message: "License", default: "MIT" },
// },
},
}
`,
mjs: `/** @type {import('simple-scaffold').ScaffoldConfigMap} */
export default {
default: {
templates: ["templates/default"],
output: ".",
// inputs: {
// author: { message: "Author name", required: true },
// license: { message: "License", default: "MIT" },
// },
},
}
`,
json: `{
"default": {
"templates": ["templates/default"],
"output": "."
}
}
`,
}
const EXAMPLE_TEMPLATE_CONTENT = `# {{ name }}
Created by Simple Scaffold.
{{#if description}}{{ description }}{{/if}}
`
export interface InitOptions {
/** Working directory to create the config in. Defaults to cwd. */
dir?: string
/** Config format to use. If not provided, the user is prompted. */
format?: ConfigFormat
/** Whether to create an example template directory. Defaults to true. */
createExample?: boolean
}
/**
* Initializes a new Simple Scaffold project by creating a config file
* and an optional example template directory.
*/
export async function initScaffold(options: InitOptions = {}): Promise<void> {
const dir = options.dir ?? process.cwd()
const format =
options.format ??
(await select<ConfigFormat>({
message: colorize.cyan("Config file format:"),
choices: [
{ name: "JavaScript (CommonJS)", value: "js" },
{ name: "JavaScript (ESM)", value: "mjs" },
{ name: "JSON", value: "json" },
],
}))
const filename = CONFIG_EXTENSIONS[format]
const configPath = path.resolve(dir, filename)
if (await pathExists(configPath)) {
console.log(colorize.yellow(`${filename} already exists, skipping config creation.`))
} else {
await fs.writeFile(configPath, CONFIG_TEMPLATES[format])
console.log(colorize.green(`Created ${filename}`))
}
const createExample = options.createExample ?? true
if (createExample) {
const templateDir = path.resolve(dir, "templates", "default")
const templateFile = path.join(templateDir, "{{name}}.md")
if (await pathExists(templateDir)) {
console.log(colorize.yellow("templates/default/ already exists, skipping example template."))
} else {
await fs.mkdir(templateDir, { recursive: true })
await fs.writeFile(templateFile, EXAMPLE_TEMPLATE_CONTENT)
console.log(colorize.green("Created templates/default/{{name}}.md"))
}
}
console.log()
console.log(colorize.dim("Get started:"))
console.log(colorize.dim(` npx simple-scaffold MyProject`))
console.log()
}

View File

@@ -1,41 +1,54 @@
import util from "util"
import path from "node:path"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import chalk from "chalk"
import { colorize, TermColor } from "./colors"
export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void {
const priority: Record<LogLevel, number> = {
[LogLevel.none]: 0,
[LogLevel.debug]: 1,
[LogLevel.info]: 2,
[LogLevel.warning]: 3,
[LogLevel.error]: 4,
}
/** Priority ordering for log levels (higher = more severe). */
const LOG_PRIORITY: Record<LogLevel, number> = {
[LogLevel.none]: 0,
[LogLevel.debug]: 1,
[LogLevel.info]: 2,
[LogLevel.warning]: 3,
[LogLevel.error]: 4,
}
if (config.logLevel === LogLevel.none || priority[level] < priority[config.logLevel ?? LogLevel.info]) {
/** Maps each log level to a terminal color. */
const LOG_LEVEL_COLOR: Record<LogLevel, TermColor> = {
[LogLevel.none]: "reset",
[LogLevel.debug]: "dim",
[LogLevel.info]: "reset",
[LogLevel.warning]: "yellow",
[LogLevel.error]: "red",
}
/** Logs a message at the given level, respecting the configured log level filter. */
export function log(config: LogConfig, level: LogLevel, ...obj: unknown[]): void {
if (
config.logLevel === LogLevel.none ||
LOG_PRIORITY[level] < LOG_PRIORITY[config.logLevel ?? LogLevel.info]
) {
return
}
const levelColor: Record<keyof typeof LogLevel, keyof typeof chalk> = {
[LogLevel.none]: "reset",
[LogLevel.debug]: "blue",
[LogLevel.info]: "dim",
[LogLevel.warning]: "yellow",
[LogLevel.error]: "red",
}
const chalkFn: any = chalk[levelColor[level]]
const key: "log" | "warn" | "error" = level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
const logFn: any = console[key]
const colorFn = colorize[LOG_LEVEL_COLOR[level]]
const key: "log" | "warn" | "error" =
level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
const logFn: (..._args: unknown[]) => void = console[key]
logFn(
...obj.map((i) =>
i instanceof Error
? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack)
? colorFn(i, JSON.stringify(i, undefined, 1), i.stack)
: typeof i === "object"
? chalkFn(JSON.stringify(i, undefined, 1))
: chalkFn(i),
? util.inspect(i, { depth: null, colors: true })
: colorFn(i),
),
)
}
/**
* Logs detailed file processing information at debug level.
* @deprecated Use `log(config, LogLevel.debug, data)` directly instead.
*/
export function logInputFile(
config: ScaffoldConfig,
data: {
@@ -52,6 +65,7 @@ export function logInputFile(
log(config, LogLevel.debug, data)
}
/** Logs the full scaffold configuration at debug level. */
export function logInitStep(config: ScaffoldConfig): void {
log(config, LogLevel.debug, "Full config:", {
name: config.name,
@@ -66,5 +80,56 @@ export function logInitStep(config: ScaffoldConfig): void {
dryRun: config.dryRun,
beforeWrite: config.beforeWrite,
} as Record<keyof ScaffoldConfig, unknown>)
log(config, LogLevel.info, "Data:", config.data)
}
/**
* Logs a tree of created files, grouped by directory.
*/
export function logFileTree(config: LogConfig, files: string[]): void {
if (files.length === 0) return
// Find common prefix to make paths relative
const commonDir = files.reduce((prefix, file) => {
while (!file.startsWith(prefix)) {
prefix = path.dirname(prefix)
}
return prefix
}, path.dirname(files[0]))
log(config, LogLevel.info, "")
log(config, LogLevel.info, colorize.bold(`📁 ${commonDir}`))
const relPaths = files.map((f) => path.relative(commonDir, f)).sort()
for (let i = 0; i < relPaths.length; i++) {
const isLast = i === relPaths.length - 1
const prefix = isLast ? "└── " : "├── "
log(config, LogLevel.info, colorize.dim(prefix) + relPaths[i])
}
}
/**
* Logs a final summary line with file count and elapsed time.
*/
export function logSummary(
config: LogConfig,
fileCount: number,
elapsedMs: number,
dryRun?: boolean,
): void {
const timeStr =
elapsedMs < 1000 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`
log(config, LogLevel.info, "")
if (dryRun) {
log(
config,
LogLevel.info,
colorize.yellow(`🏜️ Dry run complete — ${fileCount} file(s) would be created (${timeStr})`),
)
} else if (fileCount === 0) {
log(config, LogLevel.info, colorize.yellow(`⚠️ No files created (${timeStr})`))
} else {
log(config, LogLevel.info, colorize.green(`✅ Created ${fileCount} file(s) in ${timeStr}`))
}
}

View File

@@ -1,17 +1,10 @@
import path from "node:path"
import { DefaultHelpers, Helper, LogLevel, ScaffoldConfig } from "./types"
import Handlebars from "handlebars"
import dtAdd from "date-fns/add"
import dtFormat from "date-fns/format"
import dtParseISO from "date-fns/parseISO"
import { add, format, parseISO, type Duration } from "date-fns"
import { log } from "./logger"
import { Duration } from "date-fns"
const dateFns = {
add: dtAdd.add,
format: dtFormat.format,
parseISO: dtParseISO.parseISO,
}
const dateFns = { add, format, parseISO }
export const defaultHelpers: Record<DefaultHelpers, Helper> = {
camelCase,
@@ -27,7 +20,12 @@ export const defaultHelpers: Record<DefaultHelpers, Helper> = {
}
function _dateHelper(date: Date, formatString: string): string
function _dateHelper(date: Date, formatString: string, durationDifference: number, durationType: keyof Duration): string
function _dateHelper(
date: Date,
formatString: string,
durationDifference: number,
durationType: keyof Duration,
): string
function _dateHelper(
date: Date,
formatString: string,
@@ -41,8 +39,16 @@ function _dateHelper(
}
export function nowHelper(formatString: string): string
export function nowHelper(formatString: string, durationDifference: number, durationType: keyof Duration): string
export function nowHelper(formatString: string, durationDifference?: number, durationType?: keyof Duration): string {
export function nowHelper(
formatString: string,
durationDifference: number,
durationType: keyof Duration,
): string
export function nowHelper(
formatString: string,
durationDifference?: number,
durationType?: keyof Duration,
): string {
return _dateHelper(new Date(), formatString, durationDifference!, durationType!)
}
@@ -62,9 +68,16 @@ export function dateHelper(
return _dateHelper(dateFns.parseISO(date), formatString, durationDifference!, durationType!)
}
// splits by either non-alpha character or capital letter
// splits by either non-alphanumeric character or capital letter boundaries
function toWordParts(string: string): string[] {
return string.split(/(?=[A-Z])|[^a-zA-Z]/).filter((s) => s.length > 0)
// First split on non-alphanumeric characters
return string
.split(/[^a-zA-Z0-9]/)
.flatMap((segment) =>
// Then split camelCase/PascalCase boundaries, handling consecutive uppercase (e.g. "HTMLParser" -> "HTML", "Parser")
segment.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/),
)
.filter((s) => s.length > 0)
}
function camelCase(s: string): string {
@@ -105,23 +118,23 @@ export function registerHelpers(config: ScaffoldConfig): void {
export function handlebarsParse(
config: ScaffoldConfig,
templateBuffer: Buffer | string,
{ isPath = false }: { isPath?: boolean } = {},
{ asPath = false }: { asPath?: boolean } = {},
): Buffer {
const { data } = config
try {
let str = templateBuffer.toString()
if (isPath) {
if (asPath) {
str = str.replace(/\\/g, "/")
}
const parser = Handlebars.compile(str, { noEscape: true })
let outputContents = parser(data)
if (isPath && path.sep !== "/") {
if (asPath && path.sep !== "/") {
outputContents = outputContents.replace(/\//g, "\\")
}
return Buffer.from(outputContents)
} catch (e) {
log(config, LogLevel.debug, e)
log(config, LogLevel.warning, "Couldn't parse file with handlebars, returning original content")
log(config, LogLevel.debug, "Couldn't parse file with handlebars, returning original content")
return Buffer.from(templateBuffer)
}
}

19
src/path-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import path from "node:path"
/** Strips glob wildcard characters from a template path. */
export function removeGlob(template: string): string {
return path.normalize(template.replace(/\*/g, ""))
}
/** Removes a leading path separator, making the path relative. */
export function makeRelativePath(str: string): string {
return str.startsWith(path.sep) ? str.slice(1) : str
}
/** Computes a base path relative to the current working directory. */
export function getBasePath(relPath: string): string {
return path
.resolve(process.cwd(), relPath)
.replace(process.cwd() + path.sep, "")
.replace(process.cwd(), "")
}

253
src/prompts.ts Normal file
View File

@@ -0,0 +1,253 @@
import input from "@inquirer/input"
import select from "@inquirer/select"
import confirm from "@inquirer/confirm"
import number from "@inquirer/number"
import { colorize } from "./colors"
import {
ScaffoldCmdConfig,
ScaffoldConfig,
ScaffoldConfigMap,
ScaffoldInput,
ScaffoldInputType,
} from "./types"
/** Prompts the user for a scaffold name. */
export async function promptForName(): Promise<string> {
return input({
message: colorize.cyan("Scaffold name:"),
required: true,
validate: (value) => {
if (!value.trim()) return "Name cannot be empty"
return true
},
})
}
/** Prompts the user to select a template key from the available config keys. */
export async function promptForTemplateKey(configMap: ScaffoldConfigMap): Promise<string> {
const keys = Object.keys(configMap)
if (keys.length === 0) {
throw new Error("No templates found in config file")
}
if (keys.length === 1) {
return keys[0]
}
return select({
message: colorize.cyan("Select a template:"),
choices: keys.map((key) => ({
name: key,
value: key,
})),
})
}
/** Prompts the user for an output directory path. */
export async function promptForOutput(): Promise<string> {
return input({
message: colorize.cyan("Output directory:"),
required: true,
default: ".",
validate: (value) => {
if (!value.trim()) return "Output directory cannot be empty"
return true
},
})
}
/** Prompts the user for template paths (comma-separated). */
export async function promptForTemplates(): Promise<string[]> {
const value = await input({
message: colorize.cyan("Template paths (comma-separated):"),
required: true,
validate: (value) => {
if (!value.trim()) return "At least one template path is required"
return true
},
})
return value
.split(",")
.map((t) => t.trim())
.filter(Boolean)
}
/** Prompts for a single input based on its type. */
async function promptSingleInput(
key: string,
def: ScaffoldInput,
): Promise<string | boolean | number | undefined> {
const type: ScaffoldInputType = def.type ?? "text"
const message = colorize.cyan(def.message ?? `${key}:`)
switch (type) {
case "text":
return input({
message,
required: def.required,
default: def.default as string | undefined,
validate: def.required
? (value) => {
if (!value.trim()) return `${key} is required`
return true
}
: undefined,
})
case "select": {
const choices = (def.options ?? []).map((opt) =>
typeof opt === "string" ? { name: opt, value: opt } : opt,
)
if (choices.length === 0) {
throw new Error(`Input "${key}" has type "select" but no options defined`)
}
return select({
message,
choices,
default: def.default as string | undefined,
})
}
case "confirm":
return confirm({
message,
default: (def.default as boolean | undefined) ?? false,
})
case "number":
return (
(await number({
message,
required: def.required,
default: def.default as number | undefined,
})) ?? def.default
)
}
}
/**
* Prompts the user for any required scaffold inputs that are not already provided in data.
* Also applies default values for optional inputs that have one.
* Returns the merged data object.
*/
export async function promptForInputs(
inputs: Record<string, ScaffoldInput>,
existingData: Record<string, unknown> = {},
): Promise<Record<string, unknown>> {
const data = { ...existingData }
for (const [key, def] of Object.entries(inputs)) {
// Skip if already provided via data/CLI
if (key in data && data[key] !== undefined && data[key] !== "") {
continue
}
if (def.required || def.type === "select" || def.type === "confirm") {
data[key] = await promptSingleInput(key, def)
} else if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}
}
return data
}
/** Returns true if the process is running in an interactive terminal. */
export function isInteractive(): boolean {
return Boolean(process.stdin.isTTY)
}
/**
* Prompts for name and template key before the config file is parsed.
* These are needed by parseConfigFile to know which template to load.
*/
export async function promptBeforeConfig(
config: ScaffoldCmdConfig,
configMap?: ScaffoldConfigMap,
): Promise<ScaffoldCmdConfig> {
if (!isInteractive()) {
return config
}
if (!config.name) {
config.name = await promptForName()
}
if (configMap && !config.key) {
const keys = Object.keys(configMap)
if (keys.length > 1) {
config.key = await promptForTemplateKey(configMap)
}
}
return config
}
/**
* Prompts for any values still missing after the config file has been parsed.
* Only prompts in interactive mode.
*/
export async function promptAfterConfig(config: ScaffoldConfig): Promise<ScaffoldConfig> {
if (!isInteractive()) {
return config
}
if (!config.output || (typeof config.output === "string" && !config.output)) {
config.output = await promptForOutput()
}
if (!config.templates || config.templates.length === 0) {
config.templates = await promptForTemplates()
}
return config
}
/**
* @deprecated Use {@link promptBeforeConfig} and {@link promptAfterConfig} instead.
*/
export async function promptForMissingConfig(
config: ScaffoldCmdConfig,
configMap?: ScaffoldConfigMap,
): Promise<ScaffoldCmdConfig> {
const afterPre = await promptBeforeConfig(config, configMap)
if (!isInteractive()) {
return afterPre
}
if (!afterPre.output) {
afterPre.output = await promptForOutput()
}
if (!afterPre.templates || afterPre.templates.length === 0) {
afterPre.templates = await promptForTemplates()
}
return afterPre
}
/**
* Prompts for any required inputs defined in the scaffold config and merges them into data.
* Only prompts in interactive mode; in non-interactive mode, only applies defaults.
*/
export async function resolveInputs(config: ScaffoldConfig): Promise<ScaffoldConfig> {
if (!config.inputs) {
return config
}
const interactive = isInteractive()
if (interactive) {
config.data = await promptForInputs(config.inputs, config.data)
} else {
// Non-interactive: only apply defaults
const data = { ...config.data }
for (const [key, def] of Object.entries(config.inputs)) {
if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}
}
config.data = data
}
return config
}

View File

@@ -6,21 +6,18 @@
*/
import path from "node:path"
import os from "node:os"
import { exec } from "node:child_process"
import { handleErr, resolve } from "./utils"
import {
isDir,
removeGlob,
makeRelativePath,
getTemplateGlobInfo,
getFileList,
getBasePath,
handleTemplateFile,
} from "./file"
import { isDir, getTemplateGlobInfo, getFileList, handleTemplateFile, GlobInfo } from "./file"
import { removeGlob, makeRelativePath, getBasePath } from "./path-utils"
import { LogLevel, MinimalConfig, Resolver, ScaffoldCmdConfig, ScaffoldConfig } from "./types"
import { registerHelpers } from "./parser"
import { log, logInitStep, logInputFile } from "./logger"
import { log, logInitStep, logFileTree, logSummary } from "./logger"
import { parseConfigFile } from "./config"
import { resolveInputs } from "./prompts"
import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
import { assertConfigValid } from "./validate"
/**
* Create a scaffold using given `options`.
@@ -57,47 +54,135 @@ import { parseConfigFile } from "./config"
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
config.output ??= process.cwd()
await assertConfigValid(config)
config = await resolveInputs(config)
registerHelpers(config)
const startTime = performance.now()
const writtenFiles: string[] = []
try {
config.data = { name: config.name, ...config.data }
logInitStep(config)
for (let _template of config.templates) {
try {
const { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template } = await getTemplateGlobInfo(
config,
_template,
)
const files = await getFileList(config, template)
log(config, LogLevel.debug, "Iterating files", { files, template })
for (const inputFilePath of files) {
if (await isDir(inputFilePath)) {
continue
}
const relPath = makeRelativePath(path.dirname(removeGlob(inputFilePath).replace(nonGlobTemplate, "")))
const basePath = getBasePath(relPath)
logInputFile(config, {
originalTemplate: origTemplate,
relativePath: relPath,
parsedTemplate: template,
inputFilePath,
nonGlobTemplate,
basePath,
isDirOrGlob,
isGlob,
})
await handleTemplateFile(config, {
templatePath: inputFilePath,
basePath,
})
}
} catch (e: any) {
handleErr(e)
}
log(config, LogLevel.info, `Scaffolding "${config.name}"...`)
const excludes = config.templates.filter((t) => t.startsWith("!"))
const includes = config.templates.filter((t) => !t.startsWith("!"))
const templates = await resolveTemplateGlobs(config, includes)
for (const tpl of templates) {
const files = await processTemplateGlob(config, tpl, excludes)
writtenFiles.push(...files)
}
} catch (e: any) {
} catch (e: unknown) {
log(config, LogLevel.error, e)
throw e
}
const elapsed = performance.now() - startTime
logFileTree(config, writtenFiles)
logSummary(config, writtenFiles.length, elapsed, config.dryRun)
if (config.afterScaffold) {
await runAfterScaffoldHook(config, writtenFiles)
}
}
/** Resolves included template paths into GlobInfo objects. */
async function resolveTemplateGlobs(
config: ScaffoldConfig,
includes: string[],
): Promise<GlobInfo[]> {
const templates: GlobInfo[] = []
for (const includedTemplate of includes) {
try {
templates.push(await getTemplateGlobInfo(config, includedTemplate))
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
}
}
return templates
}
/** Processes all files matching a single template glob pattern. Returns paths of written files. */
async function processTemplateGlob(
config: ScaffoldConfig,
tpl: GlobInfo,
excludes: string[],
): Promise<string[]> {
const written: string[] = []
// Load .scaffoldignore from the template base directory
const ignorePatterns = await loadIgnorePatterns(tpl.baseTemplatePath)
if (ignorePatterns.length > 0) {
log(config, LogLevel.debug, `Loaded .scaffoldignore patterns:`, ignorePatterns)
}
const allFiles = await getFileList(config, [tpl.template, ...excludes])
const files = filterIgnoredFiles(allFiles, ignorePatterns, tpl.baseTemplatePath)
for (const file of files) {
if (await isDir(file)) {
continue
}
log(config, LogLevel.debug, "Iterating files", { files, file })
const relPath = makeRelativePath(
path.dirname(removeGlob(file).replace(tpl.baseTemplatePath, "")),
)
const basePath = getBasePath(relPath)
log(config, LogLevel.debug, {
originalTemplate: tpl.origTemplate,
relativePath: relPath,
parsedTemplate: tpl.template,
inputFilePath: file,
baseTemplatePath: tpl.baseTemplatePath,
basePath,
isDirOrGlob: tpl.isDirOrGlob,
isGlob: tpl.isGlob,
})
const outputPath = await handleTemplateFile(config, { templatePath: file, basePath })
if (outputPath) {
written.push(outputPath)
}
}
return written
}
/** Executes the afterScaffold hook — either a function or a shell command string. */
async function runAfterScaffoldHook(config: ScaffoldConfig, files: string[]): Promise<void> {
const hook = config.afterScaffold!
if (typeof hook === "function") {
log(config, LogLevel.debug, "Running afterScaffold function hook")
await hook({ config, files })
return
}
// Shell command string
const outputDir = typeof config.output === "string" ? config.output : process.cwd()
const cwd = path.resolve(process.cwd(), outputDir)
log(config, LogLevel.info, `Running afterScaffold command: ${hook}`)
await new Promise<void>((resolve, reject) => {
const proc = exec(hook, { cwd })
proc.stdout?.on("data", (data: string) => {
log(config, LogLevel.info, data.toString().trimEnd())
})
proc.stderr?.on("data", (data: string) => {
log(config, LogLevel.warning, data.toString().trimEnd())
})
proc.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`afterScaffold command exited with code ${code}`))
}
})
proc.on("error", reject)
})
}
/**
@@ -112,13 +197,11 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
* @return {Promise<void>} A promise that resolves when the scaffold is complete
*/
Scaffold.fromConfig = async function (
/** The path or URL to the config file */
pathOrUrl: string,
/** Information needed before loading the config */
config: MinimalConfig,
/** Any overrides to the loaded config */
overrides?: Resolver<ScaffoldCmdConfig, Partial<Omit<ScaffoldConfig, "name">>>,
): Promise<void> {
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)
const _cmdConfig: ScaffoldCmdConfig = {
dryRun: false,
output: process.cwd(),
@@ -129,11 +212,11 @@ Scaffold.fromConfig = async function (
quiet: false,
config: pathOrUrl,
version: false,
tmpDir: tmpPath,
...config,
}
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)
const _overrides = resolve(overrides, _cmdConfig)
const _config = await parseConfigFile(_cmdConfig, tmpPath)
const _config = await parseConfigFile(_cmdConfig)
return Scaffold({ ..._config, ..._overrides })
}

View File

@@ -22,12 +22,17 @@ export interface ScaffoldConfig {
* 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.
*
* You may omit files from output by prepending a `!` to their glob pattern.
*
* For example, `["components/**", "!components/README.md"]` will include everything in the directory `components`
* except the `README.md` file inside.
*
* @default Current working directory
*/
templates: string[]
/**
* Path to output to. If `subdir` is `true`, the subfolder will be created inside this path.
* Path to output to. If `subdir` is `true`, the subdir will be created inside this path.
*
* May also be a {@link FileResponseHandler} which returns a new output path to override the default one.
*
@@ -37,7 +42,7 @@ export interface ScaffoldConfig {
output: FileResponse<string>
/**
* Whether to create subfolder with the input name.
* Whether to create subdir with the input name.
*
* When `true`, you may also use {@link subdirHelper} to determine a pre-process helper on
* the directory name.
@@ -51,7 +56,7 @@ export interface ScaffoldConfig {
*
* This can be any object that will be usable by Handlebars.
*/
data?: Record<string, any>
data?: Record<string, unknown>
/**
* Enable to override output files, even if they already exist.
@@ -131,7 +136,7 @@ export interface ScaffoldConfig {
helpers?: Record<string, Helper>
/**
* Default transformer to apply to subfolder name when using `subdir: true`. Can be one of the default
* Default transformer to apply to subdir name when using `subdir: true`. Can be one of the default
* capitalization helpers, or a custom one you provide to `helpers`. Defaults to `undefined`, which means no
* transformation is done.
*
@@ -160,12 +165,121 @@ export interface ScaffoldConfig {
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
/**
* Defines interactive inputs for the template. Each input becomes a template data variable.
*
* When running interactively, required inputs that are not already provided via `data` or CLI args
* will be prompted for. Optional inputs without a value will use their `default` if defined.
*
* @example
* ```typescript
* Scaffold({
* // ...
* inputs: {
* author: { message: "Author name", required: true },
* license: { message: "License", default: "MIT" },
* },
* })
* ```
*
* In templates: `{{ author }}`, `{{ license }}`
*
* @see {@link ScaffoldInput}
*/
inputs?: Record<string, ScaffoldInput>
/**
* A callback or shell command that runs after all files have been written.
*
* When provided as a **function** (Node.js API), it receives a context object with the scaffold
* config and the list of files that were written:
*
* ```typescript
* Scaffold({
* // ...
* afterScaffold: async ({ config, files }) => {
* console.log(`Created ${files.length} files`)
* execSync("npm install", { cwd: config.output })
* },
* })
* ```
*
* When provided as a **string** (CLI `--after` flag), it is executed as a shell command
* in the output directory after scaffolding completes.
*
* @see {@link AfterScaffoldContext}
*/
afterScaffold?: AfterScaffoldHook
/** @internal */
tmpDir?: string
}
/**
* Context passed to the {@link ScaffoldConfig.afterScaffold} hook.
*
* @category Config
*/
export interface AfterScaffoldContext {
/** The resolved scaffold config that was used. */
config: ScaffoldConfig
/** List of absolute paths to files that were written. */
files: string[]
}
/**
* A hook that runs after scaffolding completes.
* Can be a function receiving context, or a shell command string.
*
* @category Config
*/
export type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
/**
* The type of an interactive input prompt.
*
* - `"text"` — free-form text input (default)
* - `"select"` — choose from a list of options
* - `"confirm"` — yes/no boolean prompt
* - `"number"` — numeric input
*
* @category Config
*/
export type ScaffoldInputType = "text" | "select" | "confirm" | "number"
/**
* Defines a single interactive input for a scaffold template.
*
* @example
* ```typescript
* inputs: {
* author: { message: "Author name", required: true },
* license: { type: "select", message: "License", options: ["MIT", "Apache-2.0", "GPL-3.0"] },
* private: { type: "confirm", message: "Private package?", default: false },
* port: { type: "number", message: "Dev server port", default: 3000 },
* }
* ```
*
* @category Config
*/
export interface ScaffoldInput {
/** The type of prompt. Defaults to `"text"`. */
type?: ScaffoldInputType
/** The prompt message shown to the user. Defaults to the input key name if omitted. */
message?: string
/** Whether this input must be provided. If true and missing, the user will be prompted interactively. */
required?: boolean
/** Default value. Type depends on the input type: string for text/select, boolean for confirm, number for number. */
default?: string | boolean | number
/** List of options for `type: "select"`. Each can be a string or `{ name, value }`. */
options?: (string | { name: string; value: string })[]
}
/**
* The names of the available helper functions that relate to text capitalization.
*
* These are available for `subfolderNameHelper`.
* These are available for `subdirHelper`.
*
* | Helper name | Example code | Example output |
* | ------------ | ----------------------- | -------------- |
@@ -331,7 +445,11 @@ export type FileResponse<T> = T | FileResponseHandler<T>
/**
* The Scaffold config for CLI
* Contains less and more specific options than {@link ScaffoldConfig}
* Contains less and more specific options than {@link ScaffoldConfig}.
*
* For more information about each option, see {@link ScaffoldConfig}.
*
* @internal
*/
export type ScaffoldCmdConfig = {
/** The name of the scaffold template to use. */
@@ -340,9 +458,9 @@ export type ScaffoldCmdConfig = {
templates: string[]
/** The output path to write to */
output: string
/** Whether to create subfolder with the input name */
/** Whether to create subdir with the input name */
subdir: boolean
/** Default transformer to apply to subfolder name when using `subdir: true` */
/** Default transformer to apply to subdir name when using `subdir: true` */
subdirHelper?: string
/** Add custom data to the templates */
data?: Record<string, string>
@@ -368,6 +486,12 @@ export type ScaffoldCmdConfig = {
git?: string
/** Display version */
version: boolean
/** 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. */
beforeWrite?: string
/** Run a shell command after all files have been written. Executed in the output directory. */
afterScaffold?: string
/** @internal */
tmpDir?: string
}
/**
@@ -381,6 +505,7 @@ export type ScaffoldCmdConfig = {
*
* @see {@link ScaffoldConfig}
*
* @internal
* @category Config
*/
export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
@@ -392,12 +517,13 @@ export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
* - A promise that resolves to a {@link ScaffoldConfigMap} object
* - A function that returns a promise that resolves to a {@link ScaffoldConfigMap} object
*
* @internal
* @category Config
*/
export type ScaffoldConfigFile = AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>
/** @internal */
export type Resolver<T, R = T> = R | ((value: T) => R)
export type Resolver<T, R = T> = R | ((_value: T) => R)
/** @internal */
export type AsyncResolver<T, R = T> = Resolver<T, Promise<R> | R>
@@ -409,7 +535,11 @@ export type LogConfig = Pick<ScaffoldConfig, "logLevel">
export type ConfigLoadConfig = LogConfig & Pick<ScaffoldCmdConfig, "config">
/** @internal */
export type RemoteConfigLoadConfig = LogConfig & Pick<ScaffoldCmdConfig, "config" | "git"> & { tmpPath: string }
export type RemoteConfigLoadConfig = LogConfig &
Pick<ScaffoldCmdConfig, "config" | "git" | "tmpDir">
/** @internal */
export type MinimalConfig = Pick<ScaffoldCmdConfig, "name" | "key">
/** @internal */
export type ListCommandCliOptions = Pick<ScaffoldCmdConfig, "config" | "git" | "logLevel" | "quiet">

View File

@@ -1,13 +1,19 @@
import { Resolver } from "./types"
// Re-export colors for backward compatibility
export { colorize, type TermColor } from "./colors"
/** Throws the error if non-null, no-ops otherwise. */
export function handleErr(err: NodeJS.ErrnoException | null): void {
if (err) throw err
}
/** Resolves a value that may be either a static value or a function that produces one. */
export function resolve<T, R = T>(resolver: Resolver<T, R>, arg: T): R {
return typeof resolver === "function" ? (resolver as (value: T) => R)(arg) : (resolver as R)
}
/** Wraps a static value in a resolver function. If already a function, returns as-is. */
export function wrapNoopResolver<T, R = T>(value: Resolver<T, R>): Resolver<T, R> {
if (typeof value === "function") {
return value

171
src/validate.ts Normal file
View File

@@ -0,0 +1,171 @@
import { z } from "zod/v4"
import { pathExists } from "./fs-utils"
// --- Reusable schemas ---
/** Schema for a JavaScript function value. */
const functionSchema = z
.any()
.refine((v) => typeof v === "function", { message: "Expected a function" })
/** Schema for a value that can be either a string or a function. */
const stringOrFunctionSchema = z.union([z.string(), functionSchema])
/** Schema for a value that can be either a boolean or a function. */
const booleanOrFunctionSchema = z.union([z.boolean(), functionSchema])
/** Schema for a select input option — either a plain string or a `{ name, value }` object. */
const selectOptionSchema = z.union([z.string(), z.object({ name: z.string(), value: z.string() })])
/** Schema for the input type enum. */
const inputTypeSchema = z.enum(["text", "select", "confirm", "number"])
/** Schema for the log level enum. */
const logLevelSchema = z.enum(["none", "debug", "info", "warning", "error"])
// --- Input schema ---
/** Zod schema for a single scaffold input definition. */
const scaffoldInputSchema = z.object({
type: inputTypeSchema.optional(),
message: z.string().optional(),
required: z.boolean().optional(),
default: z.union([z.string(), z.boolean(), z.number()]).optional(),
options: z.array(selectOptionSchema).optional(),
})
type InputDef = z.infer<typeof scaffoldInputSchema>
function validateInputSemantics(
key: string,
input: InputDef,
): { path: (string | number)[]; message: string }[] {
const issues: { path: (string | number)[]; message: string }[] = []
if (input.type === "select" && (!input.options || input.options.length === 0)) {
issues.push({
path: ["inputs", key, "options"],
message: "select input must have a non-empty options array",
})
}
if (
input.type === "confirm" &&
input.default !== undefined &&
typeof input.default !== "boolean"
) {
issues.push({
path: ["inputs", key, "default"],
message: "confirm input default must be a boolean",
})
}
if (input.type === "number" && input.default !== undefined && typeof input.default !== "number") {
issues.push({
path: ["inputs", key, "default"],
message: "number input default must be a number",
})
}
return issues
}
// --- Config schema ---
/** Zod schema for ScaffoldConfig. */
const scaffoldConfigSchema = z
.object({
name: z.string().min(1, "name is required"),
templates: z.array(z.string()).min(1, "templates must contain at least one entry"),
output: stringOrFunctionSchema,
subdir: z.boolean().optional(),
data: z.record(z.string(), z.unknown()).optional(),
overwrite: booleanOrFunctionSchema.optional(),
logLevel: logLevelSchema.optional(),
dryRun: z.boolean().optional(),
helpers: z.record(z.string(), functionSchema).optional(),
subdirHelper: z.string().optional(),
inputs: z.record(z.string(), scaffoldInputSchema).optional(),
beforeWrite: functionSchema.optional(),
afterScaffold: stringOrFunctionSchema.optional(),
tmpDir: z.string().optional(),
})
.check((ctx) => {
const config = ctx.value
if (config.subdirHelper && !config.subdir) {
ctx.issues.push({
code: "custom",
message: "subdirHelper is set but subdir is not enabled",
path: ["subdirHelper"],
input: config,
})
}
if (config.inputs) {
for (const [key, val] of Object.entries(config.inputs)) {
for (const issue of validateInputSemantics(key, val)) {
ctx.issues.push({ code: "custom", ...issue, input: config })
}
}
}
})
export {
scaffoldConfigSchema,
scaffoldInputSchema,
functionSchema,
stringOrFunctionSchema,
booleanOrFunctionSchema,
selectOptionSchema,
inputTypeSchema,
logLevelSchema,
}
/**
* Validates a scaffold config and returns a list of human-readable errors.
* Returns an empty array if the config is valid.
*/
export function validateConfig(config: unknown): string[] {
const result = scaffoldConfigSchema.safeParse(config)
if (result.success) {
return []
}
return result.error.issues.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"
return `${path}: ${issue.message}`
})
}
/**
* Validates template paths exist on disk.
* Only checks non-glob, non-negation paths.
*/
export async function validateTemplatePaths(templates: string[]): Promise<string[]> {
const errors: string[] = []
for (const tpl of templates) {
if (tpl.startsWith("!") || tpl.includes("*")) continue
if (!(await pathExists(tpl))) {
errors.push(`templates: path does not exist: ${tpl}`)
}
}
return errors
}
/**
* Validates the config and throws a formatted error if any issues are found.
* Checks both schema validity and template path existence.
*/
export async function assertConfigValid(config: unknown): Promise<void> {
const schemaErrors = validateConfig(config)
const pathErrors =
config &&
typeof config === "object" &&
"templates" in config &&
Array.isArray((config as { templates: unknown }).templates)
? await validateTemplatePaths((config as { templates: string[] }).templates)
: []
const allErrors = [...schemaErrors, ...pathErrors]
if (allErrors.length > 0) {
const lines = allErrors.map((e) => ` - ${e}`)
throw new Error(`Invalid scaffold config:\n${lines.join("\n")}`)
}
}

View File

@@ -1,17 +1,19 @@
import { describe, test, expect, beforeEach, afterEach, beforeAll, vi } from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import { Console } from "console"
import { LogLevel, ScaffoldCmdConfig } from "../src/types"
import { LogLevel, ScaffoldCmdConfig, ScaffoldConfig } from "../src/types"
import * as config from "../src/config"
import { resolve } from "../src/utils"
// @ts-ignore
import * as configFile from "../scaffold.config"
import { findConfigFile } from "../src/git"
import configFile from "./test-config"
import { findConfigFile, getOptionValueForFile } from "../src/config"
import { registerHelpers } from "../src/parser"
import path from "path"
jest.mock("../src/git", () => {
vi.mock("../src/git", async () => {
const actual = await vi.importActual<typeof import("../src/git")>("../src/git")
return {
__esModule: true,
...jest.requireActual("../src/git"),
...actual,
getGitConfig: () => {
return Promise.resolve(blankCliConf)
},
@@ -53,52 +55,225 @@ describe("config", () => {
})
test("works with quotes", () => {
expect(parseAppendData('key="value test"', blankCliConf)).toEqual({ key: "value test", name: "test" })
expect(parseAppendData('key="value test"', blankCliConf)).toEqual({
key: "value test",
name: "test",
})
})
test("handles JSON array values with :=", () => {
expect(parseAppendData('items:=["a","b"]', blankCliConf)).toEqual({
items: ["a", "b"],
name: "test",
})
})
test("handles JSON boolean with :=", () => {
expect(parseAppendData("flag:=true", blankCliConf)).toEqual({ flag: true, name: "test" })
expect(parseAppendData("flag:=false", blankCliConf)).toEqual({ flag: false, name: "test" })
})
test("handles JSON null with :=", () => {
expect(parseAppendData("val:=null", blankCliConf)).toEqual({ val: null, name: "test" })
})
test("handles JSON object with :=", () => {
expect(parseAppendData('obj:={"a":1}', blankCliConf)).toEqual({ obj: { a: 1 }, name: "test" })
})
test("handles single quoted values", () => {
expect(parseAppendData("key='value test'", blankCliConf)).toEqual({
key: "value test",
name: "test",
})
})
test("handles empty string value", () => {
expect(parseAppendData("key=", blankCliConf)).toEqual({ key: "", name: "test" })
})
test("handles negative number with :=", () => {
expect(parseAppendData("num:=-42", blankCliConf)).toEqual({ num: -42, name: "test" })
})
test("handles float with :=", () => {
expect(parseAppendData("num:=3.14", blankCliConf)).toEqual({ num: 3.14, name: "test" })
})
})
describe("githubPartToUrl", () => {
test("works", () => {
expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual("https://github.com/chenasraf/simple-scaffold.git")
expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual(
"https://github.com/chenasraf/simple-scaffold.git",
)
expect(githubPartToUrl("chenasraf/simple-scaffold.git")).toEqual(
"https://github.com/chenasraf/simple-scaffold.git",
)
})
test("handles organization repos", () => {
expect(githubPartToUrl("org/sub-repo")).toEqual("https://github.com/org/sub-repo.git")
})
test("handles repos with dots in name", () => {
expect(githubPartToUrl("user/my.repo")).toEqual("https://github.com/user/my.repo.git")
})
})
describe("parseConfigFile", () => {
test("normal config does not change", async () => {
const tmpDir = `/tmp/scaffold-config-${Date.now()}`
const { quiet: _, tmpDir: _tmpDir, version: __, ...conf } = blankCliConf
expect(
await parseConfigFile(
{
...blankCliConf,
},
`/tmp/scaffold-config-${Date.now()}`,
),
).toEqual(blankCliConf)
await parseConfigFile({
...blankCliConf,
name: "-",
tmpDir,
}),
).toEqual({ ...conf, name: "-", tmpDir, subdirHelper: undefined, beforeWrite: undefined })
})
describe("appendData", () => {
test("appends", async () => {
const result = await parseConfigFile(
{
...blankCliConf,
appendData: { key: "value" },
},
`/tmp/scaffold-config-${Date.now()}`,
)
const result = await parseConfigFile({
...blankCliConf,
name: "-",
appendData: { key: "value" },
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result?.data?.key).toEqual("value")
})
test("overwrites existing value", async () => {
const result = await parseConfigFile(
{
...blankCliConf,
data: { num: "123" },
appendData: { num: "1234" },
},
`/tmp/scaffold-config-${Date.now()}`,
)
const result = await parseConfigFile({
...blankCliConf,
name: "-",
data: { num: "123" },
appendData: { num: "1234" },
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result?.data?.num).toEqual("1234")
})
test("CLI output overrides config file output", async () => {
const tmpDir = `/tmp/scaffold-config-${Date.now()}`
const result = await parseConfigFile({
...blankCliConf,
config: path.resolve(__dirname, "test-config.js"),
key: "component",
output: "examples/test-output/override",
name: "Component",
tmpDir,
})
expect(result.output).toEqual("examples/test-output/override")
})
})
test("throws when name is missing", async () => {
await expect(
parseConfigFile({
...blankCliConf,
name: "",
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
}),
).rejects.toThrow("Missing required option: name")
})
test("preserves dryRun setting", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
dryRun: true,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.dryRun).toBe(true)
})
test("preserves subdir setting", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
subdir: true,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.subdir).toBe(true)
})
test("preserves overwrite setting", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
overwrite: true,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.overwrite).toBe(true)
})
test("merges data from config and appendData", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
data: { key1: "val1" },
appendData: { key2: "val2" },
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.data).toEqual({ key1: "val1", key2: "val2" })
})
test("appendData overrides data", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
data: { key: "original" },
appendData: { key: "overridden" },
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.data?.key).toEqual("overridden")
})
test("sets subdirHelper from config", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
subdirHelper: "pascalCase",
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.subdirHelper).toEqual("pascalCase")
})
test("handles empty templates array", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
templates: [],
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.templates).toEqual([])
})
test("throws when config key not found", async () => {
await expect(
parseConfigFile({
...blankCliConf,
name: "test",
config: path.resolve(__dirname, "test-config.js"),
key: "nonexistent",
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
}),
).rejects.toThrow('Template "nonexistent" not found')
})
test("uses default key when key not specified", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "MyComponent",
templates: undefined as unknown as string[],
config: path.resolve(__dirname, "test-config.js"),
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.templates.length).toBeGreaterThan(0)
})
})
@@ -107,7 +282,7 @@ describe("config", () => {
const resultFn = await config.getRemoteConfig({
git: "https://github.com/chenasraf/simple-scaffold.git",
logLevel: LogLevel.none,
tmpPath: `/tmp/scaffold-config-${Date.now()}`,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
const result = await resolve(resultFn, blankCliConf)
expect(result).toEqual(blankCliConf)
@@ -115,14 +290,68 @@ describe("config", () => {
test("gets local file config", async () => {
const resultFn = await config.getLocalConfig({
config: "scaffold.config.js",
config: path.join(__dirname, "test-config.js"),
logLevel: LogLevel.none,
})
const result = await resolve(resultFn, {} as any)
const result = (await resolve(resultFn, {} as ScaffoldCmdConfig)).default
expect(result).toEqual(configFile)
})
})
describe("getRemoteConfig", () => {
test("throws for unsupported protocol", async () => {
await expect(
config.getRemoteConfig({
git: "ftp://example.com/repo.git",
logLevel: LogLevel.none,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
}),
).rejects.toThrow("Unsupported protocol")
})
})
describe("getOptionValueForFile", () => {
const conf: ScaffoldConfig = {
name: "test",
output: "output",
templates: [],
logLevel: LogLevel.none,
data: { name: "test" },
}
beforeAll(() => {
registerHelpers(conf)
})
test("returns static string value", () => {
expect(getOptionValueForFile(conf, "/some/path", "static-value")).toEqual("static-value")
})
test("returns static boolean value", () => {
expect(getOptionValueForFile(conf, "/some/path", true)).toBe(true)
expect(getOptionValueForFile(conf, "/some/path", false)).toBe(false)
})
test("calls function with file path info", () => {
const fn = vi.fn().mockReturnValue("custom-output")
const result = getOptionValueForFile(conf, "/home/user/file.txt", fn)
expect(result).toEqual("custom-output")
expect(fn).toHaveBeenCalledWith("/home/user/file.txt", expect.any(String), expect.any(String))
})
test("returns default value when fn is not a function and no value", () => {
expect(
getOptionValueForFile(conf, "/some/path", undefined as unknown as string, "default"),
).toEqual("default")
})
test("function receives parsed basename", () => {
const fn = (_fullPath: string, _basedir: string, basename: string) => basename
const result = getOptionValueForFile(conf, "/home/user/{{name}}.txt", fn)
expect(result).toEqual("test.txt")
})
})
describe("findConfigFile", () => {
const struct1 = {
"scaffold.config.js": `module.exports = '${JSON.stringify(blankConfig)}'`,
@@ -137,7 +366,7 @@ describe("config", () => {
"scaffold.json": JSON.stringify(blankConfig),
}
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction {
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
return () => {
beforeEach(() => {
// console.log("Mocking:", fileStruct)
@@ -158,12 +387,153 @@ describe("config", () => {
for (const struct of [struct1, struct2, struct3, struct4]) {
const [k] = Object.keys(struct)
describe(`finds config file ${k}`, () => {
withMock(struct, async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(k)
})
})
describe(
`finds config file ${k}`,
withMock(struct, () => {
test(`finds ${k}`, async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(k)
})
}),
)
}
describe(
"finds .mjs config file",
withMock({ "scaffold.config.mjs": "export default {}" }, () => {
test("finds scaffold.config.mjs", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.mjs")
})
}),
)
describe(
"priority order",
withMock(
{
"scaffold.config.js": "module.exports = {}",
"scaffold.js": "module.exports = {}",
},
() => {
test("prefers scaffold.config.js over scaffold.js", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.js")
})
},
),
)
describe(
"throws when no config found",
withMock({ "unrelated-file.txt": "content" }, () => {
test("throws error when no config file exists", async () => {
await expect(findConfigFile(process.cwd())).rejects.toThrow("Could not find config file")
})
}),
)
describe(
"finds scaffold.config.cjs",
withMock({ "scaffold.config.cjs": "module.exports = {}" }, () => {
test("finds .cjs config file", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.cjs")
})
}),
)
describe(
"finds scaffold.config.json",
withMock({ "scaffold.config.json": "{}" }, () => {
test("finds .json config file", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.json")
})
}),
)
describe(
"finds scaffold.mjs",
withMock({ "scaffold.mjs": "export default {}" }, () => {
test("finds scaffold.mjs", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.mjs")
})
}),
)
describe(
"finds scaffold.cjs",
withMock({ "scaffold.cjs": "module.exports = {}" }, () => {
test("finds scaffold.cjs", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.cjs")
})
}),
)
describe(
"finds scaffold.json",
withMock({ "scaffold.json": "{}" }, () => {
test("finds scaffold.json", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.json")
})
}),
)
describe(
"finds .scaffold.js",
withMock({ ".scaffold.js": "module.exports = {}" }, () => {
test("finds dotfile config", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(".scaffold.js")
})
}),
)
describe(
"finds .scaffold.json",
withMock({ ".scaffold.json": "{}" }, () => {
test("finds dotfile json config", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(".scaffold.json")
})
}),
)
describe(
"prefers scaffold.config over .scaffold",
withMock(
{
"scaffold.config.js": "module.exports = {}",
".scaffold.js": "module.exports = {}",
},
() => {
test("prefers scaffold.config.js over .scaffold.js", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.js")
})
},
),
)
describe(
"prefers scaffold over .scaffold",
withMock(
{
"scaffold.js": "module.exports = {}",
".scaffold.js": "module.exports = {}",
},
() => {
test("prefers scaffold.js over .scaffold.js", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.js")
})
},
),
)
})
})

456
tests/file.test.ts Normal file
View File

@@ -0,0 +1,456 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import { Console } from "console"
import path from "node:path"
import os from "node:os"
import {
removeGlob,
makeRelativePath,
getBasePath,
getOutputDir,
getUniqueTmpPath,
pathExists,
createDirIfNotExists,
getTemplateGlobInfo,
getTemplateFileInfo,
copyFileTransformed,
handleTemplateFile,
getFileList,
} from "../src/file"
import { ScaffoldConfig, LogLevel } from "../src/types"
import { registerHelpers } from "../src/parser"
import { readFileSync } from "fs"
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
return () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
mockFs(fileStruct)
})
testFn()
afterEach(() => {
mockFs.restore()
})
}
}
const baseConfig: ScaffoldConfig = {
name: "test_app",
output: "output",
templates: ["input"],
logLevel: LogLevel.none,
data: { name: "test_app" },
tmpDir: ".",
}
describe("file utilities", () => {
describe("removeGlob", () => {
test("removes single wildcard", () => {
expect(removeGlob("input/*")).toEqual(path.normalize("input/"))
})
test("removes double wildcard", () => {
expect(removeGlob("input/**/*")).toEqual(path.normalize("input///"))
})
test("returns path unchanged when no glob", () => {
expect(removeGlob("input/file.txt")).toEqual(path.normalize("input/file.txt"))
})
test("removes wildcards from nested path", () => {
expect(removeGlob("a/b/*/c/**")).toEqual(path.normalize("a/b//c//"))
})
test("handles empty string", () => {
expect(removeGlob("")).toEqual(".")
})
})
describe("makeRelativePath", () => {
test("removes leading separator", () => {
expect(makeRelativePath(path.sep + "some/path")).toEqual("some/path")
})
test("returns path unchanged if no leading separator", () => {
expect(makeRelativePath("some/path")).toEqual("some/path")
})
test("handles empty string", () => {
expect(makeRelativePath("")).toEqual("")
})
test("removes only the first separator", () => {
expect(makeRelativePath(path.sep + "a" + path.sep + "b")).toEqual("a" + path.sep + "b")
})
})
describe("getBasePath", () => {
test("resolves relative path against cwd", () => {
const result = getBasePath("some/path")
expect(result).toEqual("some/path")
})
test("handles empty string", () => {
const result = getBasePath("")
expect(result).toEqual("")
})
test("handles current directory", () => {
const result = getBasePath(".")
expect(result).toEqual("")
})
})
describe("getOutputDir", () => {
test("returns output path without subdir", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: false }
registerHelpers(config)
const result = getOutputDir(config, "output", "")
expect(result).toEqual(path.resolve(process.cwd(), "output"))
})
test("returns output path with subdir", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: true }
registerHelpers(config)
const result = getOutputDir(config, "output", "")
expect(result).toEqual(path.resolve(process.cwd(), "output", "test_app"))
})
test("applies subdirHelper to subdir name", () => {
const config: ScaffoldConfig = {
...baseConfig,
subdir: true,
subdirHelper: "pascalCase",
}
registerHelpers(config)
const result = getOutputDir(config, "output", "")
expect(result).toEqual(path.resolve(process.cwd(), "output", "TestApp"))
})
test("includes basePath in output", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: false }
registerHelpers(config)
const result = getOutputDir(config, "output", "nested/dir")
expect(result).toEqual(path.resolve(process.cwd(), "output", "nested/dir"))
})
test("combines output, basePath, and subdir", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: true }
registerHelpers(config)
const result = getOutputDir(config, "output", "nested")
expect(result).toEqual(path.resolve(process.cwd(), "output", "nested", "test_app"))
})
})
describe("getUniqueTmpPath", () => {
test("returns a path in os temp directory", () => {
const result = getUniqueTmpPath()
expect(result.startsWith(os.tmpdir())).toBe(true)
})
test("includes scaffold-config prefix", () => {
const result = getUniqueTmpPath()
expect(path.basename(result)).toMatch(/^scaffold-config-/)
})
test("generates unique paths", () => {
const a = getUniqueTmpPath()
const b = getUniqueTmpPath()
expect(a).not.toEqual(b)
})
})
describe(
"pathExists",
withMock(
{
"existing-file.txt": "content",
"existing-dir": {},
},
() => {
test("returns true for existing file", async () => {
expect(await pathExists("existing-file.txt")).toBe(true)
})
test("returns true for existing directory", async () => {
expect(await pathExists("existing-dir")).toBe(true)
})
test("returns false for non-existing path", async () => {
expect(await pathExists("non-existing")).toBe(false)
})
},
),
)
describe(
"createDirIfNotExists",
withMock({}, () => {
test("creates directory", async () => {
await createDirIfNotExists("new-dir", { logLevel: LogLevel.none, dryRun: false })
expect(await pathExists("new-dir")).toBe(true)
})
test("creates nested directories recursively", async () => {
await createDirIfNotExists("a/b/c", { logLevel: LogLevel.none, dryRun: false })
expect(await pathExists("a/b/c")).toBe(true)
})
test("does not create directory in dry run mode", async () => {
await createDirIfNotExists("dry-dir", { logLevel: LogLevel.none, dryRun: true })
expect(await pathExists("dry-dir")).toBe(false)
})
test("does not throw if directory already exists", async () => {
await createDirIfNotExists("existing", { logLevel: LogLevel.none, dryRun: false })
await expect(
createDirIfNotExists("existing", { logLevel: LogLevel.none, dryRun: false }),
).resolves.toBeUndefined()
})
}),
)
describe(
"getTemplateGlobInfo",
withMock(
{
"template-dir": {
"file1.txt": "content1",
"file2.txt": "content2",
},
"single-file.txt": "content",
},
() => {
test("detects directory template", async () => {
const result = await getTemplateGlobInfo(baseConfig, "template-dir")
expect(result.isDirOrGlob).toBe(true)
expect(result.isGlob).toBe(false)
expect(result.template).toEqual(path.join("template-dir", "**", "*"))
})
test("detects glob template", async () => {
const result = await getTemplateGlobInfo(baseConfig, "template-dir/**/*.txt")
expect(result.isDirOrGlob).toBe(true)
expect(result.isGlob).toBe(true)
})
test("preserves non-glob single file", async () => {
const result = await getTemplateGlobInfo(baseConfig, "single-file.txt")
expect(result.isDirOrGlob).toBe(false)
expect(result.isGlob).toBe(false)
expect(result.template).toEqual("single-file.txt")
})
test("stores original template", async () => {
const result = await getTemplateGlobInfo(baseConfig, "template-dir")
expect(result.origTemplate).toEqual("template-dir")
})
},
),
)
describe(
"getFileList",
withMock(
{
templates: {
"file1.txt": "content1",
"file2.js": "content2",
".hidden": "hidden content",
nested: {
"file3.txt": "content3",
},
},
},
() => {
test("lists all files with glob", async () => {
const files = await getFileList(baseConfig, ["templates/**/*"])
expect(files.length).toBe(4)
})
test("includes dotfiles", async () => {
const files = await getFileList(baseConfig, ["templates/**/*"])
expect(files.some((f) => f.includes(".hidden"))).toBe(true)
})
test("filters by extension", async () => {
const files = await getFileList(baseConfig, ["templates/**/*.txt"])
expect(files.length).toBe(2)
expect(files.every((f) => f.endsWith(".txt"))).toBe(true)
})
test("supports exclusion patterns", async () => {
const files = await getFileList(baseConfig, ["templates/**/*.txt"])
expect(files.some((f) => f.includes(".hidden"))).toBe(false)
expect(files.every((f) => f.endsWith(".txt"))).toBe(true)
})
},
),
)
describe(
"getTemplateFileInfo",
withMock(
{
input: {
"{{name}}.txt": "Hello {{name}}",
},
output: {},
},
() => {
test("calculates correct output path", async () => {
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
const info = await getTemplateFileInfo(config, {
templatePath: "input/{{name}}.txt",
basePath: "",
})
expect(info.inputPath).toEqual(path.resolve(process.cwd(), "input/{{name}}.txt"))
expect(info.outputPath).toContain("test_app.txt")
expect(info.exists).toBe(false)
})
test("detects existing output file", async () => {
mockFs.restore()
mockFs({
input: { "{{name}}.txt": "Hello {{name}}" },
output: { "test_app.txt": "existing" },
})
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
const info = await getTemplateFileInfo(config, {
templatePath: "input/{{name}}.txt",
basePath: "",
})
expect(info.exists).toBe(true)
})
},
),
)
describe(
"copyFileTransformed",
withMock(
{
"input.txt": "Hello {{name}}",
output: {},
},
() => {
test("writes transformed content", async () => {
const config: ScaffoldConfig = { ...baseConfig }
registerHelpers(config)
await createDirIfNotExists("output", { logLevel: LogLevel.none, dryRun: false })
await copyFileTransformed(config, {
exists: false,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("Hello test_app")
})
test("does not write in dry run mode", async () => {
const config: ScaffoldConfig = { ...baseConfig, dryRun: true }
registerHelpers(config)
await copyFileTransformed(config, {
exists: false,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
expect(await pathExists("output/result.txt")).toBe(false)
})
test("skips existing file without overwrite", async () => {
mockFs.restore()
mockFs({
"input.txt": "Hello {{name}}",
output: { "result.txt": "original" },
})
const config: ScaffoldConfig = { ...baseConfig }
registerHelpers(config)
await copyFileTransformed(config, {
exists: true,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("original")
})
test("overwrites existing file with overwrite flag", async () => {
mockFs.restore()
mockFs({
"input.txt": "Hello {{name}}",
output: { "result.txt": "original" },
})
const config: ScaffoldConfig = { ...baseConfig }
registerHelpers(config)
await copyFileTransformed(config, {
exists: true,
overwrite: true,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("Hello test_app")
})
test("calls beforeWrite callback", async () => {
const config: ScaffoldConfig = {
...baseConfig,
beforeWrite: (content) => content.toString().toUpperCase(),
}
registerHelpers(config)
await createDirIfNotExists("output", { logLevel: LogLevel.none, dryRun: false })
await copyFileTransformed(config, {
exists: false,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("HELLO TEST_APP")
})
},
),
)
describe(
"handleTemplateFile",
withMock(
{
input: {
"{{name}}.txt": "Content for {{name}}",
},
output: {},
},
() => {
test("processes template file end to end", async () => {
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
await handleTemplateFile(config, {
templatePath: "input/{{name}}.txt",
basePath: "",
})
const content = readFileSync(path.join("output", "test_app.txt")).toString()
expect(content).toEqual("Content for test_app")
})
test("throws for non-existing template", async () => {
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
await expect(
handleTemplateFile(config, {
templatePath: "non-existing.txt",
basePath: "",
}),
).rejects.toThrow()
})
},
),
)
})

122
tests/ignore.test.ts Normal file
View File

@@ -0,0 +1,122 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import { Console } from "console"
import path from "node:path"
import { parseIgnoreFile, loadIgnorePatterns, filterIgnoredFiles } from "../src/ignore"
describe("ignore", () => {
describe("parseIgnoreFile", () => {
test("parses patterns", () => {
const result = parseIgnoreFile("node_modules\n*.log\n")
expect(result).toEqual(["node_modules", "*.log"])
})
test("skips comments", () => {
const result = parseIgnoreFile("# comment\nfoo\n# another\nbar")
expect(result).toEqual(["foo", "bar"])
})
test("skips empty lines", () => {
const result = parseIgnoreFile("foo\n\n\nbar\n")
expect(result).toEqual(["foo", "bar"])
})
test("trims whitespace", () => {
const result = parseIgnoreFile(" foo \n bar ")
expect(result).toEqual(["foo", "bar"])
})
test("handles empty file", () => {
expect(parseIgnoreFile("")).toEqual([])
})
test("handles comments-only file", () => {
expect(parseIgnoreFile("# just comments\n# nothing here")).toEqual([])
})
})
describe("filterIgnoredFiles", () => {
test("filters files matching patterns", () => {
const files = ["templates/file.txt", "templates/debug.log", "templates/other.js"]
const result = filterIgnoredFiles(files, ["*.log"], "templates")
expect(result).toEqual(["templates/file.txt", "templates/other.js"])
})
test("always excludes .scaffoldignore", () => {
const files = ["templates/file.txt", "templates/.scaffoldignore"]
const result = filterIgnoredFiles(files, [], "templates")
expect(result).toEqual(["templates/file.txt"])
})
test("matches by relative path", () => {
const files = ["templates/src/index.ts", "templates/dist/index.js", "templates/src/utils.ts"]
const result = filterIgnoredFiles(files, ["dist/**"], "templates")
expect(result).toEqual(["templates/src/index.ts", "templates/src/utils.ts"])
})
test("matches by basename", () => {
const files = ["templates/README.md", "templates/nested/README.md", "templates/file.txt"]
const result = filterIgnoredFiles(files, ["README.md"], "templates")
expect(result).toEqual(["templates/file.txt"])
})
test("handles multiple patterns", () => {
const files = ["tpl/a.txt", "tpl/b.log", "tpl/c.tmp", "tpl/d.ts"]
const result = filterIgnoredFiles(files, ["*.log", "*.tmp"], "tpl")
expect(result).toEqual(["tpl/a.txt", "tpl/d.ts"])
})
test("returns all files when no patterns", () => {
const files = ["tpl/a.txt", "tpl/b.txt"]
const result = filterIgnoredFiles(files, [], "tpl")
expect(result).toEqual(files)
})
test("handles glob patterns with directories", () => {
const files = [
path.join("tpl", "src", "index.ts"),
path.join("tpl", "node_modules", "pkg", "index.js"),
path.join("tpl", "file.txt"),
]
const result = filterIgnoredFiles(files, ["node_modules/**"], "tpl")
expect(result).toEqual([path.join("tpl", "src", "index.ts"), path.join("tpl", "file.txt")])
})
})
describe("loadIgnorePatterns", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
})
afterEach(() => {
mockFs.restore()
})
test("returns empty array when no .scaffoldignore exists", async () => {
mockFs({ templates: { "file.txt": "content" } })
const result = await loadIgnorePatterns("templates")
expect(result).toEqual([])
})
test("reads and parses .scaffoldignore", async () => {
mockFs({
templates: {
".scaffoldignore": "*.log\nnode_modules\n",
"file.txt": "content",
},
})
const result = await loadIgnorePatterns("templates")
expect(result).toEqual(["*.log", "node_modules"])
})
test("ignores comments in .scaffoldignore", async () => {
mockFs({
templates: {
".scaffoldignore": "# This is a comment\n*.tmp\n",
},
})
const result = await loadIgnorePatterns("templates")
expect(result).toEqual(["*.tmp"])
})
})
})

115
tests/init.test.ts Normal file
View File

@@ -0,0 +1,115 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import { Console } from "console"
import { readFileSync, existsSync } from "fs"
import path from "path"
vi.mock("@inquirer/input", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/select", () => ({
default: vi.fn(),
}))
import selectMock from "@inquirer/select"
import { initScaffold } from "../src/init"
describe("init", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
vi.clearAllMocks()
mockFs({})
})
afterEach(() => {
mockFs.restore()
})
test("creates js config and example template", async () => {
await initScaffold({ format: "js", dir: process.cwd() })
expect(existsSync("scaffold.config.js")).toBe(true)
const config = readFileSync("scaffold.config.js", "utf-8")
expect(config).toContain("module.exports")
expect(config).toContain("templates/default")
expect(existsSync(path.join("templates", "default", "{{name}}.md"))).toBe(true)
const template = readFileSync(path.join("templates", "default", "{{name}}.md"), "utf-8")
expect(template).toContain("{{ name }}")
})
test("creates mjs config", async () => {
await initScaffold({ format: "mjs", dir: process.cwd() })
expect(existsSync("scaffold.config.mjs")).toBe(true)
const config = readFileSync("scaffold.config.mjs", "utf-8")
expect(config).toContain("export default")
})
test("creates json config", async () => {
await initScaffold({ format: "json", dir: process.cwd() })
expect(existsSync("scaffold.config.json")).toBe(true)
const config = readFileSync("scaffold.config.json", "utf-8")
const parsed = JSON.parse(config)
expect(parsed.default).toBeDefined()
expect(parsed.default.templates).toEqual(["templates/default"])
})
test("does not overwrite existing config", async () => {
mockFs.restore()
mockFs({
"scaffold.config.js": "// existing config",
})
await initScaffold({ format: "js", dir: process.cwd() })
const config = readFileSync("scaffold.config.js", "utf-8")
expect(config).toBe("// existing config")
})
test("does not overwrite existing template dir", async () => {
mockFs.restore()
mockFs({
templates: {
default: {
"existing.md": "# Existing",
},
},
})
await initScaffold({ format: "js", dir: process.cwd() })
expect(existsSync(path.join("templates", "default", "existing.md"))).toBe(true)
expect(existsSync(path.join("templates", "default", "{{name}}.md"))).toBe(false)
})
test("skips example template when createExample is false", async () => {
await initScaffold({ format: "js", dir: process.cwd(), createExample: false })
expect(existsSync("scaffold.config.js")).toBe(true)
expect(existsSync("templates")).toBe(false)
})
test("prompts for format when not provided", async () => {
vi.mocked(selectMock).mockResolvedValue("js")
await initScaffold({ dir: process.cwd() })
expect(selectMock).toHaveBeenCalledOnce()
expect(existsSync("scaffold.config.js")).toBe(true)
})
test("creates config in custom directory", async () => {
mockFs.restore()
mockFs({
"my-project": {},
})
await initScaffold({ format: "js", dir: path.resolve("my-project") })
expect(existsSync(path.join("my-project", "scaffold.config.js"))).toBe(true)
expect(existsSync(path.join("my-project", "templates", "default", "{{name}}.md"))).toBe(true)
})
})

175
tests/logger.test.ts Normal file
View File

@@ -0,0 +1,175 @@
import { describe, test, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"
import { log, logInitStep, logInputFile } from "../src/logger"
import { LogLevel, ScaffoldConfig } from "../src/types"
describe("logger", () => {
let consoleSpy: {
log: MockInstance
warn: MockInstance
error: MockInstance
}
beforeEach(() => {
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => void 0),
warn: vi.spyOn(console, "warn").mockImplementation(() => void 0),
error: vi.spyOn(console, "error").mockImplementation(() => void 0),
}
})
afterEach(() => {
consoleSpy.log.mockRestore()
consoleSpy.warn.mockRestore()
consoleSpy.error.mockRestore()
})
describe("log", () => {
test("does not log when logLevel is none", () => {
log({ logLevel: LogLevel.none }, LogLevel.info, "test")
expect(consoleSpy.log).not.toHaveBeenCalled()
})
test("logs info messages with console.log", () => {
log({ logLevel: LogLevel.info }, LogLevel.info, "test message")
expect(consoleSpy.log).toHaveBeenCalled()
})
test("logs warning messages with console.warn", () => {
log({ logLevel: LogLevel.warning }, LogLevel.warning, "warning message")
expect(consoleSpy.warn).toHaveBeenCalled()
})
test("logs error messages with console.error", () => {
log({ logLevel: LogLevel.error }, LogLevel.error, "error message")
expect(consoleSpy.error).toHaveBeenCalled()
})
test("filters out messages below configured level", () => {
log({ logLevel: LogLevel.warning }, LogLevel.info, "should be filtered")
expect(consoleSpy.log).not.toHaveBeenCalled()
})
test("filters out debug messages when level is info", () => {
log({ logLevel: LogLevel.info }, LogLevel.debug, "debug message")
expect(consoleSpy.log).not.toHaveBeenCalled()
})
test("shows debug messages when level is debug", () => {
log({ logLevel: LogLevel.debug }, LogLevel.debug, "debug message")
expect(consoleSpy.log).toHaveBeenCalled()
})
test("shows all levels when configured as debug", () => {
log({ logLevel: LogLevel.debug }, LogLevel.debug, "d")
log({ logLevel: LogLevel.debug }, LogLevel.info, "i")
log({ logLevel: LogLevel.debug }, LogLevel.warning, "w")
log({ logLevel: LogLevel.debug }, LogLevel.error, "e")
expect(consoleSpy.log).toHaveBeenCalledTimes(2) // debug + info
expect(consoleSpy.warn).toHaveBeenCalledTimes(1)
expect(consoleSpy.error).toHaveBeenCalledTimes(1)
})
test("handles Error objects", () => {
log({ logLevel: LogLevel.error }, LogLevel.error, new Error("test error"))
expect(consoleSpy.error).toHaveBeenCalled()
})
test("handles objects with util.inspect", () => {
log({ logLevel: LogLevel.info }, LogLevel.info, { key: "value" })
expect(consoleSpy.log).toHaveBeenCalled()
})
test("handles multiple arguments", () => {
log({ logLevel: LogLevel.info }, LogLevel.info, "a", "b", "c")
expect(consoleSpy.log).toHaveBeenCalled()
// First call, should have 3 arguments
expect(consoleSpy.log.mock.calls[0].length).toBe(3)
})
test("defaults to info when logLevel is undefined", () => {
log({ logLevel: undefined as unknown as LogLevel }, LogLevel.info, "test")
expect(consoleSpy.log).toHaveBeenCalled()
})
test("error level shows when logLevel is info", () => {
log({ logLevel: LogLevel.info }, LogLevel.error, "error")
expect(consoleSpy.error).toHaveBeenCalled()
})
test("warning level shows when logLevel is info", () => {
log({ logLevel: LogLevel.info }, LogLevel.warning, "warning")
expect(consoleSpy.warn).toHaveBeenCalled()
})
})
describe("logInitStep", () => {
test("logs config at debug level", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.debug,
data: { name: "test" },
}
logInitStep(config)
expect(consoleSpy.log).toHaveBeenCalled()
})
test("does not log at info level (debug only)", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.info,
data: { name: "test" },
}
logInitStep(config)
// Full config is debug-only, nothing logged at info
expect(consoleSpy.log).not.toHaveBeenCalled()
})
})
describe("logInputFile", () => {
test("logs file info at debug level", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.debug,
data: { name: "test" },
}
logInputFile(config, {
originalTemplate: "input",
relativePath: ".",
parsedTemplate: "input/**/*",
inputFilePath: "input/file.txt",
nonGlobTemplate: "input",
basePath: "",
isDirOrGlob: true,
isGlob: false,
})
expect(consoleSpy.log).toHaveBeenCalled()
})
test("does not log at info level", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.info,
data: { name: "test" },
}
logInputFile(config, {
originalTemplate: "input",
relativePath: ".",
parsedTemplate: "input/**/*",
inputFilePath: "input/file.txt",
nonGlobTemplate: "input",
basePath: "",
isDirOrGlob: true,
isGlob: false,
})
expect(consoleSpy.log).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,7 +1,8 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { ScaffoldConfig } from "../src/types"
import path from "node:path"
import * as dateFns from "date-fns"
import { dateHelper, defaultHelpers, handlebarsParse, nowHelper } from "../src/parser"
import { dateHelper, defaultHelpers, handlebarsParse, nowHelper, registerHelpers } from "../src/parser"
const blankConf: ScaffoldConfig = {
logLevel: "none",
@@ -13,46 +14,136 @@ const blankConf: ScaffoldConfig = {
describe("parser", () => {
describe("handlebarsParse", () => {
let origSep: any
let origSep: string
describe("windows paths", () => {
beforeAll(() => {
origSep = path.sep
Object.defineProperty(path, "sep", { value: "\\" })
})
afterAll(() => {
Object.defineProperty(path, "sep", { value: origSep })
})
test("should work for windows paths", async () => {
expect(handlebarsParse(blankConf, "C:\\exports\\{{name}}.txt", { isPath: true }).toString()).toEqual(
expect(handlebarsParse(blankConf, "C:\\exports\\{{name}}.txt", { asPath: true }).toString()).toEqual(
"C:\\exports\\test.txt",
)
})
})
describe("non-windows paths", () => {
beforeAll(() => {
origSep = path.sep
Object.defineProperty(path, "sep", { value: "/" })
})
afterAll(() => {
Object.defineProperty(path, "sep", { value: origSep })
})
test("should work for non-windows paths", async () => {
expect(handlebarsParse(blankConf, "/home/test/{{name}}.txt", { isPath: true })).toEqual(
expect(handlebarsParse(blankConf, "/home/test/{{name}}.txt", { asPath: true })).toEqual(
Buffer.from("/home/test/test.txt"),
)
})
})
test("should not do path escaping on non-path compiles", async () => {
expect(
handlebarsParse(
{ ...blankConf, data: { ...blankConf.data, escaped: "value" } },
"/home/test/{{name}} \\{{escaped}}.txt",
{
isPath: false,
asPath: false,
},
),
).toEqual(Buffer.from("/home/test/test {{escaped}}.txt"))
})
test("should replace name token in content", () => {
const result = handlebarsParse(blankConf, "Hello {{name}}")
expect(result.toString()).toEqual("Hello test")
})
test("should replace multiple tokens", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "app", version: "1.0" },
}
expect(handlebarsParse(config, "{{name}} v{{version}}").toString()).toEqual("app v1.0")
})
test("should return Buffer", () => {
expect(Buffer.isBuffer(handlebarsParse(blankConf, "test"))).toBe(true)
})
test("should handle Buffer input", () => {
expect(handlebarsParse(blankConf, Buffer.from("Hello {{name}}")).toString()).toEqual("Hello test")
})
test("should return original content on handlebars error", () => {
const result = handlebarsParse(blankConf, "{{#if}}invalid{{/unless}}")
expect(Buffer.isBuffer(result)).toBe(true)
expect(result.toString()).toEqual("{{#if}}invalid{{/unless}}")
})
test("should handle empty template", () => {
expect(handlebarsParse(blankConf, "").toString()).toEqual("")
})
test("should handle template with no tokens", () => {
expect(handlebarsParse(blankConf, "no tokens here").toString()).toEqual("no tokens here")
})
test("should not escape HTML chars (noEscape)", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "<div>test</div>" },
}
expect(handlebarsParse(config, "{{name}}").toString()).toEqual("<div>test</div>")
})
test("should handle nested data", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", nested: { key: "value" } },
}
expect(handlebarsParse(config, "{{nested.key}}").toString()).toEqual("value")
})
test("should handle handlebars conditionals", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", showExtra: true },
}
registerHelpers(config)
expect(handlebarsParse(config, "{{#if showExtra}}extra{{/if}} content").toString()).toEqual("extra content")
})
test("should handle handlebars conditionals when false", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", showExtra: false },
}
registerHelpers(config)
expect(handlebarsParse(config, "{{#if showExtra}}extra{{/if}}content").toString()).toEqual("content")
})
test("should handle handlebars each loops", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", items: ["a", "b", "c"] },
}
registerHelpers(config)
expect(handlebarsParse(config, "{{#each items}}{{this}},{{/each}}").toString()).toEqual("a,b,c,")
})
test("should render empty for undefined data token", () => {
expect(handlebarsParse(blankConf, "{{undefinedVar}}").toString()).toEqual("")
})
})
describe("Helpers", () => {
@@ -65,6 +156,7 @@ describe("parser", () => {
expect(defaultHelpers.camelCase("TestString")).toEqual("testString")
expect(defaultHelpers.camelCase("Test____String")).toEqual("testString")
})
test("pascalCase", () => {
expect(defaultHelpers.pascalCase("test string")).toEqual("TestString")
expect(defaultHelpers.pascalCase("test_string")).toEqual("TestString")
@@ -73,6 +165,7 @@ describe("parser", () => {
expect(defaultHelpers.pascalCase("TestString")).toEqual("TestString")
expect(defaultHelpers.pascalCase("Test____String")).toEqual("TestString")
})
test("snakeCase", () => {
expect(defaultHelpers.snakeCase("test string")).toEqual("test_string")
expect(defaultHelpers.snakeCase("test_string")).toEqual("test_string")
@@ -81,6 +174,7 @@ describe("parser", () => {
expect(defaultHelpers.snakeCase("TestString")).toEqual("test_string")
expect(defaultHelpers.snakeCase("Test____String")).toEqual("test_string")
})
test("kebabCase", () => {
expect(defaultHelpers.kebabCase("test string")).toEqual("test-string")
expect(defaultHelpers.kebabCase("test_string")).toEqual("test-string")
@@ -89,6 +183,7 @@ describe("parser", () => {
expect(defaultHelpers.kebabCase("TestString")).toEqual("test-string")
expect(defaultHelpers.kebabCase("Test____String")).toEqual("test-string")
})
test("startCase", () => {
expect(defaultHelpers.startCase("test string")).toEqual("Test String")
expect(defaultHelpers.startCase("test_string")).toEqual("Test String")
@@ -98,6 +193,92 @@ describe("parser", () => {
expect(defaultHelpers.startCase("Test____String")).toEqual("Test String")
})
})
describe("string helpers edge cases", () => {
test("camelCase single word", () => {
expect(defaultHelpers.camelCase("hello")).toEqual("hello")
})
test("camelCase empty string", () => {
expect(defaultHelpers.camelCase("")).toEqual("")
})
test("camelCase all uppercase", () => {
expect(defaultHelpers.camelCase("HELLO WORLD")).toEqual("helloWorld")
})
test("pascalCase single word", () => {
expect(defaultHelpers.pascalCase("hello")).toEqual("Hello")
})
test("pascalCase empty string", () => {
expect(defaultHelpers.pascalCase("")).toEqual("")
})
test("snakeCase single word", () => {
expect(defaultHelpers.snakeCase("hello")).toEqual("hello")
})
test("snakeCase empty string", () => {
expect(defaultHelpers.snakeCase("")).toEqual("")
})
test("kebabCase single word", () => {
expect(defaultHelpers.kebabCase("hello")).toEqual("hello")
})
test("kebabCase empty string", () => {
expect(defaultHelpers.kebabCase("")).toEqual("")
})
test("startCase single word", () => {
expect(defaultHelpers.startCase("hello")).toEqual("Hello")
})
test("startCase empty string", () => {
expect(defaultHelpers.startCase("")).toEqual("")
})
test("hyphenCase is same as kebabCase", () => {
expect(defaultHelpers.hyphenCase("testString")).toEqual(defaultHelpers.kebabCase("testString"))
expect(defaultHelpers.hyphenCase("test_string")).toEqual(defaultHelpers.kebabCase("test_string"))
})
test("lowerCase lowercases everything", () => {
expect(defaultHelpers.lowerCase("HELLO")).toEqual("hello")
expect(defaultHelpers.lowerCase("Hello World")).toEqual("hello world")
})
test("upperCase uppercases everything", () => {
expect(defaultHelpers.upperCase("hello")).toEqual("HELLO")
expect(defaultHelpers.upperCase("hello world")).toEqual("HELLO WORLD")
})
test("camelCase handles numbers in string", () => {
expect(defaultHelpers.camelCase("item1_name")).toEqual("item1Name")
})
test("pascalCase handles multiple separators", () => {
expect(defaultHelpers.pascalCase("a--b__c d")).toEqual("ABCD")
})
test("snakeCase handles mixed separators", () => {
expect(defaultHelpers.snakeCase("myApp-name_here")).toEqual("my_app_name_here")
})
test("kebabCase handles mixed separators", () => {
expect(defaultHelpers.kebabCase("myApp-name_here")).toEqual("my-app-name-here")
})
test("single character inputs", () => {
expect(defaultHelpers.camelCase("a")).toEqual("a")
expect(defaultHelpers.pascalCase("a")).toEqual("A")
expect(defaultHelpers.snakeCase("a")).toEqual("a")
expect(defaultHelpers.kebabCase("a")).toEqual("a")
expect(defaultHelpers.startCase("a")).toEqual("A")
})
})
describe("date helpers", () => {
describe("now", () => {
test("should work without extra params", () => {
@@ -127,7 +308,122 @@ describe("parser", () => {
dateFns.format(dateFns.add(now, { months: 1 }), fmt),
)
})
test("should work with years offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "yyyy", 1, "years")).toEqual(
dateFns.format(dateFns.add(date, { years: 1 }), "yyyy"),
)
})
test("should work with weeks offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "yyyy-MM-dd", 2, "weeks")).toEqual(
dateFns.format(dateFns.add(date, { weeks: 2 }), "yyyy-MM-dd"),
)
})
test("should work with minutes offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "HH:mm", 30, "minutes")).toEqual(
dateFns.format(dateFns.add(date, { minutes: 30 }), "HH:mm"),
)
})
test("should work with seconds offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "HH:mm:ss", 45, "seconds")).toEqual(
dateFns.format(dateFns.add(date, { seconds: 45 }), "HH:mm:ss"),
)
})
})
describe("now edge cases", () => {
test("should work with different format tokens", () => {
const now = new Date()
expect(nowHelper("yyyy")).toEqual(dateFns.format(now, "yyyy"))
expect(nowHelper("MM")).toEqual(dateFns.format(now, "MM"))
expect(nowHelper("dd")).toEqual(dateFns.format(now, "dd"))
})
test("should work with positive offset", () => {
const now = new Date()
const result = nowHelper("yyyy-MM-dd", 1, "days")
const expected = dateFns.format(dateFns.add(now, { days: 1 }), "yyyy-MM-dd")
expect(result).toEqual(expected)
})
test("should work with hours offset", () => {
const now = new Date()
const result = nowHelper("HH", 2, "hours")
const expected = dateFns.format(dateFns.add(now, { hours: 2 }), "HH")
expect(result).toEqual(expected)
})
})
})
})
describe("registerHelpers", () => {
test("registers default helpers", () => {
const config: ScaffoldConfig = { ...blankConf }
registerHelpers(config)
const result = handlebarsParse(
{ ...config, data: { name: "hello_world" } },
"{{camelCase name}}",
)
expect(result.toString()).toEqual("helloWorld")
})
test("registers custom helpers", () => {
const config: ScaffoldConfig = {
...blankConf,
helpers: {
reverse: (text: string) => text.split("").reverse().join(""),
},
}
registerHelpers(config)
const result = handlebarsParse(
{ ...config, data: { name: "hello" } },
"{{reverse name}}",
)
expect(result.toString()).toEqual("olleh")
})
test("custom helpers override default helpers", () => {
const config: ScaffoldConfig = {
...blankConf,
helpers: {
camelCase: () => "OVERRIDDEN",
},
}
registerHelpers(config)
const result = handlebarsParse(
{ ...config, data: { name: "test" } },
"{{camelCase name}}",
)
expect(result.toString()).toEqual("OVERRIDDEN")
})
})
describe("default helpers completeness", () => {
test("all expected helpers are defined", () => {
const expectedHelpers = [
"camelCase", "snakeCase", "startCase", "kebabCase",
"hyphenCase", "pascalCase", "lowerCase", "upperCase",
"now", "date",
]
for (const helper of expectedHelpers) {
expect(defaultHelpers).toHaveProperty(helper)
expect(typeof defaultHelpers[helper as keyof typeof defaultHelpers]).toBe("function")
}
})
test("has exactly 10 helpers", () => {
expect(Object.keys(defaultHelpers).length).toBe(10)
})
})
})

529
tests/prompts.test.ts Normal file
View File

@@ -0,0 +1,529 @@
import { describe, test, expect, vi, beforeEach } from "vitest"
import { LogLevel, ScaffoldCmdConfig, ScaffoldConfig } from "../src/types"
vi.mock("@inquirer/input", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/select", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/confirm", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/number", () => ({
default: vi.fn(),
}))
import inputMock from "@inquirer/input"
import selectMock from "@inquirer/select"
import confirmMock from "@inquirer/confirm"
import numberMock from "@inquirer/number"
import {
promptForName,
promptForTemplateKey,
promptForOutput,
promptForTemplates,
promptForMissingConfig,
promptForInputs,
resolveInputs,
isInteractive,
} from "../src/prompts"
function mockTTY(value: boolean) {
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true })
}
const blankConfig: ScaffoldCmdConfig = {
logLevel: LogLevel.none,
name: "",
output: "",
templates: [],
data: {},
overwrite: false,
subdir: false,
dryRun: false,
quiet: false,
version: false,
}
describe("prompts", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("promptForName", () => {
test("calls input prompt and returns result", async () => {
vi.mocked(inputMock).mockResolvedValue("my-component")
const result = await promptForName()
expect(result).toEqual("my-component")
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForTemplateKey", () => {
test("calls select prompt when multiple keys", async () => {
vi.mocked(selectMock).mockResolvedValue("component")
const result = await promptForTemplateKey({
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
})
expect(result).toEqual("component")
expect(selectMock).toHaveBeenCalledOnce()
})
test("returns single key without prompting", async () => {
const result = await promptForTemplateKey({
default: { name: "d", templates: [], output: "" },
})
expect(result).toEqual("default")
expect(selectMock).not.toHaveBeenCalled()
})
test("throws when config map is empty", async () => {
await expect(promptForTemplateKey({})).rejects.toThrow("No templates found")
})
test("presents all keys as choices", async () => {
vi.mocked(selectMock).mockResolvedValue("b")
await promptForTemplateKey({
a: { name: "a", templates: [], output: "" },
b: { name: "b", templates: [], output: "" },
c: { name: "c", templates: [], output: "" },
})
const call = vi.mocked(selectMock).mock.calls[0][0] as {
choices: { name: string; value: string }[]
}
expect(call.choices).toEqual([
{ name: "a", value: "a" },
{ name: "b", value: "b" },
{ name: "c", value: "c" },
])
})
})
describe("promptForOutput", () => {
test("calls input prompt and returns result", async () => {
vi.mocked(inputMock).mockResolvedValue("./dist")
const result = await promptForOutput()
expect(result).toEqual("./dist")
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForTemplates", () => {
test("parses comma-separated input into array", async () => {
vi.mocked(inputMock).mockResolvedValue("src/templates, lib/other")
const result = await promptForTemplates()
expect(result).toEqual(["src/templates", "lib/other"])
})
test("handles single template", async () => {
vi.mocked(inputMock).mockResolvedValue("src/templates")
const result = await promptForTemplates()
expect(result).toEqual(["src/templates"])
})
test("trims whitespace and filters empty entries", async () => {
vi.mocked(inputMock).mockResolvedValue(" a , , b , ")
const result = await promptForTemplates()
expect(result).toEqual(["a", "b"])
})
})
describe("isInteractive", () => {
test("returns a boolean", () => {
expect(typeof isInteractive()).toBe("boolean")
})
})
describe("promptForMissingConfig", () => {
test("prompts for all missing values when interactive", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("my-app") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("my-app")
expect(result.output).toEqual("./output")
expect(result.templates).toEqual(["src/tpl"])
expect(inputMock).toHaveBeenCalledTimes(3)
})
test("does not prompt for values already provided", async () => {
mockTTY(true)
const config = {
...blankConfig,
name: "already-set",
output: "./out",
templates: ["tpl"],
}
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("already-set")
expect(result.output).toEqual("./out")
expect(result.templates).toEqual(["tpl"])
expect(inputMock).not.toHaveBeenCalled()
})
test("prompts for template key when multiple templates and no key", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("name") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
vi.mocked(selectMock).mockResolvedValue("component")
const configMap = {
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
}
const config = { ...blankConfig }
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toEqual("component")
})
test("does not prompt for template key when already set", async () => {
mockTTY(true)
const configMap = {
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
}
const config = {
...blankConfig,
name: "test",
output: "./out",
templates: ["tpl"],
key: "default",
}
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toEqual("default")
expect(selectMock).not.toHaveBeenCalled()
})
test("does not prompt for template key when only one template", async () => {
mockTTY(true)
const configMap = {
default: { name: "d", templates: [], output: "" },
}
const config = { ...blankConfig, name: "test", output: "./out", templates: ["tpl"] }
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toBeUndefined()
expect(selectMock).not.toHaveBeenCalled()
})
test("does not prompt in non-interactive mode", async () => {
mockTTY(false)
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("")
expect(result.output).toEqual("")
expect(result.templates).toEqual([])
expect(inputMock).not.toHaveBeenCalled()
})
test("does not prompt for config key when no config map provided", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("name")
.mockResolvedValueOnce("./out")
.mockResolvedValueOnce("tpl")
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
expect(result.key).toBeUndefined()
expect(selectMock).not.toHaveBeenCalled()
})
test("only prompts for missing values, not provided ones", async () => {
mockTTY(true)
vi.mocked(inputMock).mockResolvedValueOnce("src/tpl") // only templates missing
const config = { ...blankConfig, name: "app", output: "./out" }
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("app")
expect(result.output).toEqual("./out")
expect(result.templates).toEqual(["src/tpl"])
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForInputs", () => {
test("prompts for required inputs not in existing data", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
const result = await promptForInputs(
{ author: { message: "Author name", required: true } },
{},
)
expect(result.author).toEqual("John")
expect(inputMock).toHaveBeenCalledOnce()
})
test("skips inputs already provided in data", async () => {
const result = await promptForInputs(
{ author: { message: "Author name", required: true } },
{ author: "Jane" },
)
expect(result.author).toEqual("Jane")
expect(inputMock).not.toHaveBeenCalled()
})
test("applies default value for optional inputs not in data", async () => {
const result = await promptForInputs({ license: { default: "MIT" } }, {})
expect(result.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
test("does not apply default when value already exists", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{ license: "Apache-2.0" },
)
expect(result.license).toEqual("Apache-2.0")
})
test("uses input key as message fallback", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("val")
await promptForInputs({ myField: { required: true } }, {})
const call = vi.mocked(inputMock).mock.calls[0][0] as { message: string }
expect(call.message).toContain("myField")
})
test("prompts multiple required inputs in order", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John").mockResolvedValueOnce("2.0")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
version: { message: "Version", required: true },
},
{},
)
expect(result.author).toEqual("John")
expect(result.version).toEqual("2.0")
expect(inputMock).toHaveBeenCalledTimes(2)
})
test("mixes prompts, defaults, and existing data", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
license: { default: "MIT" },
description: { message: "Desc", required: true },
},
{ description: "My project" },
)
expect(result.author).toEqual("John")
expect(result.license).toEqual("MIT")
expect(result.description).toEqual("My project")
expect(inputMock).toHaveBeenCalledOnce()
})
test("preserves existing data keys not in inputs", async () => {
const result = await promptForInputs({ license: { default: "MIT" } }, { extra: "value" })
expect(result.extra).toEqual("value")
expect(result.license).toEqual("MIT")
})
test("required input with default pre-fills prompt", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("custom")
await promptForInputs({ author: { required: true, default: "Anonymous" } }, {})
const call = vi.mocked(inputMock).mock.calls[0][0] as { default?: string }
expect(call.default).toEqual("Anonymous")
})
test("select input prompts with options", async () => {
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
const result = await promptForInputs(
{
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
},
{},
)
expect(result.license).toEqual("MIT")
expect(selectMock).toHaveBeenCalledOnce()
const call = vi.mocked(selectMock).mock.calls[0][0] as {
choices: { name: string; value: string }[]
}
expect(call.choices).toEqual([
{ name: "MIT", value: "MIT" },
{ name: "Apache-2.0", value: "Apache-2.0" },
{ name: "GPL-3.0", value: "GPL-3.0" },
])
})
test("select input with object options", async () => {
vi.mocked(selectMock).mockResolvedValueOnce("mit")
const result = await promptForInputs(
{
license: {
type: "select",
options: [
{ name: "MIT License", value: "mit" },
{ name: "Apache 2.0", value: "apache" },
],
},
},
{},
)
expect(result.license).toEqual("mit")
})
test("select input throws when no options", async () => {
await expect(promptForInputs({ license: { type: "select" } }, {})).rejects.toThrow(
"no options defined",
)
})
test("select input skipped when value already provided", async () => {
const result = await promptForInputs(
{ license: { type: "select", options: ["MIT", "Apache"] } },
{ license: "MIT" },
)
expect(result.license).toEqual("MIT")
expect(selectMock).not.toHaveBeenCalled()
})
test("confirm input prompts and returns boolean", async () => {
vi.mocked(confirmMock).mockResolvedValueOnce(true)
const result = await promptForInputs(
{ private: { type: "confirm", message: "Private?" } },
{},
)
expect(result.private).toBe(true)
expect(confirmMock).toHaveBeenCalledOnce()
})
test("confirm input with default false", async () => {
vi.mocked(confirmMock).mockResolvedValueOnce(false)
await promptForInputs({ private: { type: "confirm", default: false } }, {})
const call = vi.mocked(confirmMock).mock.calls[0][0] as { default?: boolean }
expect(call.default).toBe(false)
})
test("confirm input skipped when value already provided", async () => {
const result = await promptForInputs({ private: { type: "confirm" } }, { private: true })
expect(result.private).toBe(true)
expect(confirmMock).not.toHaveBeenCalled()
})
test("number input prompts and returns number", async () => {
vi.mocked(numberMock).mockResolvedValueOnce(8080)
const result = await promptForInputs(
{ port: { type: "number", message: "Port", required: true } },
{},
)
expect(result.port).toBe(8080)
expect(numberMock).toHaveBeenCalledOnce()
})
test("number input with default", async () => {
vi.mocked(numberMock).mockResolvedValueOnce(3000)
await promptForInputs({ port: { type: "number", default: 3000, required: true } }, {})
const call = vi.mocked(numberMock).mock.calls[0][0] as { default?: number }
expect(call.default).toBe(3000)
})
test("number input skipped when value already provided", async () => {
const result = await promptForInputs(
{ port: { type: "number", required: true } },
{ port: 9090 },
)
expect(result.port).toBe(9090)
expect(numberMock).not.toHaveBeenCalled()
})
test("mixed input types in one config", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
vi.mocked(confirmMock).mockResolvedValueOnce(true)
vi.mocked(numberMock).mockResolvedValueOnce(3000)
const result = await promptForInputs(
{
author: { type: "text", message: "Author", required: true },
license: { type: "select", message: "License", options: ["MIT", "Apache"] },
private: { type: "confirm", message: "Private?" },
port: { type: "number", message: "Port", required: true },
},
{},
)
expect(result.author).toEqual("John")
expect(result.license).toEqual("MIT")
expect(result.private).toBe(true)
expect(result.port).toBe(3000)
})
})
describe("resolveInputs", () => {
test("returns config unchanged when no inputs defined", async () => {
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: { foo: "bar" },
}
const result = await resolveInputs(config)
expect(result.data).toEqual({ foo: "bar" })
})
test("applies defaults in non-interactive mode", async () => {
mockTTY(false)
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: {},
inputs: {
license: { default: "MIT" },
},
}
const result = await resolveInputs(config)
expect(result.data?.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
test("does not overwrite existing data with defaults in non-interactive mode", async () => {
mockTTY(false)
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: { license: "Apache-2.0" },
inputs: {
license: { default: "MIT" },
},
}
const result = await resolveInputs(config)
expect(result.data?.license).toEqual("Apache-2.0")
})
test("prompts for required inputs in interactive mode", async () => {
mockTTY(true)
vi.mocked(inputMock).mockResolvedValueOnce("John")
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: {},
inputs: {
author: { message: "Author", required: true },
},
}
const result = await resolveInputs(config)
expect(result.data?.author).toEqual("John")
})
})
})

View File

@@ -1,3 +1,14 @@
import {
describe,
test,
expect,
beforeEach,
afterEach,
beforeAll,
afterAll,
vi,
type MockInstance,
} from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import Scaffold from "../src/scaffold"
@@ -66,19 +77,28 @@ const fileStructDates = {
input: {
"now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}",
"offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}",
"custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
"custom.txt":
"Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
},
output: {},
}
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction {
const fileStructExcludes = {
input: {
"include.txt": "This file should be included",
"exclude.txt": "This file should be excluded",
},
output: {},
}
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
return () => {
beforeEach(() => {
// console.log("Mocking:", fileStruct)
console = new Console(process.stdout, process.stderr)
mockFs(fileStruct)
// logMock = jest.spyOn(console, 'log').mockImplementation((...args) => {
// logMock = vi.spyOn(console, 'log').mockImplementation((...args) => {
// logsTemp.push(args)
// })
})
@@ -92,7 +112,8 @@ function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunct
describe("Scaffold", () => {
describe(
"create subfolder",
"create subdir",
withMock(fileStructNormal, () => {
test("should not create by default", async () => {
await Scaffold({
@@ -122,6 +143,7 @@ describe("Scaffold", () => {
describe(
"binary files",
withMock(fileStructWithBinary, () => {
test("should copy as-is", async () => {
await Scaffold({
@@ -189,9 +211,9 @@ describe("Scaffold", () => {
describe(
"errors",
withMock(fileStructNormal, () => {
let consoleMock1: jest.SpyInstance
let consoleMock1: MockInstance
beforeAll(() => {
consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0)
consoleMock1 = vi.spyOn(console, "error").mockImplementation(() => void 0)
})
afterAll(() => {
@@ -227,9 +249,9 @@ describe("Scaffold", () => {
describe(
"dry run",
withMock(fileStructNormal, () => {
let consoleMock1: jest.SpyInstance
let consoleMock1: MockInstance
beforeAll(() => {
consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0)
consoleMock1 = vi.spyOn(console, "error").mockImplementation(() => void 0)
})
afterAll(() => {
@@ -289,7 +311,9 @@ describe("Scaffold", () => {
const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt"))
const oneDeepFile = readFileSync(join(process.cwd(), "output", "AppName/app_name-2.txt"))
const twoDeepFile = readFileSync(join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"))
const twoDeepFile = readFileSync(
join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"),
)
expect(rootFile.toString()).toEqual("This should be in root")
expect(oneDeepFile.toString()).toEqual("Hello, my value is 1")
expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!")
@@ -297,10 +321,28 @@ describe("Scaffold", () => {
}),
)
describe(
"file exclusion via glob pattern",
withMock(fileStructExcludes, () => {
test("should only include matching files", async () => {
await Scaffold({
name: "app_name",
output: "output",
templates: ["input/include.*"],
data: { value: "1" },
logLevel: "none",
})
const outputFiles = readdirSync(join(process.cwd(), "output"))
expect(outputFiles).toContain("include.txt")
expect(outputFiles).not.toContain("exclude.txt")
})
}),
)
describe(
"capitalization helpers",
withMock(fileStructHelpers, () => {
const _helpers: Record<string, (text: string) => string> = {
const _helpers: Record<string, (_text: string) => string> = {
add1: (text) => text + " 1",
}
@@ -372,7 +414,7 @@ describe("Scaffold", () => {
describe(
"custom helpers",
withMock(fileStructHelpers, () => {
const _helpers: Record<string, (text: string) => string> = {
const _helpers: Record<string, (_text: string) => string> = {
add1: (text) => text + " 1",
}
test("should work", async () => {
@@ -395,7 +437,7 @@ describe("Scaffold", () => {
}),
)
describe(
"transform subfolder",
"transform subdir",
withMock(fileStructSubdirTransformer, () => {
test("should work with no helper", async () => {
await Scaffold({
@@ -499,4 +541,705 @@ describe("Scaffold", () => {
})
}),
)
describe(
"name is available in data",
withMock(
{
input: { "file.txt": "Name: {{name}}" },
output: {},
},
() => {
test("name is automatically injected into data", async () => {
await Scaffold({
name: "my_project",
output: "output",
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Name: my_project")
})
},
),
)
describe(
"data overrides name in data",
withMock(
{
input: { "file.txt": "Name: {{name}}" },
output: {},
},
() => {
test("explicit data.name takes precedence", async () => {
await Scaffold({
name: "original_name",
output: "output",
templates: ["input"],
logLevel: "none",
data: { name: "custom_name" },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Name: custom_name")
})
},
),
)
describe(
"multiple templates",
withMock(
{
template1: { "file1.txt": "From template 1: {{name}}" },
template2: { "file2.txt": "From template 2: {{name}}" },
output: {},
},
() => {
test("processes multiple template directories", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["template1", "template2"],
logLevel: "none",
})
const file1 = readFileSync(join(process.cwd(), "output", "file1.txt")).toString()
const file2 = readFileSync(join(process.cwd(), "output", "file2.txt")).toString()
expect(file1).toEqual("From template 1: app")
expect(file2).toEqual("From template 2: app")
})
},
),
)
describe(
"template with custom data",
withMock(
{
input: { "{{name}}.txt": "Author: {{author}}, Version: {{version}}" },
output: {},
},
() => {
test("uses custom data in content and filename", async () => {
await Scaffold({
name: "my_app",
output: "output",
templates: ["input"],
logLevel: "none",
data: { author: "John", version: "2.0" },
})
const content = readFileSync(join(process.cwd(), "output", "my_app.txt")).toString()
expect(content).toEqual("Author: John, Version: 2.0")
})
},
),
)
describe(
"template with helpers in filenames",
withMock(
{
input: { "{{pascalCase name}}.tsx": "component {{pascalCase name}}" },
output: {},
},
() => {
test("applies helpers to filenames", async () => {
await Scaffold({
name: "my_component",
output: "output",
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(join(process.cwd(), "output", "MyComponent.tsx")).toString()
expect(content).toEqual("component MyComponent")
})
},
),
)
describe(
"template with helpers in directory names",
withMock(
{
input: {
"{{kebabCase name}}": {
"index.ts": "export from {{name}}",
},
},
output: {},
},
() => {
test("applies helpers to directory names", async () => {
await Scaffold({
name: "MyComponent",
output: "output",
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(
join(process.cwd(), "output", "my-component", "index.ts"),
).toString()
expect(content).toEqual("export from MyComponent")
})
},
),
)
describe(
"deeply nested template structure",
withMock(
{
input: {
"root.txt": "root",
level1: {
"l1.txt": "level 1",
level2: {
"l2.txt": "level 2",
level3: {
"l3.txt": "level 3 {{name}}",
},
},
},
},
output: {},
},
() => {
test("preserves deep nesting", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
expect(readFileSync(join(process.cwd(), "output", "root.txt")).toString()).toEqual("root")
expect(
readFileSync(join(process.cwd(), "output", "level1", "l1.txt")).toString(),
).toEqual("level 1")
expect(
readFileSync(join(process.cwd(), "output", "level1", "level2", "l2.txt")).toString(),
).toEqual("level 2")
expect(
readFileSync(
join(process.cwd(), "output", "level1", "level2", "level3", "l3.txt"),
).toString(),
).toEqual("level 3 app")
})
},
),
)
describe(
"overwrite as function",
withMock(
{
input: {
"keep.txt": "new keep",
"replace.txt": "new replace",
},
output: {
"keep.txt": "old keep",
"replace.txt": "old replace",
},
},
() => {
test("per-file overwrite control", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
overwrite: (_fullPath, _basedir, basename) => basename === "replace.txt",
})
expect(readFileSync(join(process.cwd(), "output", "keep.txt")).toString()).toEqual(
"old keep",
)
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual(
"new replace",
)
})
},
),
)
describe(
"multiple custom helpers",
withMock(
{
input: {
"file.txt": "{{reverse name}} - {{repeat name}}",
},
output: {},
},
() => {
test("multiple custom helpers work together", async () => {
await Scaffold({
name: "abc",
output: "output",
templates: ["input"],
logLevel: "none",
helpers: {
reverse: (text: string) => text.split("").reverse().join(""),
repeat: (text: string) => text + text,
},
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("cba - abcabc")
})
},
),
)
describe(
"subdirHelper with different helpers",
withMock(
{
input: { "file.txt": "content" },
output: {},
},
() => {
test("subdirHelper camelCase", async () => {
await Scaffold({
name: "my_component",
output: "output",
templates: ["input"],
logLevel: "none",
subdir: true,
subdirHelper: "camelCase",
})
const content = readFileSync(
join(process.cwd(), "output", "myComponent", "file.txt"),
).toString()
expect(content).toEqual("content")
})
test("subdirHelper kebabCase", async () => {
await Scaffold({
name: "MyComponent",
output: "output",
templates: ["input"],
logLevel: "none",
subdir: true,
subdirHelper: "kebabCase",
})
const content = readFileSync(
join(process.cwd(), "output", "my-component", "file.txt"),
).toString()
expect(content).toEqual("content")
})
test("subdirHelper snakeCase", async () => {
await Scaffold({
name: "MyComponent",
output: "output",
templates: ["input"],
logLevel: "none",
subdir: true,
subdirHelper: "snakeCase",
})
const content = readFileSync(
join(process.cwd(), "output", "my_component", "file.txt"),
).toString()
expect(content).toEqual("content")
})
},
),
)
describe(
"empty template directory",
withMock(
{
input: {},
output: {},
},
() => {
test("handles empty template dir gracefully", async () => {
await expect(
Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
}),
).resolves.toBeUndefined()
})
},
),
)
describe(
"template with special characters in data",
withMock(
{
input: { "file.txt": "Value: {{value}}" },
output: {},
},
() => {
test("handles special characters in data values", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
data: { value: 'hello & <world> "test"' },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual('Value: hello & <world> "test"')
})
},
),
)
describe(
"beforeWrite with async callback",
withMock(
{
input: { "file.txt": "Hello {{name}}" },
output: {},
},
() => {
test("supports async beforeWrite", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
beforeWrite: async (content) => {
return content.toString().replace("Hello", "Hi")
},
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Hi app")
})
},
),
)
describe(
"beforeWrite receives all arguments",
withMock(
{
input: { "{{name}}.txt": "Template: {{name}}" },
output: {},
},
() => {
test("beforeWrite gets content, rawContent, and outputPath", async () => {
const beforeWriteSpy = vi.fn().mockReturnValue(undefined)
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
beforeWrite: beforeWriteSpy,
})
expect(beforeWriteSpy).toHaveBeenCalledTimes(1)
const [content, rawContent, outputPath] = beforeWriteSpy.mock.calls[0]
expect(content.toString()).toEqual("Template: app")
expect(rawContent.toString()).toEqual("Template: {{name}}")
expect(outputPath).toContain("app.txt")
})
},
),
)
describe(
"multiple binary files",
withMock(
{
input: {
"img1.bin": crypto.randomBytes(5000),
"img2.bin": crypto.randomBytes(8000),
"text.txt": "regular text {{name}}",
},
output: {},
},
() => {
test("handles mix of binary and text files", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const text = readFileSync(join(process.cwd(), "output", "text.txt")).toString()
expect(text).toEqual("regular text app")
const bin1 = readFileSync(join(process.cwd(), "output", "img1.bin"))
const bin2 = readFileSync(join(process.cwd(), "output", "img2.bin"))
expect(bin1.length).toBeGreaterThan(0)
expect(bin2.length).toBeGreaterThan(0)
})
},
),
)
describe(
"output with function returning dynamic path",
withMock(
{
input: {
"component.tsx": "component {{name}}",
"style.css": "style for {{name}}",
},
output: {},
},
() => {
test("output function can route files to different directories", async () => {
await Scaffold({
name: "Button",
output: (_fullPath, _basedir, basename) => {
if (basename.endsWith(".css")) return join("output", "styles")
return join("output", "components")
},
templates: ["input"],
logLevel: "none",
})
const component = readFileSync(
join(process.cwd(), "output", "components", "component.tsx"),
).toString()
const style = readFileSync(
join(process.cwd(), "output", "styles", "style.css"),
).toString()
expect(component).toEqual("component Button")
expect(style).toEqual("style for Button")
})
},
),
)
describe(
"handlebars block helpers",
withMock(
{
input: {
"file.txt": "{{#if showHeader}}Header\n{{/if}}Body for {{name}}",
},
output: {},
},
() => {
test("supports handlebars block helpers in templates", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
data: { showHeader: true },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Header\nBody for app")
})
},
),
)
describe(
"glob pattern as template",
withMock(
{
src: {
"file1.txt": "text 1 {{name}}",
"file2.txt": "text 2 {{name}}",
"file3.js": "js {{name}}",
},
output: {},
},
() => {
test("glob pattern selects matching files only", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["src/*.txt"],
logLevel: "none",
})
// glob templates maintain structure relative to the non-glob part
const outputFiles = readdirSync(join(process.cwd(), "output", "src"))
expect(outputFiles).toContain("file1.txt")
expect(outputFiles).toContain("file2.txt")
expect(outputFiles).not.toContain("file3.js")
})
},
),
)
describe(
"dotfiles in template",
withMock(
{
input: {
".gitignore": "node_modules",
".env.example": "KEY={{name}}",
},
output: {},
},
() => {
test("includes dotfiles in output", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual(
"node_modules",
)
expect(readFileSync(join(process.cwd(), "output", ".env.example")).toString()).toEqual(
"KEY=app",
)
})
},
),
)
describe(
"large number of files",
withMock(
{
input: Object.fromEntries(
Array.from({ length: 50 }, (_, i) => [`file${i}.txt`, `Content ${i} for {{name}}`]),
),
output: {},
},
() => {
test("handles many files", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files.length).toBe(50)
expect(readFileSync(join(process.cwd(), "output", "file0.txt")).toString()).toEqual(
"Content 0 for app",
)
expect(readFileSync(join(process.cwd(), "output", "file49.txt")).toString()).toEqual(
"Content 49 for app",
)
})
},
),
)
describe(
".scaffoldignore",
withMock(
{
input: {
".scaffoldignore": "*.log\nREADME.md\n",
"file.txt": "included",
"debug.log": "excluded log",
"README.md": "excluded readme",
"other.js": "included js",
},
output: {},
},
() => {
test("excludes files matching .scaffoldignore patterns", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files).toContain("file.txt")
expect(files).toContain("other.js")
expect(files).not.toContain("debug.log")
expect(files).not.toContain("README.md")
expect(files).not.toContain(".scaffoldignore")
})
},
),
)
describe(
".scaffoldignore not copied to output",
withMock(
{
input: {
".scaffoldignore": "*.log",
"file.txt": "content",
},
output: {},
},
() => {
test(".scaffoldignore is never in output", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files).not.toContain(".scaffoldignore")
expect(files).toContain("file.txt")
})
},
),
)
describe(
"afterScaffold hook",
withMock(
{
input: { "file.txt": "Hello {{name}}" },
output: {},
},
() => {
test("calls function hook with config and files", async () => {
const hookFn = vi.fn()
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
afterScaffold: hookFn,
})
expect(hookFn).toHaveBeenCalledOnce()
const ctx = hookFn.mock.calls[0][0]
expect(ctx.config.name).toEqual("app")
expect(ctx.files.length).toBe(1)
expect(ctx.files[0]).toContain("file.txt")
})
test("calls async function hook", async () => {
let called = false
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
afterScaffold: async () => {
called = true
},
})
expect(called).toBe(true)
})
test("does not call hook when no files written (dry run)", async () => {
const hookFn = vi.fn()
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
dryRun: true,
afterScaffold: hookFn,
})
expect(hookFn).toHaveBeenCalledOnce()
expect(hookFn.mock.calls[0][0].files.length).toBe(0)
})
test("does not call hook when not provided", async () => {
await expect(
Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
}),
).resolves.toBeUndefined()
})
},
),
)
})

3
tests/test-config.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare const config: import("../dist").ScaffoldConfigFile;
export = config;

View File

@@ -1,5 +1,8 @@
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
// @ts-check
/** @type {import('../dist').ScaffoldConfigFile} */
// eslint-disable-next-line no-undef
module.exports = (conf) => {
// eslint-disable-next-line no-undef
console.log("Config:", conf)
return {
default: {
@@ -12,5 +15,10 @@ module.exports = (conf) => {
output: "examples/test-output/component",
data: { property: "myProp", value: "10" },
},
configs: {
templates: ["examples/test-input/**/.*"],
output: "examples/test-output/configs",
name: "---",
},
}
}

View File

@@ -1,15 +1,55 @@
import { handleErr, resolve } from "../src/utils"
import { describe, test, expect } from "vitest"
import { handleErr, resolve, wrapNoopResolver, colorize, TermColor } from "../src/utils"
describe("utils", () => {
describe("resolve", () => {
test("should resolve function", () => {
expect(resolve(() => 1, null)).toBe(1)
expect(resolve((x) => x, 2)).toBe(2)
})
test("should resolve value", () => {
expect(resolve(1, null)).toBe(1)
expect(resolve(2, 1)).toBe(2)
})
test("should resolve function with argument transformation", () => {
expect(resolve((x: number) => x * 2, 5)).toBe(10)
})
test("should resolve static string", () => {
expect(resolve("hello", null)).toBe("hello")
})
test("should resolve static boolean", () => {
expect(resolve(true, null)).toBe(true)
expect(resolve(false, null)).toBe(false)
})
test("should resolve static object", () => {
const obj = { key: "value" }
expect(resolve(obj, null)).toBe(obj)
})
test("should resolve function returning object", () => {
expect(resolve(() => ({ key: "value" }), null)).toEqual({ key: "value" })
})
test("should pass argument to function", () => {
const fn = (config: { name: string }) => config.name
expect(resolve(fn, { name: "test" })).toBe("test")
})
test("should resolve zero", () => {
expect(resolve(0, null)).toBe(0)
})
test("should resolve null", () => {
expect(resolve(null, "anything")).toBe(null)
})
test("should resolve undefined", () => {
expect(resolve(undefined, "anything")).toBe(undefined)
})
})
describe("handleErr", () => {
@@ -17,5 +57,151 @@ describe("utils", () => {
expect(() => handleErr({ name: "test", message: "test" })).toThrow()
expect(() => handleErr(null as never)).not.toThrow()
})
test("should throw the provided error", () => {
const err = new Error("test error")
expect(() => handleErr(err as unknown as NodeJS.ErrnoException)).toThrow("test error")
})
})
describe("wrapNoopResolver", () => {
test("should wrap static value in function", () => {
const wrapped = wrapNoopResolver("hello")
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)("anything")).toBe("hello")
})
test("should return function as-is", () => {
const fn = (x: string) => x.toUpperCase()
const wrapped = wrapNoopResolver(fn)
expect(wrapped).toBe(fn)
})
test("should wrap object value", () => {
const obj = { key: "value" }
const wrapped = wrapNoopResolver(obj)
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)("anything")).toBe(obj)
})
test("should wrap boolean value", () => {
const wrapped = wrapNoopResolver(true)
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)(null)).toBe(true)
})
test("should wrap number value", () => {
const wrapped = wrapNoopResolver(42)
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)(null)).toBe(42)
})
})
})
describe("colorize", () => {
test("should colorize text with red color", () => {
const result = colorize("Hello", "red")
expect(result).toBe("\x1b[31mHello\x1b[0m")
})
test("should colorize text with bold", () => {
const result = colorize("Hello", "bold")
expect(result).toBe("\x1b[1mHello\x1b[23m")
})
test("should reset color", () => {
const result = colorize("Hello", "reset")
expect(result).toBe("\x1b[0mHello\x1b[0m")
})
test("should have all color functions", () => {
const colors: TermColor[] = [
"reset",
"dim",
"bold",
"italic",
"underline",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"gray",
]
colors.forEach((color) => {
expect(typeof colorize[color]).toBe("function")
})
})
test("should colorize text using colorize.red", () => {
const result = colorize.red("Hello")
expect(result).toBe("\x1b[31mHello\x1b[0m")
})
test("should colorize text using template strings with colorize.blue", () => {
const result = colorize.blue`Hello ${"World"}`
expect(result).toBe("\x1b[34mHello World\x1b[0m")
})
test("should colorize with green", () => {
expect(colorize("Hello", "green")).toBe("\x1b[32mHello\x1b[0m")
})
test("should colorize with yellow", () => {
expect(colorize("Hello", "yellow")).toBe("\x1b[33mHello\x1b[0m")
})
test("should colorize with magenta", () => {
expect(colorize("Hello", "magenta")).toBe("\x1b[35mHello\x1b[0m")
})
test("should colorize with cyan", () => {
expect(colorize("Hello", "cyan")).toBe("\x1b[36mHello\x1b[0m")
})
test("should colorize with white", () => {
expect(colorize("Hello", "white")).toBe("\x1b[37mHello\x1b[0m")
})
test("should colorize with gray", () => {
expect(colorize("Hello", "gray")).toBe("\x1b[90mHello\x1b[0m")
})
test("should colorize with dim", () => {
expect(colorize("Hello", "dim")).toBe("\x1b[2mHello\x1b[22m")
})
test("should colorize with italic", () => {
expect(colorize("Hello", "italic")).toBe("\x1b[3mHello\x1b[23m")
})
test("should colorize with underline", () => {
expect(colorize("Hello", "underline")).toBe("\x1b[4mHello\x1b[24m")
})
test("color functions work as template strings", () => {
const name = "World"
expect(colorize.green`Hello ${name}`).toBe("\x1b[32mHello World\x1b[0m")
})
test("color functions work with direct call", () => {
expect(colorize.yellow("warning")).toBe("\x1b[33mwarning\x1b[0m")
expect(colorize.cyan("info")).toBe("\x1b[36minfo\x1b[0m")
})
test("handles empty string", () => {
expect(colorize("", "red")).toBe("\x1b[31m\x1b[0m")
})
test("handles special characters", () => {
expect(colorize("hello\nworld", "blue")).toBe("\x1b[34mhello\nworld\x1b[0m")
})
test("template string with multiple interpolations", () => {
const a = "one"
const b = "two"
expect(colorize.red`${a} and ${b}`).toBe("\x1b[31mone and two\x1b[0m")
})
})

231
tests/validate.test.ts Normal file
View File

@@ -0,0 +1,231 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import { Console } from "console"
import { validateConfig, validateTemplatePaths, assertConfigValid } from "../src/validate"
const validConfig = {
name: "test",
templates: ["templates"],
output: "output",
}
describe("validate", () => {
describe("validateConfig", () => {
test("returns no errors for valid config", () => {
expect(validateConfig(validConfig)).toEqual([])
})
test("returns no errors with all optional fields", () => {
const errors = validateConfig({
...validConfig,
subdir: true,
subdirHelper: "camelCase",
data: { key: "value" },
logLevel: "debug",
dryRun: true,
overwrite: true,
inputs: {
author: { type: "text", message: "Author", required: true },
},
})
expect(errors).toEqual([])
})
test("errors on missing name", () => {
const errors = validateConfig({ ...validConfig, name: "" })
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("name")
})
test("errors on missing templates", () => {
const errors = validateConfig({ ...validConfig, templates: [] })
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("templates")
})
test("errors on invalid logLevel", () => {
const errors = validateConfig({ ...validConfig, logLevel: "verbose" })
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("logLevel")
})
test("errors on subdirHelper without subdir", () => {
const errors = validateConfig({
...validConfig,
subdirHelper: "camelCase",
})
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("subdirHelper")
})
test("no error on subdirHelper with subdir", () => {
const errors = validateConfig({
...validConfig,
subdir: true,
subdirHelper: "camelCase",
})
expect(errors).toEqual([])
})
test("errors on select input without options", () => {
const errors = validateConfig({
...validConfig,
inputs: {
license: { type: "select" },
},
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes("select") && e.includes("options"))).toBe(true)
})
test("no error on select input with options", () => {
const errors = validateConfig({
...validConfig,
inputs: {
license: { type: "select", options: ["MIT", "Apache"] },
},
})
expect(errors).toEqual([])
})
test("errors on confirm input with non-boolean default", () => {
const errors = validateConfig({
...validConfig,
inputs: {
flag: { type: "confirm", default: "yes" },
},
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes("confirm") && e.includes("boolean"))).toBe(true)
})
test("errors on number input with non-number default", () => {
const errors = validateConfig({
...validConfig,
inputs: {
port: { type: "number", default: "3000" },
},
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes("number"))).toBe(true)
})
test("valid input types pass", () => {
const errors = validateConfig({
...validConfig,
inputs: {
a: { type: "text", required: true },
b: { type: "select", options: ["x", "y"] },
c: { type: "confirm", default: false },
d: { type: "number", default: 42 },
},
})
expect(errors).toEqual([])
})
test("accepts function output", () => {
const errors = validateConfig({
...validConfig,
output: () => "dynamic-output",
})
expect(errors).toEqual([])
})
test("accepts function overwrite", () => {
const errors = validateConfig({
...validConfig,
overwrite: () => true,
})
expect(errors).toEqual([])
})
test("accepts afterScaffold string", () => {
const errors = validateConfig({
...validConfig,
afterScaffold: "npm install",
})
expect(errors).toEqual([])
})
test("accepts afterScaffold function", () => {
const errors = validateConfig({
...validConfig,
afterScaffold: () => {},
})
expect(errors).toEqual([])
})
})
describe("validateTemplatePaths", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
mockFs({
templates: { "file.txt": "content" },
})
})
afterEach(() => {
mockFs.restore()
})
test("returns no errors for existing paths", async () => {
const errors = await validateTemplatePaths(["templates"])
expect(errors).toEqual([])
})
test("returns error for missing paths", async () => {
const errors = await validateTemplatePaths(["nonexistent"])
expect(errors.length).toBe(1)
expect(errors[0]).toContain("nonexistent")
})
test("skips glob patterns", async () => {
const errors = await validateTemplatePaths(["templates/**/*"])
expect(errors).toEqual([])
})
test("skips negation patterns", async () => {
const errors = await validateTemplatePaths(["!excluded"])
expect(errors).toEqual([])
})
})
describe("assertConfigValid", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
mockFs({
templates: { "file.txt": "content" },
})
})
afterEach(() => {
mockFs.restore()
})
test("does not throw for valid config", async () => {
await expect(assertConfigValid(validConfig)).resolves.toBeUndefined()
})
test("throws formatted error for invalid config", async () => {
await expect(assertConfigValid({ name: "", templates: [], output: "" })).rejects.toThrow(
"Invalid scaffold config",
)
})
test("includes all errors in message", async () => {
try {
await assertConfigValid({ name: "", templates: [], output: "out" })
} catch (e) {
const msg = (e as Error).message
expect(msg).toContain("name")
expect(msg).toContain("templates")
}
})
test("checks template path existence", async () => {
await expect(
assertConfigValid({ name: "test", templates: ["missing"], output: "out" }),
).rejects.toThrow("does not exist")
})
})
})

View File

@@ -1,20 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": "./src",
"esModuleInterop": true,
"lib": ["ES2022"],
"lib": ["ESNext"],
"declaration": true,
"outDir": "dist",
"strict": true,
"sourceMap": true,
"removeComments": false,
"paths": {
"@/*": ["./src/*"],
"@/*": ["./src/*"]
},
"types": ["node"]
},
"include": ["src/index.ts", "src/cmd.ts"],
"exclude": ["tests/*"],
"exclude": ["tests/*"]
}

44
vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from "vite"
import path from "node:path"
export default defineConfig({
build: {
lib: {
entry: {
index: path.resolve(__dirname, "src/index.ts"),
cmd: path.resolve(__dirname, "src/cmd.ts"),
},
formats: ["cjs"],
},
outDir: "dist",
target: "node20",
rollupOptions: {
// Externalize all node builtins and all dependencies
external: [
/^node:/, // Node builtins
/^[^./]/, // All bare imports (dependencies)
],
output: {
exports: "named",
},
},
sourcemap: true,
minify: false,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
globals: true,
clearMocks: true,
coverage: {
enabled: true,
provider: "v8",
reportsDirectory: "coverage",
exclude: ["node_modules/", "scaffold.config.js", "dist/", "docs/"],
},
exclude: ["node_modules", "dist", "docs"],
},
})