Compare commits

...

26 Commits

Author SHA1 Message Date
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
42 changed files with 10841 additions and 7808 deletions

View File

@@ -1,33 +0,0 @@
name: Test
on:
pull_request:
branches:
- master
- develop
permissions:
contents: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: pnpm run ci
- run: pnpm test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: pnpm run ci
- run: pnpm build

View File

@@ -1,31 +0,0 @@
name: Documentation
on:
push:
branches:
- master
- develop
permissions:
contents: write
jobs:
docs:
name: Build Documentation
runs-on: ubuntu-latest
# if: "contains(github.event.head_commit.message, 'chore(release)')"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: |
pnpm install --frozen-lockfile
cd docs && pnpm install --frozen-lockfile
- 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,6 +1,9 @@
name: Release
on:
pull_request:
branches:
- master
push:
branches:
- master
@@ -8,30 +11,38 @@ on:
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: pnpm run ci
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
- run: npm i -g pnpm
- run: pnpm run ci
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
release:
name: Release Please
if: github.event_name == 'push'
needs:
- build
- test
@@ -47,18 +58,42 @@ jobs:
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: 20
cache: pnpm
registry-url: "https://registry.npmjs.org"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: cd dist && npm publish --provenance --access public
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: 20
registry-url: "https://registry.npmjs.org"
- run: npm i -g pnpm
- run: pnpm run ci
- run: pnpm build
- run: cd dist && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
docs/docs/api/
examples/
.github/
CHANGELOG.md

View File

@@ -1,16 +1,52 @@
# Change Log
## [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 ([ba96ca6](https://github.com/chenasraf/simple-scaffold/commit/ba96ca64d1e02beb16bd127fc889da3ef016b7d5))
* remove chalk dependency ([ab9322e](https://github.com/chenasraf/simple-scaffold/commit/ab9322e1ab9c0a07cdab7275f3398286dee67a64))
### Bug Fixes
* exclude globs ([a2788e7](https://github.com/chenasraf/simple-scaffold/commit/a2788e7c7d27f46d55cf4810e1a8193b5d403568))
* 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)

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">
@@ -100,9 +95,40 @@ 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).
### Interactive Mode
When running in a terminal, Simple Scaffold will interactively prompt for any missing required
values — name, output directory, template paths, and template key (if multiple are available).
Config files can also define **inputs** — custom fields that are prompted interactively and become
template data:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
},
}
```
Inputs can be pre-provided via `--data` or `-D` to skip the prompt:
```sh
npx simple-scaffold -c scaffold.config.js -k component -D author=John MyComponent
```
### Configuration Files
You can use a config file to more easily maintain all your scaffold definitions.
You can use a config file to more easily maintain all your 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}`.
`scaffold.config.js`
@@ -120,10 +146,11 @@ module.exports = {
}
```
Then call your scaffold like this:
Then just run from the same directory:
```sh
$ npx simple-scaffold -c scaffold.config.js PageWrapper
$ npx simple-scaffold PageWrapper
# or explicitly: npx simple-scaffold -c scaffold.config.js PageWrapper
```
This will allow you to avoid needing to remember which configs are needed or to store them in a

View File

@@ -23,7 +23,8 @@ module.exports = {
}
```
For the full configuration options, see [ScaffoldConfigFile](../api/modules#scaffoldconfigfile).
For the full configuration options, see
[ScaffoldConfigFile](../api/type-aliases/ScaffoldConfigFile).
If you want to supply functions inside the configurations, you must use a `.js`/`.cjs`/`.mjs` file
as JSON does not support non-primitives.
@@ -45,6 +46,31 @@ module.exports = (config) => {
}
```
### Template Inputs
You can define **inputs** in your config to prompt users for custom values when scaffolding. Each
input becomes a template data variable:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License type", default: "MIT" },
description: { message: "Component description" },
},
},
}
```
In your templates, use these as `{{ author }}`, `{{ license }}`, `{{ description }}`.
- **Required** inputs are prompted interactively if not provided via `--data` or `-D`
- **Optional** inputs with a `default` use that value silently if not provided
- In non-interactive environments, only defaults are applied
If you want to provide templates that need no name (such as common config files which are easily
portable between projects), you may provide the `name` property in the config object.
@@ -53,9 +79,38 @@ default and therefore it will no longer be required in the CLI arguments.
## 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:
### Auto-detection
By default, Simple Scaffold automatically searches the current working directory for a config file.
No `--config` flag is needed if your config file uses one of the standard names.
The following files are tried in order:
1. `scaffold.config.mjs`
2. `scaffold.config.cjs`
3. `scaffold.config.js`
4. `scaffold.config.json`
5. `scaffold.mjs`
6. `scaffold.cjs`
7. `scaffold.js`
8. `scaffold.json`
9. `.scaffold.mjs`
10. `.scaffold.cjs`
11. `.scaffold.js`
12. `.scaffold.json`
If a config file is found, it is loaded automatically. If multiple templates are defined and no
`--key` is provided, you'll be prompted to select one interactively.
```sh
# Just run from a directory containing scaffold.config.js — no flags needed
simple-scaffold MyComponentName
```
### Explicit config path
You can also provide a specific file or directory path using `--config` (or `-c`), optionally
alongside `--key` or `-k`:
```sh
simple-scaffold -c <file> -k <template_key>
@@ -67,7 +122,12 @@ For example:
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:
When a directory is given, the same auto-detection order listed above is used to find a config file
within that directory.
### Default template key
If you don't supply a template key (e.g. `component`), `default` will be used:
```js
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
@@ -78,36 +138,23 @@ module.exports = {
}
```
And then:
```sh
# will use 'default' template
simple-scaffold -c scaffold.json MyComponentName
```
- When the a directory is given, the following files in the given directory 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.
If multiple keys exist and no key is specified, you'll be prompted to choose one interactively.
### Supported file types
Any importable file is supported, depending on your build process.
Common files include:
Common extensions:
- `*.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.
- `.mjs`
- `.cjs`
- `.js`
- `.json`
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

View File

@@ -13,24 +13,68 @@ To see this and more information anytime, add the `-h` or `--help` flag to your
Options:
| Option/flag \| Alias | Description |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--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 or directory to load config from |
| `--git` \| `-g` | Git URL or GitHub path to load a template from. |
| `--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 `--subdir` is enabled, the subdir 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` \| `--no-overwrite` \| `-W` | Enable to override output files, even if they already exist. (default: false) |
| `--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` |
| `--subdir` \| `-s` \| `--no-subdir` \| `-S` | Create a parent directory with the input name (and possibly `--subdir-helper` (default: false) |
| `--subdir-helper` \| `-H` | Default helper to apply to subdir name when using `--subdir`. |
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`)(default: false) |
| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none, debug, info, warn, error`. The provided level will display messages of the same level or higher. (default: info) |
| `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. |
| `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. (default: false) |
| `--version` \| `-v` | Display version. |
| Option/flag \| Alias | Description |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. If omitted in an interactive terminal, you will be prompted. |
| `--config` \| `-c` | Filename or directory to load config from. If omitted, the current directory is searched automatically for a config file (see [Auto-detection](configuration_files#auto-detection)). |
| `--git` \| `-g` | Git URL or GitHub path to load a template from. |
| `--key` \| `-k` | Key to load inside the config file. If omitted and multiple templates are available, you will be prompted to select one. |
| `--output` \| `-o` | 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. |
| `--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. If omitted in an interactive terminal, you will be prompted for a comma-separated list. |
| `--overwrite` \| `-w` \| `--no-overwrite` \| `-W` | Enable to override output files, even if they already exist. (default: false) |
| `--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` |
| `--subdir` \| `-s` \| `--no-subdir` \| `-S` | Create a parent directory with the input name (and possibly `--subdir-helper` (default: false) |
| `--subdir-helper` \| `-H` | Default helper to apply to subdir name when using `--subdir`. |
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`)(default: false) |
| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none, debug, info, warn, error`. The provided level will display messages of the same level or higher. (default: info) |
| `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. |
| `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. (default: false) |
| `--version` \| `-v` | Display version. |
### Interactive Mode
When running in a terminal (TTY), Simple Scaffold will prompt for any missing required values:
- **Name** — text input if `--name` is not provided
- **Template key** — selectable list if `--key` is not provided and the config file has multiple
templates
- **Output directory** — text input if `--output` is not provided
- **Template paths** — comma-separated text input if `--templates` is not provided
In non-interactive environments (CI, piped input), missing values will cause an error instead of
prompting.
### Template Inputs
Config files can define **inputs** — custom fields that are prompted interactively and injected as
template data. This is useful for templates that need user-specific values like author name,
license, or description.
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License type", default: "MIT" },
description: { message: "Description" },
},
},
}
```
Each input becomes available as a Handlebars variable in your templates (e.g., `{{ author }}`,
`{{ license }}`).
- **Required inputs** without a value will be prompted interactively
- **Optional inputs** with a `default` will use that value if not provided
- All inputs can be pre-provided via `--data` or `-D` to skip the prompt:
```shell
simple-scaffold -c scaffold.config.js -k component -D author=John -D license=Apache-2.0 MyComponent
```
### Before Write option

View File

@@ -7,12 +7,9 @@ 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.
The config takes similar arguments to the command line. See the full
[API documentation](https://chenasraf.github.io/simple-scaffold/docs/api/interfaces/ScaffoldConfig)
for all configuration options and their behavior.
```ts
interface ScaffoldConfig {
@@ -20,19 +17,25 @@ interface ScaffoldConfig {
templates: string[]
output: FileResponse<string>
subdir?: boolean
data?: Record<string, any>
data?: Record<string, unknown>
overwrite?: FileResponse<boolean>
quiet?: boolean
verbose?: LogLevel
logLevel?: LogLevel
dryRun?: boolean
helpers?: Record<string, Helper>
subdirHelper?: DefaultHelpers | string
inputs?: Record<string, ScaffoldInput>
beforeWrite?(
content: Buffer,
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
}
interface ScaffoldInput {
message?: string
required?: boolean
default?: string
}
```
### Before Write option
@@ -46,6 +49,31 @@ to be used as the file contents.
Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by
Simple Scaffold.
### Inputs
The `inputs` option lets you define fields that will be prompted interactively (when running in a
TTY) and merged into the template data. This is useful when your templates need user-specific
values.
```typescript
import Scaffold from "simple-scaffold"
await Scaffold({
name: "component",
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
})
// In templates: {{ author }}, {{ license }}
```
- **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
## Example
This is an example of loading a complete scaffold via Node.js:
@@ -53,7 +81,7 @@ This is an example of loading a complete scaffold via Node.js:
```typescript
import Scaffold from "simple-scaffold"
const config = {
await Scaffold({
name: "component",
templates: [path.join(__dirname, "scaffolds", "component")],
output: path.join(__dirname, "src", "components"),
@@ -65,10 +93,12 @@ const config = {
helpers: {
twice: (text) => [text, text].join(" "),
},
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
// return a string to replace the final file contents after pre-processing, or `undefined`
// to keep it as-is
beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase(),
}
const scaffold = Scaffold(config)
})
```

View File

@@ -31,7 +31,6 @@ title: Examples
### Output
- Output file path:
- With `subdir = false` (default):
```text

View File

@@ -1,10 +1,11 @@
---
title: Usage
sidebar_position: 0
---
- [CLI Usage](cli)
- [Template Files](templates)
- [Configuration Files](configuration_files)
- [CLI Usage](cli)
- [Node.js Usage](node)
- [Examples](examples)
- [Migration](migration)
- [Node.js Usage](node)
- [Template Files](templates)

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,
},
@@ -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": "~5.9.3"
},
"browserslist": {
"production": [

10217
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,202 +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: {
// "#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,6 +1,6 @@
{
"name": "simple-scaffold",
"version": "2.3.0",
"version": "3.0.0",
"description": "Generate any file structure - from single components to entire app boilerplates, with a single command.",
"homepage": "https://chenasraf.github.io/simple-scaffold",
"repository": {
@@ -27,32 +27,36 @@
],
"scripts": {
"clean": "rimraf 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",
"coverage": "open coverage/lcov-report/index.html",
"cmd": "ts-node src/cmd.ts",
"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",
"ci": "pnpm install --frozen-lockfile"
},
"dependencies": {
"date-fns": "^4.0.0",
"glob": "^11.0.0",
"@inquirer/input": "^5.0.10",
"@inquirer/select": "^5.1.2",
"date-fns": "^4.1.0",
"glob": "^13.0.6",
"handlebars": "^4.7.8",
"massarg": "2.0.1"
"massarg": "2.1.1"
},
"devDependencies": {
"@types/jest": "^29.5.13",
"@types/mock-fs": "^4.13.4",
"@types/node": "^22.5.5",
"jest": "^29.7.0",
"mock-fs": "^5.2.0",
"rimraf": "^6.0.1",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
"@eslint/js": "^10.0.1",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"mock-fs": "^5.5.0",
"rimraf": "^6.1.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1",
"vite-node": "^6.0.0",
"vitest": "^4.1.0"
}
}

3881
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,80 @@
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

@@ -3,13 +3,14 @@
import path from "node:path"
import fs from "node:fs/promises"
import { massarg } from "massarg"
import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types"
import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig, ScaffoldConfigMap } from "./types"
import { Scaffold } from "./scaffold"
import { getConfigFile, parseAppendData, parseConfigFile } from "./config"
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 "./utils"
import { colorize } from "./colors"
import { promptForMissingConfig, resolveInputs } from "./prompts"
export async function parseCliArgs(args = process.argv.slice(2)) {
const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false))
@@ -30,17 +31,38 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
return
}
log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`)
const tmpPath = generateUniqueTmpPath()
config.tmpDir = generateUniqueTmpPath()
try {
// Auto-detect config file in cwd if not explicitly provided
if (!config.config && !config.git) {
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 early so we can prompt for template key
const hasConfigSource = Boolean(config.config || config.git)
let configMap: ScaffoldConfigMap | undefined
if (hasConfigSource) {
configMap = await getConfigFile(config)
}
// Prompt for missing values interactively
config = await promptForMissingConfig(config, configMap)
log(config, LogLevel.debug, "Parsing config file...", config)
const parsed = await parseConfigFile(config, tmpPath)
await Scaffold(parsed)
const parsed = await parseConfigFile(config)
const resolved = await resolveInputs(parsed)
await Scaffold(resolved)
} catch (e) {
const message = "message" in (e as any) ? (e as any).message : e?.toString()
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({
@@ -49,9 +71,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
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.",
"for this specific option. If omitted in an interactive terminal, you will be prompted.",
isDefault: true,
required: !isConfigProvided,
})
.option({
name: "config",
@@ -68,15 +89,15 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
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 subdir will be created inside " +
"this path. Default is current working directory.",
required: !isConfigProvided,
"this path. If omitted in an interactive terminal, you will be prompted.",
})
.option({
name: "templates",
@@ -85,8 +106,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",
@@ -171,7 +192,6 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
aliases: ["ls"],
description: "List all available templates for a given config. See `list -h` for more information.",
run: async (_config) => {
const tmpPath = generateUniqueTmpPath()
const config = {
templates: [],
name: "",
@@ -180,19 +200,20 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
subdir: false,
overwrite: false,
dryRun: false,
tmpDir: generateUniqueTmpPath(),
..._config,
config: _config.config ?? (!_config.git ? process.cwd() : undefined),
}
try {
const file = await getConfigFile(config, tmpPath)
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 any) ? (e as any).message : e?.toString()
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 })
}
},
})

69
src/colors.ts Normal file
View File

@@ -0,0 +1,69 @@
/** 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 = 0
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,9 +1,6 @@
import path from "node:path"
import fs from "node:fs/promises"
import {
ConfigLoadConfig,
FileResponse,
FileResponseHandler,
LogConfig,
LogLevel,
RemoteConfigLoadConfig,
@@ -12,35 +9,19 @@ import {
ScaffoldConfigFile,
ScaffoldConfigMap,
} from "./types"
import { handlebarsParse } from "./parser"
import { log } from "./logger"
import { resolve, wrapNoopResolver } from "./utils"
import { getGitConfig } from "./git"
import { createDirIfNotExists, getUniqueTmpPath, isDir, pathExists } from "./file"
import { exec, spawn } from "node:child_process"
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) }
}
@@ -51,8 +32,8 @@ function isWrappedWithQuotes(string: string): boolean {
return (string.startsWith('"') && string.endsWith('"')) || (string.startsWith("'") && string.endsWith("'"))
}
/** @internal */
export async function getConfigFile(config: ScaffoldCmdConfig, tmpPath: string): Promise<ScaffoldConfigMap> {
/** 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.info, `Loading config from GitHub ${config.git}`)
config.git = githubPartToUrl(config.git)
@@ -65,13 +46,11 @@ export async function getConfigFile(config: ScaffoldCmdConfig, tmpPath: string):
log(config, LogLevel.info, `Loading config from file ${configFilename}`)
const configPromise = await (isGit
? getRemoteConfig({ git: configPath, config: configFilename, logLevel: config.logLevel, tmpPath })
? getRemoteConfig({ git: configPath, config: configFilename, logLevel: config.logLevel, tmpDir: config.tmpDir! })
: 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) {
log(config, LogLevel.debug, "Config is a function or promise, resolving...")
configImport = await resolve(configImport.default, config)
@@ -79,9 +58,24 @@ export async function getConfigFile(config: ScaffoldCmdConfig, tmpPath: string):
return configImport
}
/** @internal */
export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string): Promise<ScaffoldConfig> {
let output: ScaffoldConfig = { ...config, beforeWrite: undefined }
/**
* 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
@@ -91,7 +85,7 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string
if (shouldLoadConfig) {
const key = config.key ?? "default"
const configImport = await getConfigFile(config, tmpPath)
const configImport = await getConfigFile(config)
if (!configImport[key]) {
throw new Error(`Template "${key}" not found in ${config.config}`)
@@ -100,11 +94,13 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string
const imported = configImport[key]
log(config, LogLevel.debug, "Imported result", imported)
output = {
...config,
...output,
...imported,
beforeWrite: undefined,
templates: config.templates || imported.templates,
output: config.output || imported.output,
data: {
...(imported as any).data,
...imported.data,
...config.data,
},
}
@@ -122,7 +118,7 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string
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")) {
@@ -131,7 +127,7 @@ export function githubPartToUrl(part: string): string {
return gitUrl.toString()
}
/** @internal */
/** 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>
@@ -154,13 +150,13 @@ export async function getLocalConfig(config: ConfigLoadConfig & Partial<LogConfi
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.info, `Loading config from remote ${git}, config file ${configFile || "<auto-detect>"}`)
const url = new URL(git!)
const isHttp = url.protocol === "http:" || url.protocol === "https:"
@@ -170,14 +166,15 @@ export async function getRemoteConfig(
throw new Error(`Unsupported protocol ${url.protocol}`)
}
return getGitConfig(url, configFile, tmpPath, logConfig)
return getGitConfig(url, configFile, tmpDir, logConfig)
}
/** @internal */
/** 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) {
@@ -188,72 +185,3 @@ export async function findConfigFile(root: string): Promise<string> {
}
throw new Error(`Could not find config file in git repo`)
}
function wrapBeforeWrite(
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
beforeWrite: string,
): ScaffoldConfig["beforeWrite"] {
return async (content, rawContent, outputFile) => {
const tmpPath = path.join(getUniqueTmpPath(), path.basename(outputFile))
await createDirIfNotExists(path.dirname(tmpPath), config)
const ext = path.extname(outputFile)
const rawTmpPath = tmpPath.replace(ext, ".raw" + ext)
try {
log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite)
let cmd = await prepareBeforeWriteCmd({ beforeWrite, tmpPath, 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(tmpPath, { force: true })
await fs.rm(rawTmpPath, { force: true })
}
}
}
async function prepareBeforeWriteCmd({
beforeWrite,
tmpPath,
content,
rawTmpPath,
rawContent,
}: {
beforeWrite: string
tmpPath: 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(tmpPath, content)
cmd = beforeWrite.replaceAll(pathReg, tmpPath)
}
if (rawPathReg.test(beforeWrite)) {
await fs.writeFile(rawTmpPath, rawContent)
cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath)
}
if (!cmd) {
await fs.writeFile(tmpPath, content)
cmd = [beforeWrite, tmpPath].join(" ")
}
return cmd
}

View File

@@ -1,76 +1,55 @@
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 { 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: 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: 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
}
}
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(), "")
/** 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
}
/** 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 (
@@ -81,30 +60,25 @@ export async function getFileList(config: ScaffoldConfig, templates: string[]):
).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)
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, "**", "*")
const _isGlob = hasMagic(template)
log(config, LogLevel.debug, "before isDir", "isGlob:", _isGlob, 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 { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template: _template }
return { baseTemplatePath, origTemplate: template, isDirOrGlob, isGlob: _isGlob, template: resolvedTemplate }
}
/** Complete information about a template file's output destination. */
export interface OutputFileInfo {
inputPath: string
outputPathOpt: string
@@ -113,20 +87,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,
{
@@ -163,6 +141,7 @@ export async function copyFileTransformed(
log(config, LogLevel.info, "Done.")
}
/** 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(),
@@ -178,43 +157,39 @@ export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, base
)
}
/**
* Processes a single template file: resolves output paths, creates directories,
* and writes the transformed output.
*/
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, {
templatePath,
basePath,
})
const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false)
try {
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(config, {
templatePath,
basePath,
})
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)
}
})
}
/** @internal */
export function getUniqueTmpPath(): string {
return path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
log(config, LogLevel.info, `Writing to ${outputPath}`)
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
throw e
}
}

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

@@ -0,0 +1,61 @@
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,31 +1,34 @@
import util from "util"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { colorize, TermColor } from "./utils"
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]: "blue",
[LogLevel.info]: "dim",
[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, TermColor> = {
[LogLevel.none]: "reset",
[LogLevel.debug]: "blue",
[LogLevel.info]: "dim",
[LogLevel.warning]: "yellow",
[LogLevel.error]: "red",
}
const colorFn = colorize[levelColor[level]]
const colorFn = colorize[LOG_LEVEL_COLOR[level]]
const key: "log" | "warn" | "error" = level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
const logFn: any = console[key]
const logFn: (..._args: unknown[]) => void = console[key]
logFn(
...obj.map((i) =>
i instanceof Error
@@ -37,6 +40,10 @@ export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void {
)
}
/**
* Logs detailed file processing information at debug level.
* @deprecated Use `log(config, LogLevel.debug, data)` directly instead.
*/
export function logInputFile(
config: ScaffoldConfig,
data: {
@@ -53,6 +60,7 @@ export function logInputFile(
log(config, LogLevel.debug, data)
}
/** Logs the full scaffold configuration at debug level, with a data summary at info level. */
export function logInitStep(config: ScaffoldConfig): void {
log(config, LogLevel.debug, "Full config:", {
name: config.name,

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,
@@ -62,9 +55,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,17 +105,17 @@ 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)

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(), "")
}

161
src/prompts.ts Normal file
View File

@@ -0,0 +1,161 @@
import input from "@inquirer/input"
import select from "@inquirer/select"
import { colorize } from "./colors"
import { ScaffoldCmdConfig, ScaffoldConfig, ScaffoldConfigMap, ScaffoldInput } 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 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) {
data[key] = await input({
message: colorize.cyan(def.message ?? `${key}:`),
required: true,
default: def.default,
validate: (value) => {
if (!value.trim()) return `${key} is required`
return true
},
})
} 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)
}
/**
* Fills in missing config values by prompting the user interactively.
* Only prompts when running in a TTY — in non-interactive mode, returns config as-is.
*/
export async function promptForMissingConfig(
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)
}
}
if (!config.output) {
config.output = await promptForOutput()
}
if (!config.templates || config.templates.length === 0) {
config.templates = await promptForTemplates()
}
return config
}
/**
* 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

@@ -8,20 +8,13 @@ import path from "node:path"
import os from "node:os"
import { handleErr, resolve } from "./utils"
import {
isDir,
removeGlob,
makeRelativePath,
getTemplateGlobInfo,
getFileList,
getBasePath,
handleTemplateFile,
GlobInfo,
} 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 } from "./logger"
import { parseConfigFile } from "./config"
import { resolveInputs } from "./prompts"
/**
* Create a scaffold using given `options`.
@@ -58,55 +51,65 @@ import { parseConfigFile } from "./config"
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
config.output ??= process.cwd()
config = await resolveInputs(config)
registerHelpers(config)
try {
config.data = { name: config.name, ...config.data }
logInitStep(config)
const excludes = config.templates.filter((t) => t.startsWith("!"))
const includes = config.templates.filter((t) => !t.startsWith("!"))
const templates: GlobInfo[] = []
for (let _template of includes) {
try {
const { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template } = await getTemplateGlobInfo(
config,
_template,
)
templates.push({ nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template })
} catch (e: any) {
handleErr(e)
}
}
const templates = await resolveTemplateGlobs(config, includes)
for (const tpl of templates) {
const files = await getFileList(config, [tpl.template, ...excludes])
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.nonGlobTemplate, "")))
const basePath = getBasePath(relPath)
logInputFile(config, {
originalTemplate: tpl.origTemplate,
relativePath: relPath,
parsedTemplate: tpl.template,
inputFilePath: file,
nonGlobTemplate: tpl.nonGlobTemplate,
basePath,
isDirOrGlob: tpl.isDirOrGlob,
isGlob: tpl.isGlob,
})
await handleTemplateFile(config, {
templatePath: file,
basePath,
})
}
await processTemplateGlob(config, tpl, excludes)
}
} catch (e: any) {
} catch (e: unknown) {
log(config, LogLevel.error, e)
throw e
}
}
/** 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. */
async function processTemplateGlob(config: ScaffoldConfig, tpl: GlobInfo, excludes: string[]): Promise<void> {
const files = await getFileList(config, [tpl.template, ...excludes])
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,
})
await handleTemplateFile(config, { templatePath: file, basePath })
}
}
/**
* Create a scaffold based on a config file or URL.
*
@@ -118,14 +121,12 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
* @category Main
* @return {Promise<void>} A promise that resolves when the scaffold is complete
*/
Scaffold.fromConfig = async function(
/** The path or URL to the config file */
Scaffold.fromConfig = async function (
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(),
@@ -136,11 +137,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

@@ -56,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.
@@ -165,6 +165,46 @@ 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>
/** @internal */
tmpDir?: string
}
/**
* Defines a single interactive input for a scaffold template.
*
* @category Config
*/
export interface ScaffoldInput {
/** 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 used when the user doesn't provide one. */
default?: string
}
/**
@@ -377,6 +417,8 @@ export type ScaffoldCmdConfig = {
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
/** @internal */
tmpDir?: string
}
/**
@@ -406,7 +448,7 @@ export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
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>
@@ -418,9 +460,10 @@ 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
@@ -15,65 +21,3 @@ export function wrapNoopResolver<T, R = T>(value: Resolver<T, R>): Resolver<T, R
return (_) => value
}
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
export type TermColor = keyof typeof colorMap
function _colorize(text: string, color: TermColor): string {
const c = colorMap[color]!
let r = 0
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 }
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,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/config"
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)
},
@@ -55,6 +57,39 @@ describe("config", () => {
test("works with quotes", () => {
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", () => {
@@ -64,44 +99,170 @@ describe("config", () => {
"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,
name: "-",
},
`/tmp/scaffold-config-${Date.now()}`,
),
).toEqual({ ...blankCliConf, name: "-" })
await parseConfigFile({
...blankCliConf,
name: "-",
tmpDir,
}),
).toEqual({ ...conf, name: "-", tmpDir, subdirHelper: undefined, beforeWrite: undefined })
})
describe("appendData", () => {
test("appends", async () => {
const result = await parseConfigFile(
{
...blankCliConf,
name: "-",
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,
name: "-",
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 any,
config: path.resolve(__dirname, "test-config.js"),
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.templates.length).toBeGreaterThan(0)
})
})
@@ -110,7 +271,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)
@@ -118,14 +279,70 @@ 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 any, "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)}'`,
@@ -140,7 +357,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)
@@ -161,12 +378,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 {
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()
const os = require("os")
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()
})
},
),
)
})

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 any }, 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 config at info level (debug only)", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.info,
data: { name: "test" },
}
logInitStep(config)
// Should only log the "Data:" line at info, not the "Full config:" at debug
expect(consoleSpy.log).toHaveBeenCalledTimes(1)
})
})
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)
})
})
})

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

@@ -0,0 +1,399 @@
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(),
}))
import inputMock from "@inquirer/input"
import selectMock from "@inquirer/select"
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")
})
})
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,4 @@
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"
@@ -79,14 +80,14 @@ const fileStructExcludes = {
output: {},
}
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction {
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)
// })
})
@@ -99,8 +100,10 @@ function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunct
}
describe("Scaffold", () => {
describe(
"create subdir",
withMock(fileStructNormal, () => {
test("should not create by default", async () => {
await Scaffold({
@@ -130,6 +133,7 @@ describe("Scaffold", () => {
describe(
"binary files",
withMock(fileStructWithBinary, () => {
test("should copy as-is", async () => {
await Scaffold({
@@ -197,9 +201,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(() => {
@@ -235,9 +239,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(() => {
@@ -276,7 +280,8 @@ describe("Scaffold", () => {
}),
)
describe("output structure", () => {
describe(
"output structure",
withMock(fileStructNested, () => {
test("should maintain input structure on output", async () => {
await Scaffold({
@@ -301,28 +306,31 @@ describe("Scaffold", () => {
expect(oneDeepFile.toString()).toEqual("Hello, my value is 1")
expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!")
})
})
}),
)
describe(
"file exclusion via glob pattern",
withMock(fileStructExcludes, () => {
test("should exclude files", async () => {
test("should only include matching files", async () => {
await Scaffold({
name: "app_name",
output: "output",
templates: ["input", "!exclude.txt"],
templates: ["input/include.*"],
data: { value: "1" },
logLevel: "none",
})
const includeFile = readFileSync(join(process.cwd(), "output", "app_name.txt"))
expect(includeFile.toString()).toEqual("This file should be included")
expect(() => readFileSync(join(process.cwd(), "output", "exclude.txt"))).toThrow()
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",
}
@@ -394,7 +402,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 () => {
@@ -521,4 +529,559 @@ 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")
})
},
),
)
})

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

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

24
tests/test-config.js Normal file
View File

@@ -0,0 +1,24 @@
// @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: {
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: "---",
},
}
}

View File

@@ -1,14 +1,55 @@
import { handleErr, resolve, colorize, TermColor } 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", () => {
@@ -16,26 +57,64 @@ 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 Function)("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 Function)("anything")).toBe(obj)
})
test("should wrap boolean value", () => {
const wrapped = wrapNoopResolver(true)
expect(typeof wrapped).toBe("function")
expect((wrapped as Function)(null)).toBe(true)
})
test("should wrap number value", () => {
const wrapped = wrapNoopResolver(42)
expect(typeof wrapped).toBe("function")
expect((wrapped as Function)(null)).toBe(42)
})
})
})
describe("colorize", () => {
it("should colorize text with red color", () => {
test("should colorize text with red color", () => {
const result = colorize("Hello", "red")
expect(result).toBe("\x1b[31mHello\x1b[0m")
})
it("should colorize text with bold", () => {
test("should colorize text with bold", () => {
const result = colorize("Hello", "bold")
expect(result).toBe("\x1b[1mHello\x1b[23m")
})
it("should reset color", () => {
test("should reset color", () => {
const result = colorize("Hello", "reset")
expect(result).toBe("\x1b[0mHello\x1b[0m")
})
it("should have all color functions", () => {
test("should have all color functions", () => {
const colors: TermColor[] = [
"reset",
"dim",
@@ -56,13 +135,73 @@ describe("colorize", () => {
})
})
it("should colorize text using colorize.red", () => {
test("should colorize text using colorize.red", () => {
const result = colorize.red("Hello")
expect(result).toBe("\x1b[31mHello\x1b[0m")
})
it("should colorize text using template strings with colorize.blue", () => {
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")
})
})

53
vite.config.ts Normal file
View File

@@ -0,0 +1,53 @@
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",
rollupOptions: {
external: [
"node:os",
"node:path",
"node:fs",
"node:fs/promises",
"node:constants",
"node:child_process",
"node:util",
"date-fns",
"date-fns/add",
"date-fns/format",
"date-fns/parseISO",
"glob",
"handlebars",
"handlebars/runtime",
"massarg",
"massarg/command",
],
},
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"],
},
})