mirror of
https://github.com/chenasraf/simple-scaffold.git
synced 2026-05-18 01:29:09 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8834e49085 | |||
| c797893eb5 | |||
| 5b5a1fa9a4 | |||
| f5ed38ad95 | |||
| 6f15f3db14 | |||
| 02dcbdaf13 | |||
| 970e8e0b37 | |||
| c860e4644a | |||
| bb0248f91a | |||
| 972d199fbb | |||
| b5fd1df821 | |||
| f6408f221d | |||
| 0a4ead17c0 | |||
| 7926b15053 | |||
| 9cdea1c5ea | |||
| 4a79961ae5 | |||
| 51f422cc90 | |||
| 93f3a4caaf | |||
| 04e7e895d7 | |||
| 68e6d17fa9 | |||
| d64dd4f0e7 | |||
| 519ef273ac | |||
| 1431fda3db | |||
| 2229a9cda1 | |||
| 1f80a50185 | |||
| e82827d909 | |||
| 2e49448e59 | |||
| 0ffd7ef788 | |||
| d16fb17c38 | |||
| af33c059b9 | |||
| d487d36b04 | |||
| 429f12d1b8 | |||
| dcba30689b | |||
| 7745385573 | |||
| 29f2afe097 | |||
| 4b0b4e7380 |
30
.github/workflows/docs.yml
vendored
30
.github/workflows/docs.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
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
|
||||
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -18,11 +19,12 @@ jobs:
|
||||
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
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test
|
||||
|
||||
build:
|
||||
@@ -30,11 +32,12 @@ jobs:
|
||||
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
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
release:
|
||||
@@ -59,15 +62,41 @@ jobs:
|
||||
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
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- run: npm i -g pnpm
|
||||
- run: pnpm run ci
|
||||
- run: |
|
||||
echo "node: $(node --version)"
|
||||
echo "npm: $(npm --version)"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: cd dist && npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- run: npm publish ./dist --provenance
|
||||
|
||||
docs:
|
||||
name: Deploy Documentation
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.release.outputs.release_created }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cd docs && pnpm install --frozen-lockfile
|
||||
- run: pnpm docs:build
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/build
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
docs/docs/api/
|
||||
examples/
|
||||
.github/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,5 +1,51 @@
|
||||
# Change Log
|
||||
|
||||
## [3.1.1](https://github.com/chenasraf/simple-scaffold/compare/v3.1.0...v3.1.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependencies ([f5ed38a](https://github.com/chenasraf/simple-scaffold/commit/f5ed38ad950e3738b173cd39baf2acc9e35bf8de))
|
||||
|
||||
## [3.1.0](https://github.com/chenasraf/simple-scaffold/compare/v3.0.0...v3.1.0) (2026-03-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* after scaffold hook ([0a4ead1](https://github.com/chenasraf/simple-scaffold/commit/0a4ead17c013b5410e8eec8000e83fa7b7186fbb))
|
||||
* config validation ([972d199](https://github.com/chenasraf/simple-scaffold/commit/972d199fbb7167506f76eba697fbea6acea9de56))
|
||||
* init command ([7926b15](https://github.com/chenasraf/simple-scaffold/commit/7926b15053726d5eb9e5772b5f04c3989e567b2c))
|
||||
* scaffoldignore ([b5fd1df](https://github.com/chenasraf/simple-scaffold/commit/b5fd1df821eba7e13e5ceedc53da550c8dd7cd8e))
|
||||
* select, confirm and number input types ([f6408f2](https://github.com/chenasraf/simple-scaffold/commit/f6408f221d84d84c5671737af63ff8079093fd27))
|
||||
* update all ouput logging ([bb0248f](https://github.com/chenasraf/simple-scaffold/commit/bb0248f91a012c297e408f15f0846c5272166543))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* interactive inputs with existing config/cli options ([c860e46](https://github.com/chenasraf/simple-scaffold/commit/c860e4644a3f3d4bd6b7dab9974accdf8bae9463))
|
||||
|
||||
## [3.0.0](https://github.com/chenasraf/simple-scaffold/compare/v2.3.3...v3.0.0) (2026-03-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add .scaffold.{ext} as auto-detected file ([04e7e89](https://github.com/chenasraf/simple-scaffold/commit/04e7e895d7d97e29cf1ded2f2d0ac8e6a237b997))
|
||||
* auto-detect config file ([68e6d17](https://github.com/chenasraf/simple-scaffold/commit/68e6d17fa9898dffbc620eba0140748a90bc007f))
|
||||
* interactive inputs ([519ef27](https://github.com/chenasraf/simple-scaffold/commit/519ef273ac3db4b7a1e71c8e1c456aa1334d6fbd))
|
||||
* predefined data inputs ([d64dd4f](https://github.com/chenasraf/simple-scaffold/commit/d64dd4f0e775d3bff3efb074d0af23d54edcbaab))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* string helpers to words parts conversion ([af33c05](https://github.com/chenasraf/simple-scaffold/commit/af33c059b91d3f463a5d174ab3a0119c577880c5))
|
||||
|
||||
## [2.3.3](https://github.com/chenasraf/simple-scaffold/compare/v2.3.2...v2.3.3) (2025-06-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* config CLI precedence over file ([4b0b4e7](https://github.com/chenasraf/simple-scaffold/commit/4b0b4e73803ff741120b18767ded88db324a8844))
|
||||
|
||||
## [2.3.2](https://github.com/chenasraf/simple-scaffold/compare/v2.3.1...v2.3.2) (2024-10-27)
|
||||
|
||||
|
||||
|
||||
376
README.md
376
README.md
@@ -13,17 +13,12 @@
|
||||
|
||||
</h2>
|
||||
|
||||
Looking to streamline your workflow and get your projects up and running quickly? Look no further
|
||||
than Simple Scaffold - the easy-to-use NPM package that simplifies the process of organizing and
|
||||
copying your commonly-created files.
|
||||
Simple Scaffold is a file scaffolding tool. You define templates once, then generate files from them
|
||||
whenever you need — whether it's a single component or an entire app boilerplate.
|
||||
|
||||
With its agnostic and un-opinionated approach, Simple Scaffold can handle anything from a few simple
|
||||
files to an entire app boilerplate setup. Plus, with the power of **Handlebars.js** syntax, you can
|
||||
easily replace custom data and personalize your files to fit your exact needs. But that's not all -
|
||||
you can also use it to loop through data, use conditions, and write custom functions using helpers.
|
||||
|
||||
Don't waste any more time manually copying and pasting files - let Simple Scaffold do the heavy
|
||||
lifting for you and start building your projects faster and more efficiently today!
|
||||
Templates use **Handlebars.js** syntax, so you can inject data, loop over lists, use conditionals,
|
||||
and write custom helpers. It works as a CLI or as a Node.js library, and it doesn't care what kind
|
||||
of files you're generating.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -33,149 +28,292 @@ lifting for you and start building your projects faster and more efficiently tod
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
> **Full documentation is available at
|
||||
> [chenasraf.github.io/simple-scaffold](https://chenasraf.github.io/simple-scaffold)** — including
|
||||
> detailed guides on [CLI usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli),
|
||||
> [Node.js API](https://chenasraf.github.io/simple-scaffold/docs/usage/node),
|
||||
> [templates](https://chenasraf.github.io/simple-scaffold/docs/usage/templates),
|
||||
> [configuration files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files),
|
||||
> [examples](https://chenasraf.github.io/simple-scaffold/docs/usage/examples), and
|
||||
> [migration from v1/v2](https://chenasraf.github.io/simple-scaffold/docs/usage/migration).
|
||||
|
||||
See full documentation [here](https://chenasraf.github.io/simple-scaffold).
|
||||
## Table of Contents
|
||||
|
||||
- [Command Line Interface (CLI) usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli)
|
||||
- [Node.js usage](https://chenasraf.github.io/simple-scaffold/docs/usage/node)
|
||||
- [Templates](https://chenasraf.github.io/simple-scaffold/docs/usage/templates)
|
||||
- [Configuration Files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files)
|
||||
- [Migration](https://chenasraf.github.io/simple-scaffold/docs/usage/migration)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Configuration Files](#configuration-files)
|
||||
- [Templates](#templates)
|
||||
- [Interactive Mode & Inputs](#interactive-mode--inputs)
|
||||
- [Remote Templates](#remote-templates)
|
||||
- [CLI Reference](#cli-reference)
|
||||
- [Node.js API](#nodejs-api)
|
||||
- [Built-in Helpers](#built-in-helpers)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Cheat Sheet
|
||||
|
||||
A quick rundown of common usage scenarios:
|
||||
|
||||
- Remote template config file on GitHub:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -g username/repository -c scaffold.js -k component NewComponentName
|
||||
```
|
||||
|
||||
- Local template config file:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -c scaffold.js -k component NewComponentName
|
||||
```
|
||||
|
||||
- Local one-time usage:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -t templates/component -o src/components NewComponentName
|
||||
```
|
||||
|
||||
### Remote Configurations
|
||||
|
||||
The fastest way to get started is to is to re-use someone else's (or your own) work using a template
|
||||
repository.
|
||||
|
||||
A remote config can be loaded in one of these ways:
|
||||
|
||||
- For templates hosted on GitHub, the syntax is `-g user/repository_name`
|
||||
- For other Git platforms like GitLab, use `-g https://example.com/user/repository_name.git`
|
||||
|
||||
These remote configurations support multiple scaffold groups, which can be specified using the
|
||||
`--key` or `-k` argument:
|
||||
### Install
|
||||
|
||||
```sh
|
||||
$ npx simple-scaffold \
|
||||
-g chenasraf/simple-scaffold \
|
||||
-k component \
|
||||
PageWrapper
|
||||
|
||||
# equivalent to:
|
||||
$ npx simple-scaffold \
|
||||
-g https://github.com/chenasraf/simple-scaffold.git \
|
||||
-c scaffold.config.js \
|
||||
-k component \
|
||||
PageWrapper
|
||||
npm install -D simple-scaffold
|
||||
# or use directly with npx
|
||||
npx simple-scaffold
|
||||
```
|
||||
|
||||
By default, the template name is set to `default` when the `--key` option is not provided.
|
||||
### Initialize a Project
|
||||
|
||||
See information about each option and flag using the `--help` flag, or read the
|
||||
[CLI documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/cli). For information
|
||||
about how configuration files work, [see below](#configuration-files).
|
||||
Run `init` to create a config file and an example template:
|
||||
|
||||
### Configuration Files
|
||||
```sh
|
||||
npx simple-scaffold init
|
||||
```
|
||||
|
||||
You can use a config file to more easily maintain all your scaffold definitions.
|
||||
This creates `scaffold.config.js` and `templates/default/{{name}}.md`. Now generate files:
|
||||
|
||||
`scaffold.config.js`
|
||||
```sh
|
||||
npx simple-scaffold MyProject
|
||||
```
|
||||
|
||||
### One-off Usage (No Config)
|
||||
|
||||
Generate files from a template directory without a config file:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -t templates/component -o src/components MyComponent
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
Config files let you define reusable scaffold definitions. Simple Scaffold **auto-detects** config
|
||||
files in the current directory — no `--config` flag needed.
|
||||
|
||||
It searches for these files in order:
|
||||
|
||||
`scaffold.config.{mjs,cjs,js,json}`, `scaffold.{mjs,cjs,js,json}`, `.scaffold.{mjs,cjs,js,json}`
|
||||
|
||||
### Example
|
||||
|
||||
```js
|
||||
// scaffold.config.js
|
||||
module.exports = {
|
||||
// use "default" to avoid needing to specify key
|
||||
// in this case the key is "component"
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
data: {
|
||||
// ...
|
||||
},
|
||||
page: {
|
||||
templates: ["templates/page"],
|
||||
output: "src/pages",
|
||||
subdir: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -k component MyComponent
|
||||
npx simple-scaffold -k page Dashboard
|
||||
```
|
||||
|
||||
Use the key `default` to skip the `-k` flag entirely.
|
||||
|
||||
### Listing Available Templates
|
||||
|
||||
```sh
|
||||
npx simple-scaffold list
|
||||
npx simple-scaffold list -c path/to/config.js
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
Templates are regular files in a directory. Both **file names** and **file contents** support
|
||||
Handlebars syntax. Simple Scaffold preserves the directory structure of your template folder.
|
||||
|
||||
### Example Template
|
||||
|
||||
`templates/component/{{pascalCase name}}.tsx`
|
||||
|
||||
```tsx
|
||||
// Created: {{ now 'yyyy-MM-dd' }}
|
||||
import React from "react"
|
||||
|
||||
export default {{ pascalCase name }}: React.FC = (props) => {
|
||||
return (
|
||||
<div className="{{ camelCase name }}">{{ pascalCase name }} Component</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Running `npx simple-scaffold -t templates/component -o src/components PageWrapper` produces
|
||||
`src/components/PageWrapper.tsx` with all tokens replaced.
|
||||
|
||||
### Glob Patterns & Exclusions
|
||||
|
||||
Template paths support globs and negation:
|
||||
|
||||
```js
|
||||
{
|
||||
templates: ["templates/component/**", "!templates/component/README.md"]
|
||||
}
|
||||
```
|
||||
|
||||
### .scaffoldignore
|
||||
|
||||
Place a `.scaffoldignore` file in your template directory to exclude files. It works like
|
||||
`.gitignore` — one pattern per line, `#` for comments.
|
||||
|
||||
## Interactive Mode & Inputs
|
||||
|
||||
When running in a terminal, Simple Scaffold prompts for any missing required values (name, output,
|
||||
template key). Config files can also define **inputs** — custom fields that are prompted
|
||||
interactively:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
inputs: {
|
||||
author: { type: "text", message: "Author name", required: true },
|
||||
license: { type: "select", message: "License", options: ["MIT", "Apache-2.0", "GPL-3.0"] },
|
||||
isPublic: { type: "confirm", message: "Public package?" },
|
||||
priority: { type: "number", message: "Priority level", default: 1 },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then call your scaffold like this:
|
||||
**Input types:** `text` (default), `select`, `confirm`, `number`
|
||||
|
||||
Pre-fill inputs from the command line to skip prompts:
|
||||
|
||||
```sh
|
||||
$ npx simple-scaffold -c scaffold.config.js PageWrapper
|
||||
npx simple-scaffold -k component -D author=John -D license=MIT MyComponent
|
||||
```
|
||||
|
||||
This will allow you to avoid needing to remember which configs are needed or to store them in a
|
||||
one-liner in `package.json` which can get pretty long and messy, and harder to maintain.
|
||||
## Remote Templates
|
||||
|
||||
Also, this allows you to define more complex scaffolds with logic without having to use the Node.js
|
||||
API directly. (Of course you always have the option to still do so if you wish)
|
||||
|
||||
More information can be found at the
|
||||
[Configuration Files documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files).
|
||||
|
||||
### Templates Structure
|
||||
|
||||
Templates are **any file** in the a directory given to `--templates`.
|
||||
|
||||
Simple Scaffold will maintain any file and directory structure you try to generate, while replacing
|
||||
any tokens such as `{{ name }}` or other custom-data using
|
||||
[Handlebars.js](https://handlebarsjs.com/).
|
||||
|
||||
`templates/component/{{ pascalName name }}.tsx`
|
||||
|
||||
```tsx
|
||||
// Created: {{ now 'yyyy-MM-dd' }}
|
||||
import React from 'react'
|
||||
|
||||
export default {{pascalCase name}}: React.FC = (props) => {
|
||||
return (
|
||||
<div className="{{camelCase name}}">{{pascalCase name}} Component</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
To generate the template output once without saving a configuration file, run:
|
||||
Use templates from any Git repository:
|
||||
|
||||
```sh
|
||||
# generate single component
|
||||
$ npx simple-scaffold \
|
||||
-t templates/component \
|
||||
-o src/components \
|
||||
PageWrapper
|
||||
# GitHub shorthand
|
||||
npx simple-scaffold -g username/repo -k component MyComponent
|
||||
|
||||
# Full Git URL (GitLab, Bitbucket, etc.)
|
||||
npx simple-scaffold -g https://gitlab.com/user/repo.git -k component MyComponent
|
||||
```
|
||||
|
||||
This will immediately create the following file: `src/components/PageWrapper.tsx`
|
||||
The repository is cloned to a temporary directory, used, and cleaned up automatically.
|
||||
|
||||
```tsx
|
||||
// Created: 2077-01-01
|
||||
import React from 'react'
|
||||
## CLI Reference
|
||||
|
||||
export default PageWrapper: React.FC = (props) => {
|
||||
return (
|
||||
<div className="pageWrapper">PageWrapper Component</div>
|
||||
)
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------- | ---------------------------------------- |
|
||||
| `[name]` | Generate files from a template (default) |
|
||||
| `init` | Create config file and example template |
|
||||
| `list` / `ls` | List available template keys in a config |
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------------------ | --------- | ---------------------------------------------------- | ----------- |
|
||||
| `--config` | `-c` | Path to config file or directory | auto-detect |
|
||||
| `--git` | `-g` | Git URL or GitHub shorthand | |
|
||||
| `--key` | `-k` | Template key from config | `default` |
|
||||
| `--output` | `-o` | Output directory | |
|
||||
| `--templates` | `-t` | Template file paths or globs | |
|
||||
| `--data` | `-d` | Custom JSON data | |
|
||||
| `--append-data` | `-D` | Key-value data (`key=string`, `key:=raw`) | |
|
||||
| `--subdir`/`--no-subdir` | `-s`/`-S` | Create parent directory with input name | `false` |
|
||||
| `--subdir-helper` | `-H` | Helper to transform subdir name | |
|
||||
| `--overwrite`/`--no-overwrite` | `-w`/`-W` | Overwrite existing files | `false` |
|
||||
| `--dry-run` | `-dr` | Preview output without writing files | `false` |
|
||||
| `--before-write` | `-B` | Script to run before each file is written | |
|
||||
| `--after-scaffold` | `-A` | Shell command to run after scaffolding | |
|
||||
| `--quiet` | `-q` | Suppress output | |
|
||||
| `--log-level` | `-l` | Log level (`none`, `debug`, `info`, `warn`, `error`) | `info` |
|
||||
| `--version` | `-v` | Show version | |
|
||||
| `--help` | `-h` | Show help | |
|
||||
|
||||
## Node.js API
|
||||
|
||||
```js
|
||||
import Scaffold from "simple-scaffold"
|
||||
|
||||
// Basic usage
|
||||
const scaffold = new Scaffold({
|
||||
name: "MyComponent",
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
})
|
||||
await scaffold.run()
|
||||
|
||||
// Load from config file
|
||||
const scaffold = await Scaffold.fromConfig("scaffold.config.js", {
|
||||
key: "component",
|
||||
name: "MyComponent",
|
||||
})
|
||||
await scaffold.run()
|
||||
```
|
||||
|
||||
### Config Options
|
||||
|
||||
| Option | Type | Description |
|
||||
| --------------- | -------------------------- | ------------------------------------- |
|
||||
| `name` | `string` | Name for generated files (required) |
|
||||
| `templates` | `string[]` | Template paths or globs (required) |
|
||||
| `output` | `string \| Function` | Output directory or per-file function |
|
||||
| `data` | `Record<string, unknown>` | Custom template data |
|
||||
| `inputs` | `Record<string, Input>` | Interactive input definitions |
|
||||
| `helpers` | `Record<string, Function>` | Custom Handlebars helpers |
|
||||
| `subdir` | `boolean` | Create parent directory with name |
|
||||
| `subdirHelper` | `string` | Helper for subdir name transformation |
|
||||
| `overwrite` | `boolean \| Function` | Overwrite existing files |
|
||||
| `dryRun` | `boolean` | Preview without writing |
|
||||
| `logLevel` | `string` | Log verbosity |
|
||||
| `beforeWrite` | `Function` | Async hook before each file write |
|
||||
| `afterScaffold` | `Function \| string` | Hook after all files are written |
|
||||
|
||||
## Built-in Helpers
|
||||
|
||||
All helpers work in both file names and file contents.
|
||||
|
||||
### Case Helpers
|
||||
|
||||
| Helper | Example Input | Output |
|
||||
| ------------ | ------------- | --------- |
|
||||
| `camelCase` | `my name` | `myName` |
|
||||
| `pascalCase` | `my name` | `MyName` |
|
||||
| `snakeCase` | `my name` | `my_name` |
|
||||
| `kebabCase` | `my name` | `my-name` |
|
||||
| `hyphenCase` | `my name` | `my-name` |
|
||||
| `startCase` | `my name` | `My Name` |
|
||||
| `upperCase` | `my name` | `MY NAME` |
|
||||
| `lowerCase` | `My Name` | `my name` |
|
||||
|
||||
### Date Helpers
|
||||
|
||||
```handlebars
|
||||
{{now "yyyy-MM-dd"}}
|
||||
{{now "yyyy-MM-dd HH:mm" -1 "hours"}}
|
||||
{{date myDateVar "yyyy-MM-dd"}}
|
||||
{{date "2077-01-01T00:00:00Z" "yyyy-MM-dd" 7 "days"}}
|
||||
```
|
||||
|
||||
### Custom Helpers
|
||||
|
||||
Add your own via config:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
helpers: {
|
||||
shout: (str) => str.toUpperCase() + "!!!",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -189,7 +327,7 @@ just a small amount to help sustain this project, I would be very very thankful!
|
||||
<img
|
||||
height='36'
|
||||
src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3'
|
||||
alt='Buy Me a Coffee at ko-fi.com'
|
||||
alt='Buy Me a Coffee at ko-fi.com'
|
||||
/>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,138 +1,134 @@
|
||||
---
|
||||
title: Template Files
|
||||
title: Templates
|
||||
---
|
||||
|
||||
# Preparing template files
|
||||
# Templates
|
||||
|
||||
Put your template files anywhere, and fill them with tokens for replacement.
|
||||
Templates are regular files in a directory. Both **file names** and **file contents** support
|
||||
[Handlebars.js](https://handlebarsjs.com/) syntax for token replacement.
|
||||
|
||||
Each template (not file) in the config array is parsed individually, and copied to the output
|
||||
directory. If a single template path contains multiple files (e.g. if you use a folder path or a
|
||||
glob pattern), the first directory up the tree of that template will become the base inside the
|
||||
defined output path for that template, while copying files recursively and maintaining their
|
||||
relative structure.
|
||||
## How Templates Are Resolved
|
||||
|
||||
Examples:
|
||||
Each path in the `templates` array is resolved individually. If a path points to a directory or glob
|
||||
pattern containing multiple files, the first directory up the tree becomes the base path — files are
|
||||
then copied recursively into `output`, preserving their relative structure.
|
||||
|
||||
> In the following examples, the config `name` is `AppName`, and the config `output` is `src`.
|
||||
> In the examples below, `name` is `AppName` and `output` is `src`.
|
||||
|
||||
| Input template | Files in template | Output path(s) |
|
||||
| Template path | Files found | Output |
|
||||
| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `./templates/{{ name }}.txt` | `./templates/{{ name }}.txt` | `src/AppName.txt` |
|
||||
| `./templates/directory` | `outer/{{name}}.txt`,<br />`outer2/inner/{{name}}.txt` | `src/outer/AppName.txt`,<br />`src/outer2/inner/AppName.txt` |
|
||||
| `./templates/others/**/*.txt` | `outer/{{name}}.jpg`,<br />`outer2/inner/{{name}}.txt` | `src/outer2/inner/AppName.txt` |
|
||||
|
||||
## Variable/token replacement
|
||||
### Glob Patterns & Exclusions
|
||||
|
||||
Scaffolding will replace `{{ varName }}` in both the file name and its contents and put the
|
||||
transformed files in the output directory.
|
||||
Template paths support glob patterns and negation with `!`:
|
||||
|
||||
The data available for the template parser is the data you pass to the `data` config option (or
|
||||
`--data` argument in CLI).
|
||||
|
||||
For example, using the following command:
|
||||
|
||||
```bash
|
||||
npx simple-scaffold@latest \
|
||||
--templates templates/components/{{name}}.jsx \
|
||||
--output src/components \
|
||||
--create-sub-folder true \
|
||||
MyComponent
|
||||
```
|
||||
|
||||
Will output a file with the path:
|
||||
|
||||
```text
|
||||
<working_dir>/src/components/MyComponent.jsx
|
||||
```
|
||||
|
||||
The contents of the file will be transformed in a similar fashion.
|
||||
|
||||
Your `data` will be pre-populated with the following:
|
||||
|
||||
- `{{name}}`: raw name of the component as you entered it
|
||||
|
||||
> Simple-Scaffold uses [Handlebars.js](https://handlebarsjs.com/) for outputting the file contents.
|
||||
> Any `data` you add in the config will be available for use with their names wrapped in `{{` and
|
||||
> `}}`. Other Handlebars built-ins such as `each`, `if` and `with` are also supported, see
|
||||
> [Handlebars.js Language Features](https://handlebarsjs.com/guide/#language-features) for more
|
||||
> information.
|
||||
|
||||
## Helpers
|
||||
|
||||
### Built-in Helpers
|
||||
|
||||
Simple-Scaffold provides some built-in text transformation filters usable by Handlebars.
|
||||
|
||||
For example, you may use `{{ snakeCase name }}` inside a template file or filename, and it will
|
||||
replace `My Name` with `my_name` when producing the final value.
|
||||
|
||||
#### Capitalization Helpers
|
||||
|
||||
| Helper name | Example code | Example output |
|
||||
| ------------ | ----------------------- | -------------- |
|
||||
| [None] | `{{ name }}` | my name |
|
||||
| `camelCase` | `{{ camelCase name }}` | myName |
|
||||
| `snakeCase` | `{{ snakeCase name }}` | my_name |
|
||||
| `startCase` | `{{ startCase name }}` | My Name |
|
||||
| `kebabCase` | `{{ kebabCase name }}` | my-name |
|
||||
| `hyphenCase` | `{{ hyphenCase name }}` | my-name |
|
||||
| `pascalCase` | `{{ pascalCase name }}` | MyName |
|
||||
| `upperCase` | `{{ upperCase name }}` | MY NAME |
|
||||
| `lowerCase` | `{{ lowerCase name }}` | my name |
|
||||
|
||||
#### Date helpers
|
||||
|
||||
| Helper name | Description | Example code | Example output |
|
||||
| -------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ |
|
||||
| `now` | Current date with format | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
|
||||
| `now` (with offset) | Current date with format, and with offset | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` |
|
||||
| `date` | Custom date with format | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
|
||||
| `date` (with offset) | Custom date with format, and with offset | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" -1 "days" }}` | `2041-31-12 15:00` |
|
||||
| `date` (with date from `--data`) | Custom date with format, with data from the `data` config option | `{{ date myCustomDate "yyyy-MM-dd HH:mm" }}` | `2042-01-01 12:00` |
|
||||
|
||||
Further details:
|
||||
|
||||
- We use [`date-fns`](https://date-fns.org/docs/) for parsing/manipulating the dates. If you want
|
||||
more information on the date tokens to use, refer to
|
||||
[their format documentation](https://date-fns.org/docs/format).
|
||||
|
||||
- The date helper format takes the following arguments:
|
||||
|
||||
```typescript
|
||||
(
|
||||
date: string,
|
||||
format: string,
|
||||
offsetAmount?: number,
|
||||
offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds"
|
||||
)
|
||||
```
|
||||
|
||||
- **The now helper** (for current time) takes the same arguments, minus the first one (`date`) as it
|
||||
is implicitly the current date:
|
||||
|
||||
```typescript
|
||||
(
|
||||
format: string,
|
||||
offsetAmount?: number,
|
||||
offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds"
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Helpers
|
||||
|
||||
You may also add your own custom helpers using the `helpers` options when using the JS API (rather
|
||||
than the CLI). The `helpers` option takes an object whose keys are helper names, and values are the
|
||||
transformation functions. For example, `upperCase` is implemented like so:
|
||||
|
||||
```typescript
|
||||
config.helpers = {
|
||||
upperCase: (text) => text.toUpperCase(),
|
||||
```js
|
||||
{
|
||||
templates: ["templates/component/**", "!templates/component/README.md"]
|
||||
}
|
||||
```
|
||||
|
||||
All of the above helpers (built in and custom) will also be available to you when using
|
||||
`subdirHelper` (`--sub-dir-helper`/`-H`) as a possible value.
|
||||
## Ignoring Files
|
||||
|
||||
> To see more information on how helpers work and more features, see
|
||||
> [Handlebars.js docs](https://handlebarsjs.com/guide/#custom-helpers).
|
||||
Place a `.scaffoldignore` file in your template directory to exclude files. It works like
|
||||
`.gitignore` — one pattern per line, `#` for comments.
|
||||
|
||||
```text
|
||||
# .scaffoldignore
|
||||
*.log
|
||||
node_modules
|
||||
README.md
|
||||
dist/**
|
||||
```
|
||||
|
||||
The `.scaffoldignore` file itself is never copied to the output.
|
||||
|
||||
Patterns are matched against both the file's basename and its path relative to the template
|
||||
directory, so `README.md` matches at any depth while `dist/**` only matches a `dist` directory at
|
||||
the template root.
|
||||
|
||||
## Token Replacement
|
||||
|
||||
Handlebars expressions like `{{ name }}` are replaced in both file names and file contents. The
|
||||
`name` variable is always available — it's the name you pass when running the scaffold.
|
||||
|
||||
Any additional data from `--data`, `-D`, `data` config, or [inputs](configuration_files#inputs) is
|
||||
also available.
|
||||
|
||||
```bash
|
||||
npx simple-scaffold \
|
||||
-t templates/component/{{name}}.jsx \
|
||||
-o src/components \
|
||||
MyComponent
|
||||
```
|
||||
|
||||
This produces `src/components/MyComponent.jsx`, with all tokens inside the file replaced as well.
|
||||
|
||||
All standard Handlebars features work — `{{#if}}`, `{{#each}}`, `{{#with}}`, and more. See
|
||||
[Handlebars.js Language Features](https://handlebarsjs.com/guide/#language-features) for details.
|
||||
|
||||
## Built-in Helpers
|
||||
|
||||
Simple Scaffold includes helpers you can use in templates and file names. Helpers can also be
|
||||
nested: `{{ pascalCase (snakeCase name) }}`.
|
||||
|
||||
### Case Helpers
|
||||
|
||||
| Helper | Usage | `my name` becomes |
|
||||
| ------------ | ----------------------- | ----------------- |
|
||||
| _(none)_ | `{{ name }}` | `my name` |
|
||||
| `camelCase` | `{{ camelCase name }}` | `myName` |
|
||||
| `pascalCase` | `{{ pascalCase name }}` | `MyName` |
|
||||
| `snakeCase` | `{{ snakeCase name }}` | `my_name` |
|
||||
| `kebabCase` | `{{ kebabCase name }}` | `my-name` |
|
||||
| `hyphenCase` | `{{ hyphenCase name }}` | `my-name` |
|
||||
| `startCase` | `{{ startCase name }}` | `My Name` |
|
||||
| `upperCase` | `{{ upperCase name }}` | `MY NAME` |
|
||||
| `lowerCase` | `{{ lowerCase name }}` | `my name` |
|
||||
|
||||
### Date Helpers
|
||||
|
||||
Both `now` and `date` use [`date-fns`](https://date-fns.org/docs/format) format tokens.
|
||||
|
||||
| Helper | Example | Output |
|
||||
| -------------------- | ---------------------------------------------------------------- | ------------------- |
|
||||
| `now` | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
|
||||
| `now` (with offset) | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` |
|
||||
| `date` | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` |
|
||||
| `date` (with offset) | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" -1 "days" }}` | `2041-12-31 15:00` |
|
||||
| `date` (from data) | `{{ date myCustomDate "yyyy-MM-dd HH:mm" }}` | _(depends on data)_ |
|
||||
|
||||
**Signatures:**
|
||||
|
||||
```typescript
|
||||
now(format: string, offsetAmount?: number, offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds")
|
||||
|
||||
date(date: string, format: string, offsetAmount?: number, offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds")
|
||||
```
|
||||
|
||||
## Custom Helpers
|
||||
|
||||
You can register custom Handlebars helpers via the `helpers` config option:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
helpers: {
|
||||
shout: (text) => text.toUpperCase() + "!!!",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then use in templates: `{{ shout name }}`.
|
||||
|
||||
All helpers (built-in and custom) are also available as values for `subdirHelper` (`--subdir-helper`
|
||||
/ `-H`).
|
||||
|
||||
For more on Handlebars helpers, see the
|
||||
[Handlebars.js docs](https://handlebarsjs.com/guide/#custom-helpers).
|
||||
|
||||
@@ -2,158 +2,204 @@
|
||||
title: Configuration Files
|
||||
---
|
||||
|
||||
If you want to have reusable configurations which are complex and don't fit into command lines
|
||||
easily, or just want to manage your templates easier, you can use configuration files to load your
|
||||
scaffolding configurations.
|
||||
# Configuration Files
|
||||
|
||||
## Creating config files
|
||||
Config files let you define reusable scaffold definitions — template paths, output directories,
|
||||
custom data, inputs, and hooks — all in one place.
|
||||
|
||||
Configuration files should be valid `.js`/`.mjs`/`.cjs`/`.json` files that contain valid Scaffold
|
||||
configurations.
|
||||
## Creating a Config File
|
||||
|
||||
Each file hold multiple scaffolds. Each scaffold is a key, and its value is the configuration. For
|
||||
example:
|
||||
The fastest way is to run `init`:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold init
|
||||
```
|
||||
|
||||
This creates a `scaffold.config.js` and an example template in `templates/default/`. See
|
||||
[`init` command](cli#init) for options.
|
||||
|
||||
### Config Structure
|
||||
|
||||
A config file exports an object mapping **template keys** to scaffold configurations:
|
||||
|
||||
```js
|
||||
// scaffold.config.js
|
||||
module.exports = {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
},
|
||||
page: {
|
||||
templates: ["templates/page"],
|
||||
output: "src/pages",
|
||||
subdir: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For the full list of options, see [ScaffoldConfig](../api/interfaces/ScaffoldConfig) or the
|
||||
[Node.js API](node) page.
|
||||
|
||||
### Dynamic Configs
|
||||
|
||||
JS config files can export a **function** that receives the CLI config and returns the scaffold map.
|
||||
This lets you pre-process arguments or add logic:
|
||||
|
||||
```js
|
||||
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
|
||||
module.exports = (config) => ({
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
data: { timestamp: Date.now() },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The function can also be `async`.
|
||||
|
||||
### Supported Formats
|
||||
|
||||
- `.js` (CommonJS)
|
||||
- `.cjs` (CommonJS, explicit)
|
||||
- `.mjs` (ESM)
|
||||
- `.json`
|
||||
|
||||
:::note The correct extension may depend on your `package.json` `"type"` field. Packages with
|
||||
`"type": "module"` may require `.mjs` or `.cjs` instead of `.js`. :::
|
||||
|
||||
## Using a Config File
|
||||
|
||||
### Auto-detection
|
||||
|
||||
Simple Scaffold automatically searches the current directory for a config file — no `--config` flag
|
||||
needed. The following names are tried in order:
|
||||
|
||||
1. `scaffold.config.{mjs,cjs,js,json}`
|
||||
2. `scaffold.{mjs,cjs,js,json}`
|
||||
3. `.scaffold.{mjs,cjs,js,json}`
|
||||
|
||||
```sh
|
||||
# Just run from the project root — config is found automatically
|
||||
npx simple-scaffold -k component MyComponent
|
||||
```
|
||||
|
||||
### Explicit Path
|
||||
|
||||
Use `--config` (`-c`) to point to a specific file or directory:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -c path/to/scaffold.config.js -k component MyComponent
|
||||
```
|
||||
|
||||
When a directory is given, the auto-detection order above is used within that directory.
|
||||
|
||||
### Default Template Key
|
||||
|
||||
If you don't provide `--key`, the `default` key is used:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
default: {
|
||||
templates: ["templates/default"],
|
||||
output: "src",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
# Uses the "default" key — no -k needed
|
||||
npx simple-scaffold MyProject
|
||||
```
|
||||
|
||||
If multiple keys exist and no `--key` is provided, you'll be prompted to select one interactively.
|
||||
|
||||
### Providing a Default Name
|
||||
|
||||
If your template doesn't need a dynamic name (e.g. common config files), set `name` in the config:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
eslint: {
|
||||
name: ".eslintrc",
|
||||
templates: ["templates/eslint"],
|
||||
output: ".",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The name can still be overridden with `--name` on the command line.
|
||||
|
||||
## Inputs
|
||||
|
||||
Inputs define custom fields that are prompted interactively and become template data variables:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For the full configuration options, see [ScaffoldConfigFile](../api/modules#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.
|
||||
|
||||
Another feature of using a JS file is you can export a function which will be loaded with the CMD
|
||||
config provided to Simple Scaffold. The `extra` key contains any values not consumed by built-in
|
||||
flags, so you can pre-process your args before outputting a config:
|
||||
|
||||
```js
|
||||
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
|
||||
module.exports = (config) => {
|
||||
console.log("Config:", config)
|
||||
return {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
inputs: {
|
||||
author: { message: "Author name", required: true },
|
||||
license: {
|
||||
type: "select",
|
||||
message: "License",
|
||||
options: ["MIT", "Apache-2.0", "GPL-3.0"],
|
||||
},
|
||||
private: { type: "confirm", message: "Private?", default: false },
|
||||
port: { type: "number", message: "Port", default: 3000 },
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
You will always be able to override it using `--name NewName`, but it will be given a value by
|
||||
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:
|
||||
|
||||
```sh
|
||||
simple-scaffold -c <file> -k <template_key>
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
simple-scaffold -c scaffold.json -k component MyComponentName
|
||||
```
|
||||
|
||||
If you don't want to supply a template/config name (e.g. `component`), `default` will be used:
|
||||
|
||||
```js
|
||||
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
|
||||
module.exports = {
|
||||
default: {
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
And then:
|
||||
Use them in templates as `{{ author }}`, `{{ license }}`, `{{ private }}`, `{{ port }}`.
|
||||
|
||||
**Input types:**
|
||||
|
||||
| Type | Description | Value type |
|
||||
| --------- | ------------------------------ | ---------- |
|
||||
| `text` | Free-form text input (default) | `string` |
|
||||
| `select` | Choose from a list of options | `string` |
|
||||
| `confirm` | Yes/no prompt | `boolean` |
|
||||
| `number` | Numeric input | `number` |
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- **Required** inputs are prompted if not provided via `--data` or `-D`
|
||||
- **Select and confirm** inputs are always prompted unless pre-provided
|
||||
- **Optional** inputs with a `default` use that value silently
|
||||
- In non-interactive environments (CI, piped input), only defaults are applied
|
||||
|
||||
Pre-fill inputs from the CLI:
|
||||
|
||||
```sh
|
||||
# will use 'default' template
|
||||
simple-scaffold -c scaffold.json MyComponentName
|
||||
npx simple-scaffold -k component -D author=John -D license=MIT MyComponent
|
||||
```
|
||||
|
||||
- When the a directory is given, the following files in the given directory will be tried in order:
|
||||
## Remote Templates (Git)
|
||||
|
||||
- `scaffold.config.*`
|
||||
- `scaffold.*`
|
||||
|
||||
Where `*` denotes any supported file extension, in the priority listed in
|
||||
[Supported file types](#supported-file-types)
|
||||
|
||||
- When the `template_key` is ommitted, `default` will be used as default.
|
||||
|
||||
### Supported file types
|
||||
|
||||
Any importable file is supported, depending on your build process.
|
||||
|
||||
Common files include:
|
||||
|
||||
- `*.mjs`
|
||||
- `*.cjs`
|
||||
- `*.js`
|
||||
- `*.json`
|
||||
|
||||
When filenames are ommited when loading configs, these are the file extensions that will be
|
||||
automatically tried, by the specified order of priority.
|
||||
|
||||
Note that you might need to find the correct extension of `.js`, `.cjs` or `.mjs` depending on your
|
||||
build process and your package type (for example, packages with `"type": "module"` in their
|
||||
`package.json` might be required to use `.mjs`.)
|
||||
|
||||
### Git/GitHub Templates
|
||||
|
||||
You may specify a git or GitHub url to use remote templates.
|
||||
|
||||
The command line option is `--git` or `-g`.
|
||||
|
||||
- You may specify a full git or HTTPS git URL, which will be tried
|
||||
- You may specify a git username and project if the project is on GitHub
|
||||
Load config files and templates from any Git repository:
|
||||
|
||||
```sh
|
||||
# GitHub shorthand
|
||||
simple-scaffold -g <username>/<project_name> [-c <filename>] [-k <template_key>]
|
||||
npx simple-scaffold -g username/repo -k component MyComponent
|
||||
|
||||
# Any git URL, git:// and https:// are supported
|
||||
simple-scaffold -g git://gitlab.com/<username>/<project_name> [-c <filename>] [-k <template_key>]
|
||||
simple-scaffold -g https://gitlab.com/<username>/<project_name>.git [-c <filename>] [-k <template_key>]
|
||||
# Full Git URL (GitLab, Bitbucket, etc.)
|
||||
npx simple-scaffold -g https://gitlab.com/user/repo.git -k component MyComponent
|
||||
```
|
||||
|
||||
When a config file path is omitted, the files given in the list above will be tried on the root
|
||||
directory of the git repository.
|
||||
When `--config` is omitted, the standard auto-detection order is used within the cloned repo. The
|
||||
repository is cloned to a temporary directory and cleaned up automatically.
|
||||
|
||||
**Note:** The repository will be cloned to a temporary directory and removed after the scaffolding
|
||||
has been done.
|
||||
|
||||
## Use In Node.js
|
||||
|
||||
You can also start a scaffold from Node.js with a remote file or URL config.
|
||||
|
||||
Just use the `Scaffold.fromConfig` function:
|
||||
### From Node.js
|
||||
|
||||
```ts
|
||||
Scaffold.fromConfig(
|
||||
"scaffold.config.js", // file or HTTPS git URL
|
||||
{
|
||||
// name of the generated component
|
||||
name: "My Component",
|
||||
// key to load from the config
|
||||
key: "component",
|
||||
},
|
||||
{
|
||||
// other config overrides
|
||||
},
|
||||
import Scaffold from "simple-scaffold"
|
||||
|
||||
const scaffold = await Scaffold.fromConfig(
|
||||
"https://github.com/user/repo.git", // or a local file path
|
||||
{ name: "MyComponent", key: "component" },
|
||||
)
|
||||
await scaffold.run()
|
||||
```
|
||||
|
||||
@@ -1,130 +1,162 @@
|
||||
---
|
||||
title: CLI Usage
|
||||
title: CLI
|
||||
---
|
||||
|
||||
## Available flags
|
||||
# CLI
|
||||
|
||||
```text
|
||||
Usage: simple-scaffold [options]
|
||||
```sh
|
||||
npx simple-scaffold [options] [name]
|
||||
```
|
||||
|
||||
To see this and more information anytime, add the `-h` or `--help` flag to your call, e.g.
|
||||
`npx simple-scaffold@latest -h`.
|
||||
Use `--help` (`-h`) to see all available options at any time.
|
||||
|
||||
Options:
|
||||
## Commands
|
||||
|
||||
| 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. |
|
||||
| Command | Description |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| _(default)_ | Generate files from a template |
|
||||
| `init` | Create a config file and example template |
|
||||
| `list` / `ls` | List available template keys in a config |
|
||||
|
||||
### Before Write option
|
||||
### `init`
|
||||
|
||||
This option allows you to preprocess a file before it is being written, such as running a formatter,
|
||||
linter or other commands.
|
||||
Scaffolds a config file and example template directory to get you started:
|
||||
|
||||
To use this option, pass it the command you would like to run. The following tokens will be replaced
|
||||
in your string:
|
||||
|
||||
- `{{path}}` - the temporary file path for you to read from
|
||||
- `{{rawpath}}` - a different file path containing the raw file contents **before** they were
|
||||
handled by Handlebars.js.
|
||||
|
||||
If none of these tokens are found, the regular (non-raw) path will be appended to the end of the
|
||||
command.
|
||||
|
||||
```shell
|
||||
simple-scaffold -c . --before-write prettier
|
||||
# command: prettier /tmp/somefile
|
||||
|
||||
simple-scaffold -c . --before-write 'cat {{path}} | my-linter'
|
||||
# command: cat /tmp/somefile | my-linter
|
||||
```sh
|
||||
npx simple-scaffold init
|
||||
npx simple-scaffold init --format mjs
|
||||
npx simple-scaffold init --dir packages/my-lib
|
||||
```
|
||||
|
||||
The command should return the string to write to the file through standard output (stdout), and not
|
||||
re-write the tmp file as it is not used for writing. Returning an empty string (after trimming) will
|
||||
discard the result and write the original file contents.
|
||||
| Option | Description |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| `--dir` / `-d` | Directory to create the config in (defaults to current directory) |
|
||||
| `--format` / `-f` | Config format: `js`, `mjs`, or `json` (prompts if omitted) |
|
||||
|
||||
See
|
||||
[beforeWrite](https://chenasraf.github.io/simple-scaffold/docs/api/interfaces/ScaffoldConfig#beforewrite)
|
||||
Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can
|
||||
output `''` for the same effect.
|
||||
Creates:
|
||||
|
||||
## Available Commands:
|
||||
- A config file (`scaffold.config.js` by default) with a `default` template key
|
||||
- A `templates/default/` directory with an example `{{name}}.md` template
|
||||
|
||||
| Command \| Alias | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------------------ |
|
||||
| `list` \| `ls` | List all available templates for a given config. See `list -h` for more information. |
|
||||
Existing files are never overwritten.
|
||||
|
||||
## Examples:
|
||||
### `list`
|
||||
|
||||
> See
|
||||
> [Configuration Files](https://chenasraf.github.io/simple-scaffold/docs/usage/configuration_files)
|
||||
> for organizing multiple scaffold types into easy-to-maintain files
|
||||
Lists all template keys defined in a config file:
|
||||
|
||||
Usage with config file
|
||||
|
||||
```shell
|
||||
$ simple-scaffold -c scaffold.cmd.js -k component MyComponent
|
||||
```sh
|
||||
npx simple-scaffold list
|
||||
npx simple-scaffold list -c path/to/config.js
|
||||
npx simple-scaffold list -g username/repo
|
||||
```
|
||||
|
||||
Usage with GitHub config file
|
||||
## Options
|
||||
|
||||
```shell
|
||||
$ simple-scaffold -g chenasraf/simple-scaffold -k component MyComponent
|
||||
| Flag | Short | Description | Default |
|
||||
| -------------------------------- | --------- | ----------------------------------------------------- | --------------- |
|
||||
| `--name` | `-n` | Name for generated files (can also be positional arg) | |
|
||||
| `--config` | `-c` | Path to config file or directory | _(auto-detect)_ |
|
||||
| `--git` | `-g` | Git URL or GitHub shorthand (`user/repo`) | |
|
||||
| `--key` | `-k` | Template key from config | `default` |
|
||||
| `--output` | `-o` | Output directory | |
|
||||
| `--templates` | `-t` | Template paths or glob patterns (repeatable) | |
|
||||
| `--data` | `-d` | Custom data as JSON string | |
|
||||
| `--append-data` | `-D` | Key-value data (`key=string`, `key:=raw`), repeatable | |
|
||||
| `--subdir` / `--no-subdir` | `-s`/`-S` | Create parent directory with the input name | `false` |
|
||||
| `--subdir-helper` | `-H` | Helper to transform subdir name | |
|
||||
| `--overwrite` / `--no-overwrite` | `-w`/`-W` | Overwrite existing files | `false` |
|
||||
| `--dry-run` | `-dr` | Preview output without writing files | `false` |
|
||||
| `--before-write` | `-B` | Command to run before each file is written | |
|
||||
| `--after-scaffold` | `-A` | Shell command to run after all files are written | |
|
||||
| `--quiet` | `-q` | Suppress output (same as `--log-level none`) | |
|
||||
| `--log-level` | `-l` | Log level: `none`, `debug`, `info`, `warn`, `error` | `info` |
|
||||
| `--version` | `-v` | Show version | |
|
||||
| `--help` | `-h` | Show help | |
|
||||
|
||||
## Interactive Mode
|
||||
|
||||
When running in a terminal (TTY), Simple Scaffold prompts for any missing required values:
|
||||
|
||||
- **Name** — if `--name` is not provided
|
||||
- **Template key** — if `--key` is not provided and the config has multiple templates
|
||||
- **Output directory** — if `--output` is not provided
|
||||
- **Template paths** — if `--templates` is not provided (comma-separated)
|
||||
|
||||
[Inputs](configuration_files#inputs) defined in config files are also prompted interactively.
|
||||
|
||||
In non-interactive environments (CI, piped input), missing required values cause an error.
|
||||
|
||||
## Hooks
|
||||
|
||||
### Before Write
|
||||
|
||||
Runs a command before each file is written. The command receives a temporary file path and should
|
||||
output the final content to stdout.
|
||||
|
||||
```sh
|
||||
# Appends file path automatically
|
||||
npx simple-scaffold -c . --before-write prettier
|
||||
|
||||
# Use tokens for explicit control
|
||||
npx simple-scaffold -c . --before-write 'cat {{path}} | my-linter'
|
||||
```
|
||||
|
||||
Usage with https git URL (for non-GitHub)
|
||||
**Tokens:**
|
||||
|
||||
```shell
|
||||
$ simple-scaffold \
|
||||
-g https://example.com/user/template.git \
|
||||
-c scaffold.cmd.js \
|
||||
-k component \
|
||||
MyComponent
|
||||
- `{{path}}` — temporary file path with Handlebars-processed contents
|
||||
- `{{rawpath}}` — temporary file path with raw (unprocessed) contents
|
||||
|
||||
If no tokens are found, `{{path}}` is appended automatically. Returning an empty string (after
|
||||
trimming) discards the result and writes the original contents.
|
||||
|
||||
### After Scaffold
|
||||
|
||||
Runs a shell command after all files are written. The command executes in the output directory:
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -c . --after-scaffold 'npm install'
|
||||
npx simple-scaffold -c . --after-scaffold 'git init && git add .'
|
||||
```
|
||||
|
||||
Full syntax with config path and template key (applicable to all above methods)
|
||||
See the [Node.js API](node#after-scaffold-hook) for the function-based equivalent.
|
||||
|
||||
```shell
|
||||
$ simple-scaffold -c scaffold.cmd.js -k component MyComponent
|
||||
## CLI Examples
|
||||
|
||||
```sh
|
||||
# Use auto-detected config, default key
|
||||
npx simple-scaffold MyProject
|
||||
|
||||
# Specify config and key
|
||||
npx simple-scaffold -c scaffold.config.js -k component MyComponent
|
||||
|
||||
# GitHub remote template
|
||||
npx simple-scaffold -g username/repo -k component MyComponent
|
||||
|
||||
# Full Git URL
|
||||
npx simple-scaffold -g https://gitlab.com/user/repo.git -k component MyComponent
|
||||
|
||||
# One-off (no config file)
|
||||
npx simple-scaffold -t templates/component -o src/components MyComponent
|
||||
|
||||
# With custom data
|
||||
npx simple-scaffold -k component -D author=John -D license:='"MIT"' MyComponent
|
||||
|
||||
# Dry run
|
||||
npx simple-scaffold -k component --dry-run MyComponent
|
||||
```
|
||||
|
||||
Excluded template key, assumes 'default' key
|
||||
|
||||
```shell
|
||||
$ simple-scaffold -c scaffold.cmd.js MyComponent
|
||||
```
|
||||
|
||||
Shortest syntax for GitHub, assumes file 'scaffold.cmd.js' and template key 'default'
|
||||
|
||||
```shell
|
||||
$ simple-scaffold -g chenasraf/simple-scaffold MyComponent
|
||||
```
|
||||
|
||||
You can also add this as a script in your `package.json`:
|
||||
### package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"scaffold-cfg": "npx simple-scaffold -c scaffold.cmd.js -k component",
|
||||
"scaffold-gh": "npx simple-scaffold -g chenasraf/simple-scaffold -k component",
|
||||
"scaffold": "npx simple-scaffold@latest -t scaffolds/component/**/* -o src/components -d '{\"myProp\": \"propName\", \"myVal\": 123}'"
|
||||
"scaffold-component": "npx simple-scaffold -c scaffold.cmd.js -k"
|
||||
"scaffold": "simple-scaffold -k component",
|
||||
"scaffold:page": "simple-scaffold -k page"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
npm run scaffold -- MyComponent
|
||||
npm run scaffold:page -- Dashboard
|
||||
```
|
||||
|
||||
@@ -1,74 +1,208 @@
|
||||
---
|
||||
title: Node.js Usage
|
||||
title: Node.js API
|
||||
---
|
||||
|
||||
## Overview
|
||||
# Node.js API
|
||||
|
||||
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.
|
||||
Use Simple Scaffold programmatically for more complex workflows, custom logic, or integration into
|
||||
build tools.
|
||||
|
||||
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).
|
||||
## Basic Usage
|
||||
|
||||
See the full
|
||||
[documentation](https://chenasraf.github.io/simple-scaffold/interfaces/ScaffoldConfig.html) for the
|
||||
configuration options and their behavior.
|
||||
```typescript
|
||||
import Scaffold from "simple-scaffold"
|
||||
|
||||
```ts
|
||||
const scaffold = new Scaffold({
|
||||
name: "MyComponent",
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
})
|
||||
await scaffold.run()
|
||||
```
|
||||
|
||||
## Loading from a Config File
|
||||
|
||||
```typescript
|
||||
import Scaffold from "simple-scaffold"
|
||||
|
||||
const scaffold = await Scaffold.fromConfig(
|
||||
"scaffold.config.js", // local path or HTTPS Git URL
|
||||
{ name: "MyComponent", key: "component" },
|
||||
{
|
||||
/* optional config overrides */
|
||||
},
|
||||
)
|
||||
await scaffold.run()
|
||||
```
|
||||
|
||||
## Config Interface
|
||||
|
||||
```typescript
|
||||
interface ScaffoldConfig {
|
||||
name: string
|
||||
templates: string[]
|
||||
output: FileResponse<string>
|
||||
subdir?: boolean
|
||||
data?: Record<string, any>
|
||||
subdirHelper?: string
|
||||
data?: Record<string, unknown>
|
||||
overwrite?: FileResponse<boolean>
|
||||
quiet?: boolean
|
||||
verbose?: LogLevel
|
||||
logLevel?: "none" | "debug" | "info" | "warn" | "error"
|
||||
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>
|
||||
afterScaffold?: AfterScaffoldHook
|
||||
}
|
||||
```
|
||||
|
||||
### Before Write option
|
||||
For the full API reference, see [ScaffoldConfig](../api/interfaces/ScaffoldConfig).
|
||||
|
||||
This option allows you to preprocess a file before it is being written, such as running a formatter,
|
||||
linter or other commands.
|
||||
### Options
|
||||
|
||||
To use this option, you can run any async/blocking command, and return a string as the final output
|
||||
to be used as the file contents.
|
||||
| Option | Type | Description |
|
||||
| --------------- | ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `name` | `string` | Name for generated files _(required)_ |
|
||||
| `templates` | `string[]` | Template paths or globs; prefix with `!` to exclude _(required)_ |
|
||||
| `output` | `string \| Function` | Output directory, or a function for per-file control |
|
||||
| `data` | `Record<string, unknown>` | Custom data available in templates |
|
||||
| `inputs` | `Record<string, ScaffoldInput>` | Interactive input definitions (see [Inputs](configuration_files#inputs)) |
|
||||
| `helpers` | `Record<string, Function>` | Custom Handlebars helpers |
|
||||
| `subdir` | `boolean` | Create a parent directory with the input name (default: `false`) |
|
||||
| `subdirHelper` | `string` | Helper to transform the subdir name (e.g. `"pascalCase"`) |
|
||||
| `overwrite` | `boolean \| Function` | Overwrite existing files (default: `false`); function for per-file logic |
|
||||
| `dryRun` | `boolean` | Preview without writing files (default: `false`) |
|
||||
| `logLevel` | `string` | Log verbosity (default: `"info"`) |
|
||||
| `beforeWrite` | `Function` | Async hook before each file is written |
|
||||
| `afterScaffold` | `Function \| string` | Hook after all files are written |
|
||||
|
||||
Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by
|
||||
Simple Scaffold.
|
||||
## Hooks
|
||||
|
||||
## Example
|
||||
### Before Write
|
||||
|
||||
This is an example of loading a complete scaffold via Node.js:
|
||||
Runs before each file is written. Return a `string` or `Buffer` to replace the file contents, or
|
||||
`undefined` to keep the default (Handlebars-processed) output.
|
||||
|
||||
```typescript
|
||||
await new Scaffold({
|
||||
name: "MyComponent",
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
beforeWrite: async (content, rawContent, outputPath) => {
|
||||
// Format the output, transform it, or return undefined to keep as-is
|
||||
return content.toString().trim()
|
||||
},
|
||||
}).run()
|
||||
```
|
||||
|
||||
### After Scaffold Hook
|
||||
|
||||
Runs after all files have been written. Pass a **function** for full control, or a **string** to run
|
||||
a shell command in the output directory.
|
||||
|
||||
**Function:**
|
||||
|
||||
```typescript
|
||||
await new Scaffold({
|
||||
name: "my-app",
|
||||
templates: ["templates/app"],
|
||||
output: ".",
|
||||
afterScaffold: async ({ config, files }) => {
|
||||
console.log(`Created ${files.length} files`)
|
||||
// e.g. run npm install, git init, open editor, etc.
|
||||
},
|
||||
}).run()
|
||||
```
|
||||
|
||||
**Shell command:**
|
||||
|
||||
```typescript
|
||||
await new Scaffold({
|
||||
name: "my-app",
|
||||
templates: ["templates/app"],
|
||||
output: "my-app",
|
||||
afterScaffold: "npm install && git init",
|
||||
}).run()
|
||||
```
|
||||
|
||||
The context object:
|
||||
|
||||
```typescript
|
||||
interface AfterScaffoldContext {
|
||||
config: ScaffoldConfig
|
||||
files: string[] // absolute paths of written files
|
||||
}
|
||||
```
|
||||
|
||||
In dry-run mode, the hook is still called but the `files` array will be empty.
|
||||
|
||||
## Inputs
|
||||
|
||||
Define interactive prompts that merge into template data:
|
||||
|
||||
```typescript
|
||||
await new Scaffold({
|
||||
name: "component",
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
inputs: {
|
||||
author: { message: "Author name", required: true },
|
||||
license: {
|
||||
type: "select",
|
||||
message: "License",
|
||||
options: ["MIT", "Apache-2.0", "GPL-3.0"],
|
||||
},
|
||||
private: { type: "confirm", message: "Private package?", default: false },
|
||||
port: { type: "number", message: "Dev server port", default: 3000 },
|
||||
},
|
||||
}).run()
|
||||
// In templates: {{ author }}, {{ license }}, {{ private }}, {{ port }}
|
||||
```
|
||||
|
||||
```typescript
|
||||
interface ScaffoldInput {
|
||||
type?: "text" | "select" | "confirm" | "number"
|
||||
message?: string
|
||||
required?: boolean
|
||||
default?: string | boolean | number
|
||||
options?: (string | { name: string; value: string })[] // for type: "select"
|
||||
}
|
||||
```
|
||||
|
||||
- **Required** inputs are prompted if not already in `data`
|
||||
- **Optional** inputs with a `default` are applied silently
|
||||
- Pre-providing values in `data` skips the prompt for that input
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import path from "path"
|
||||
import Scaffold from "simple-scaffold"
|
||||
|
||||
const config = {
|
||||
name: "component",
|
||||
await new Scaffold({
|
||||
name: "MyComponent",
|
||||
templates: [path.join(__dirname, "scaffolds", "component")],
|
||||
output: path.join(__dirname, "src", "components"),
|
||||
subdir: true,
|
||||
subdirHelper: "upperCase",
|
||||
subdirHelper: "pascalCase",
|
||||
data: {
|
||||
property: "value",
|
||||
},
|
||||
helpers: {
|
||||
twice: (text) => [text, text].join(" "),
|
||||
},
|
||||
// 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)
|
||||
inputs: {
|
||||
author: { message: "Author name", required: true },
|
||||
license: { message: "License", default: "MIT" },
|
||||
},
|
||||
beforeWrite: (content, rawContent, outputPath) => {
|
||||
return content.toString().toUpperCase()
|
||||
},
|
||||
afterScaffold: async ({ config, files }) => {
|
||||
console.log(`Created ${files.length} files in ${config.output}`)
|
||||
},
|
||||
}).run()
|
||||
```
|
||||
|
||||
@@ -2,140 +2,185 @@
|
||||
title: Examples
|
||||
---
|
||||
|
||||
## Example files
|
||||
# Examples
|
||||
|
||||
### Input
|
||||
## React Component
|
||||
|
||||
- Input file path:
|
||||
### Template
|
||||
|
||||
```text
|
||||
project → scaffold → {{Name}}.js → src → components
|
||||
```
|
||||
**File:** `templates/component/{{pascalCase name}}.tsx`
|
||||
|
||||
- Input file contents:
|
||||
```tsx
|
||||
/**
|
||||
* Author: {{ author }}
|
||||
* Date: {{ now "yyyy-MM-dd" }}
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Author: {{ author }}
|
||||
* Date: {{ now "yyyy-MM-dd" }}
|
||||
*/
|
||||
import React from 'react'
|
||||
export default {{ pascalCase name }}: React.FC = (props) => {
|
||||
return (
|
||||
<div className="{{ camelCase name }}">{{ pascalCase name }} Component</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
export default {{camelCase name}}: React.FC = (props) => {
|
||||
return (
|
||||
<div className="{{className}}">{{camelCase name}} Component</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
### Config
|
||||
|
||||
```js
|
||||
// scaffold.config.js
|
||||
module.exports = {
|
||||
component: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
data: {
|
||||
author: "My Name",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -k component MyComponent
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
- Output file path:
|
||||
**File:** `src/components/MyComponent.tsx`
|
||||
|
||||
- With `subdir = false` (default):
|
||||
```tsx
|
||||
/**
|
||||
* Author: My Name
|
||||
* Date: 2077-01-01
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
```text
|
||||
project → src → components → MyComponent.js
|
||||
```
|
||||
|
||||
- With `subdir = true`:
|
||||
|
||||
```text
|
||||
project → src → components → MyComponent → MyComponent.js
|
||||
```
|
||||
|
||||
- With `subdir = true` and `subdirHelper = 'upperCase'`:
|
||||
|
||||
```text
|
||||
project → src → components → MYCOMPONENT → MyComponent.js
|
||||
```
|
||||
|
||||
- Output file contents:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Author: My Name
|
||||
* Date: 2077-01-01
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
export default MyComponent: React.FC = (props) => {
|
||||
return (
|
||||
<div className="myClassName">MyComponent Component</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Example run commands
|
||||
|
||||
### Command Example
|
||||
|
||||
```bash
|
||||
simple-scaffold \
|
||||
-t project/scaffold/**/* \
|
||||
-o src/components \
|
||||
-d '{"className": "myClassName","author": "My Name"}'
|
||||
MyComponent
|
||||
export default MyComponent: React.FC = (props) => {
|
||||
return (
|
||||
<div className="myComponent">MyComponent Component</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Equivalent Node Module Example
|
||||
## Subdir Variations
|
||||
|
||||
Given the template and config above, the output path changes based on `subdir` settings:
|
||||
|
||||
| Setting | Output path |
|
||||
| ------------------------------------------- | -------------------------------------------- |
|
||||
| `subdir: false` (default) | `src/components/MyComponent.tsx` |
|
||||
| `subdir: true` | `src/components/MyComponent/MyComponent.tsx` |
|
||||
| `subdir: true`, `subdirHelper: "upperCase"` | `src/components/MYCOMPONENT/MyComponent.tsx` |
|
||||
|
||||
## CLI One-liner (No Config)
|
||||
|
||||
```sh
|
||||
npx simple-scaffold \
|
||||
-t templates/component/**/* \
|
||||
-o src/components \
|
||||
-d '{"author": "My Name"}' \
|
||||
MyComponent
|
||||
```
|
||||
|
||||
## Node.js Equivalent
|
||||
|
||||
```typescript
|
||||
import Scaffold from "simple-scaffold"
|
||||
|
||||
async function main() {
|
||||
await Scaffold({
|
||||
name: "MyComponent",
|
||||
templates: ["project/scaffold/**/*"],
|
||||
output: ["src/components"],
|
||||
data: {
|
||||
className: "myClassName",
|
||||
author: "My Name",
|
||||
},
|
||||
})
|
||||
console.log("Done.")
|
||||
await new Scaffold({
|
||||
name: "MyComponent",
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
data: {
|
||||
author: "My Name",
|
||||
},
|
||||
}).run()
|
||||
```
|
||||
|
||||
## Reusable Config Files
|
||||
|
||||
### CommonJS (`scaffold.config.js`)
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
default: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Re-usable config
|
||||
### ESM (`scaffold.config.mjs`)
|
||||
|
||||
#### Shell
|
||||
|
||||
```bash
|
||||
# cjs
|
||||
simple-scaffold -c scaffold.cjs MyComponent \
|
||||
-d '{"className": "myClassName","author": "My Name"}'
|
||||
# mjs
|
||||
simple-scaffold -c scaffold.mjs MyComponent \
|
||||
-d '{"className": "myClassName","author": "My Name"}'
|
||||
```js
|
||||
export default {
|
||||
default: {
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### scaffold.cjs
|
||||
### Dynamic Config (with function)
|
||||
|
||||
```js
|
||||
module.exports = (config) => ({
|
||||
default: {
|
||||
templates: ["project/scaffold/**/*"],
|
||||
output: ["src/components"],
|
||||
templates: ["templates/component"],
|
||||
output: "src/components",
|
||||
data: {
|
||||
className: "myClassName",
|
||||
author: "My Name",
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### scaffold.mjs
|
||||
## With Inputs
|
||||
|
||||
```js
|
||||
export default (config) => ({
|
||||
default: {
|
||||
templates: ["project/scaffold/**/*"],
|
||||
output: ["src/components"],
|
||||
data: {
|
||||
className: "myClassName",
|
||||
author: "My Name",
|
||||
// scaffold.config.js
|
||||
module.exports = {
|
||||
package: {
|
||||
templates: ["templates/package"],
|
||||
output: "packages",
|
||||
subdir: true,
|
||||
inputs: {
|
||||
description: { message: "Package description", required: true },
|
||||
author: { message: "Author", default: "Team" },
|
||||
license: {
|
||||
type: "select",
|
||||
message: "License",
|
||||
options: ["MIT", "Apache-2.0", "ISC"],
|
||||
},
|
||||
private: { type: "confirm", message: "Private package?", default: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
# Interactive — prompts for each input
|
||||
npx simple-scaffold -k package my-lib
|
||||
|
||||
# Non-interactive — provide all values upfront
|
||||
npx simple-scaffold -k package -D description="A utility library" -D author=John my-lib
|
||||
```
|
||||
|
||||
## With Hooks
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
app: {
|
||||
templates: ["templates/app"],
|
||||
output: ".",
|
||||
subdir: true,
|
||||
afterScaffold: "cd {{name}} && npm install && git init",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
npx simple-scaffold -k app my-app
|
||||
# Files are generated, then npm install and git init run automatically
|
||||
```
|
||||
|
||||
@@ -2,60 +2,63 @@
|
||||
title: Migration
|
||||
---
|
||||
|
||||
# Migration
|
||||
|
||||
## v1.x to v2.x
|
||||
|
||||
### CLI option changes
|
||||
### CLI Changes
|
||||
|
||||
- Several changes to how remote configs are loaded via CLI:
|
||||
- The `:template_key` syntax has been removed. You can still use `-k template_key` to achieve the
|
||||
same result.
|
||||
- The `--github` (`-gh`) flag has been replaced by a generic `--git` (`-g`) one, which handles any
|
||||
git URL. Providing a partial GitHub path will default to trying to find the project on GitHub,
|
||||
e.g. `-g username/project`
|
||||
- The `#template_file` syntax has been removed, you may use `--config` or `-c` to tell Simple
|
||||
Scaffold which file to look for inside the git project. There is a default file priority list
|
||||
which can find the file for you if it is in one of the supported filenames.
|
||||
- `verbose` can now take the names `debug`, `info`, `warn`, `error` or `none` (case insensitive).
|
||||
- `--create-sub-folder` (`-s`) has been renamed to `--subdir` (`-s`) in the CLI. The Node.js names
|
||||
have been changed as well.
|
||||
- `--sub-folder-name-helper` (`-sh`) has been renamed to `--subdir-helper` (`-sh`). The Node.js
|
||||
names have been changed as well.
|
||||
- All boolean flags no longer take a value. `-q` instead of `-q 1` or `-q true`, `-s` instead of
|
||||
`-s 1`, `-w` instead of `-w 1`, etc.
|
||||
**Remote config syntax:**
|
||||
|
||||
### Behavior changes
|
||||
- The `:template_key` suffix syntax has been removed. Use `-k template_key` instead.
|
||||
- `--github` (`-gh`) is now `--git` (`-g`) and supports any Git URL. GitHub shorthand still works:
|
||||
`-g username/project`.
|
||||
- The `#template_file` suffix syntax has been removed. Use `--config` (`-c`) to specify which file
|
||||
to look for inside the Git project.
|
||||
|
||||
- Data is no longer auto-populated with `Name` (PascalCase) by default. You can just use the helper
|
||||
in your templates contents and file names, simply use `{{ pascalCase name }}` instead of
|
||||
`{{ Name }}`. `Name` was arbitrary and it is confusing (is it `Title Case`? `PascalCase`? only
|
||||
reading the docs can tell). Alternatively, you can inject the transformed name into your `data`
|
||||
manually using a scaffold config file, by using the Node API or by appending the data to the CLI
|
||||
invocation.
|
||||
**Renamed flags:**
|
||||
|
||||
| v1.x | v2.x |
|
||||
| ---------------------------------- | -------------------------------------------------------- |
|
||||
| `--create-sub-folder` / `-s` | `--subdir` / `-s` |
|
||||
| `--sub-folder-name-helper` / `-sh` | `--subdir-helper` / `-H` |
|
||||
| `--verbose` (true/false) | `--log-level` (`debug`, `info`, `warn`, `error`, `none`) |
|
||||
|
||||
**Boolean flags** no longer take a value. Use `-q` instead of `-q true`, `-s` instead of `-s 1`,
|
||||
etc.
|
||||
|
||||
### Behavior Changes
|
||||
|
||||
**`{{ Name }}` removed.** The auto-populated `Name` (PascalCase) variable is gone. Use
|
||||
`{{ pascalCase name }}` in your templates instead. If you need the old behavior, inject it manually
|
||||
via `data`:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
default: {
|
||||
templates: ["templates/default"],
|
||||
output: "src",
|
||||
data: { Name: "{{ pascalCase name }}" }, // or set it programmatically
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## v0.x to v1.x
|
||||
|
||||
In Simple Scaffold v1.0, the entire codebase was overhauled, yet usage remains mostly the same
|
||||
between versions. With these notable exceptions:
|
||||
In v1.0, the codebase was overhauled but usage remained mostly the same.
|
||||
|
||||
- Some of the argument names have changed
|
||||
- Template syntax has been improved
|
||||
- The command to run Scaffold has been simplified from `new SimpleScaffold(opts).run()` to
|
||||
`SimpleScaffold(opts)`, which now returns a promise that you can await to know when the process
|
||||
has been completed.
|
||||
### API Changes
|
||||
|
||||
### Argument changes
|
||||
| v0.x | v1.x |
|
||||
| -------------------------------- | ------------------------------------------ |
|
||||
| `new SimpleScaffold(opts).run()` | `SimpleScaffold(opts)` (returns a Promise) |
|
||||
| `locals` option | `data` option |
|
||||
| `--locals` / `-l` flag | `--data` / `-d` flag |
|
||||
|
||||
- `locals` has been renamed to `data`. The appropriate command line args have been updated as well
|
||||
to `--data` | `-d`.
|
||||
- Additional options have been added to both CLI and Node interfaces. See
|
||||
[Command Line Interface (CLI) usage](https://chenasraf.github.io/simple-scaffold/docs/usage/cli)
|
||||
and [Node.js usage](https://chenasraf.github.io/simple-scaffold/docs/usage/node) for more
|
||||
information.
|
||||
### Template Syntax
|
||||
|
||||
### Template syntax changes
|
||||
Templates still use Handlebars.js. v1.x added **built-in helpers** (case transformations, date
|
||||
formatting), removing the need to pre-process template data for common operations like `camelCase`,
|
||||
`snakeCase`, etc.
|
||||
|
||||
Simple Scaffold still uses Handlebars.js to handle template content and file names. However, helpers
|
||||
have been added to remove the need for you to pre-process the template data on simple use-cases such
|
||||
as case type manipulation (converting to camel case, snake case, etc)
|
||||
|
||||
See the readme for the full information on how to use these helpers and which are available.
|
||||
See [Templates](templates#built-in-helpers) for the full list of available helpers.
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
---
|
||||
title: Usage
|
||||
sidebar_position: 0
|
||||
---
|
||||
|
||||
- [CLI Usage](cli)
|
||||
- [Configuration Files](configuration_files)
|
||||
- [Examples](examples)
|
||||
- [Migration](migration)
|
||||
- [Node.js Usage](node)
|
||||
- [Template Files](templates)
|
||||
# Usage
|
||||
|
||||
Simple Scaffold can be used as a **CLI tool** or as a **Node.js library**. Both approaches share the
|
||||
same core concepts: templates, configuration files, and Handlebars-based token replacement.
|
||||
|
||||
- [Templates](./usage/templates) — how template files and directories work, built-in helpers, and
|
||||
custom helpers
|
||||
- [Configuration Files](./usage/configuration_files) — reusable scaffold definitions,
|
||||
auto-detection, inputs, and remote Git templates
|
||||
- [CLI](./usage/cli) — all commands, flags, interactive mode, and hooks
|
||||
- [Node.js API](./usage/node) — programmatic usage, full config interface, and hooks
|
||||
- [Examples](./usage/examples) — end-to-end examples for CLI and Node.js
|
||||
- [Migration](./usage/migration) — upgrading from v1.x or v0.x to the latest version
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/plugin-google-tag-manager": "^3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"@docusaurus/core": "^3.9.2",
|
||||
"@docusaurus/plugin-google-tag-manager": "^3.9.2",
|
||||
"@docusaurus/preset-classic": "^3.9.2",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1",
|
||||
"@docusaurus/tsconfig": "3.1.1",
|
||||
"@docusaurus/types": "3.1.1",
|
||||
"docusaurus-plugin-typedoc": "^0.22.0",
|
||||
"typedoc": "^0.25.7",
|
||||
"typedoc-plugin-markdown": "^3.17.1",
|
||||
"typescript": "~5.2.2"
|
||||
"@docusaurus/module-type-aliases": "^3.9.2",
|
||||
"@docusaurus/tsconfig": "^3.9.2",
|
||||
"@docusaurus/types": "^3.9.2",
|
||||
"docusaurus-plugin-typedoc": "^1.4.2",
|
||||
"typedoc": "^0.28.18",
|
||||
"typedoc-plugin-markdown": "^4.11.0",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
10235
docs/pnpm-lock.yaml
generated
10235
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ export default [
|
||||
...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended),
|
||||
{
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
|
||||
202
jest.config.ts
202
jest.config.ts
@@ -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,
|
||||
}
|
||||
66
package.json
66
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simple-scaffold",
|
||||
"version": "2.3.2",
|
||||
"version": "3.1.1",
|
||||
"description": "Generate any file structure - from single components to entire app boilerplates, with a single command.",
|
||||
"homepage": "https://chenasraf.github.io/simple-scaffold",
|
||||
"repository": {
|
||||
@@ -27,34 +27,56 @@
|
||||
],
|
||||
"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"
|
||||
"ci": "pnpm install --frozen-lockfile",
|
||||
"lint": "eslint src/ tests/",
|
||||
"format": "prettier --write .",
|
||||
"lint-staged": "lint-staged",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,mts,js,mjs}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,yml,yaml,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/confirm": "^6.0.10",
|
||||
"@inquirer/input": "^5.0.10",
|
||||
"@inquirer/number": "^4.0.10",
|
||||
"@inquirer/select": "^5.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"glob": "^11.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"massarg": "2.0.1"
|
||||
"glob": "^13.0.6",
|
||||
"handlebars": "^4.7.9",
|
||||
"massarg": "2.1.1",
|
||||
"minimatch": "^10.2.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/node": "^22.8.1",
|
||||
"jest": "^29.7.0",
|
||||
"mock-fs": "^5.4.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.11.0"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"prettier": "^3.8.1",
|
||||
"rimraf": "^6.1.3",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite-node": "^6.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
4182
pnpm-lock.yaml
generated
4182
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// @ts-check
|
||||
/** @type {import('./dist').ScaffoldConfigFile} */
|
||||
module.exports = (conf) => {
|
||||
console.log("Config:", conf)
|
||||
module.exports = () => {
|
||||
// console.log("Config:", conf)
|
||||
return {
|
||||
default: {
|
||||
templates: ["examples/test-input/Component"],
|
||||
86
src/before-write.ts
Normal file
86
src/before-write.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import { exec } from "node:child_process"
|
||||
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
|
||||
import { log } from "./logger"
|
||||
import { createDirIfNotExists, getUniqueTmpPath } from "./fs-utils"
|
||||
|
||||
/**
|
||||
* Wraps a CLI beforeWrite command string into a beforeWrite callback function.
|
||||
* The command receives the processed content via a temp file and can return modified content via stdout.
|
||||
* @internal
|
||||
*/
|
||||
export function wrapBeforeWrite(
|
||||
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
|
||||
beforeWrite: string,
|
||||
): ScaffoldConfig["beforeWrite"] {
|
||||
return async (content, rawContent, outputFile) => {
|
||||
const tmpDir = path.join(getUniqueTmpPath(), path.basename(outputFile))
|
||||
await createDirIfNotExists(path.dirname(tmpDir), config)
|
||||
const ext = path.extname(outputFile)
|
||||
const rawTmpPath = tmpDir.replace(ext, ".raw" + ext)
|
||||
try {
|
||||
log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite)
|
||||
const cmd = await prepareBeforeWriteCmd({
|
||||
beforeWrite,
|
||||
tmpDir,
|
||||
content,
|
||||
rawTmpPath,
|
||||
rawContent,
|
||||
})
|
||||
const result = await new Promise<string | undefined>((resolve, reject) => {
|
||||
log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd)
|
||||
const proc = exec(cmd)
|
||||
proc.stdout!.on("data", (data) => {
|
||||
if (data.trim()) {
|
||||
resolve(data.toString())
|
||||
} else {
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
proc.stderr!.on("data", (data) => {
|
||||
reject(data.toString())
|
||||
})
|
||||
})
|
||||
return result
|
||||
} catch (e) {
|
||||
log(config, LogLevel.debug, e)
|
||||
log(config, LogLevel.warning, "Error running beforeWrite command, returning original content")
|
||||
return undefined
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { force: true })
|
||||
await fs.rm(rawTmpPath, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareBeforeWriteCmd({
|
||||
beforeWrite,
|
||||
tmpDir,
|
||||
content,
|
||||
rawTmpPath,
|
||||
rawContent,
|
||||
}: {
|
||||
beforeWrite: string
|
||||
tmpDir: string
|
||||
content: Buffer
|
||||
rawTmpPath: string
|
||||
rawContent: Buffer
|
||||
}): Promise<string> {
|
||||
let cmd: string = ""
|
||||
const pathReg = /\{\{\s*path\s*\}\}/gi
|
||||
const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi
|
||||
if (pathReg.test(beforeWrite)) {
|
||||
await fs.writeFile(tmpDir, content)
|
||||
cmd = beforeWrite.replaceAll(pathReg, tmpDir)
|
||||
}
|
||||
if (rawPathReg.test(beforeWrite)) {
|
||||
await fs.writeFile(rawTmpPath, rawContent)
|
||||
cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath)
|
||||
}
|
||||
if (!cmd) {
|
||||
await fs.writeFile(tmpDir, content)
|
||||
cmd = [beforeWrite, tmpDir].join(" ")
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
137
src/cmd.ts
137
src/cmd.ts
@@ -3,23 +3,24 @@
|
||||
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 { promptBeforeConfig, promptAfterConfig, resolveInputs } from "./prompts"
|
||||
import { initScaffold } from "./init"
|
||||
|
||||
export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false))
|
||||
const pkgFile = await fs.readFile(path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json"))
|
||||
const isProjectRoot = Boolean(
|
||||
await fs.stat(path.join(__dirname, "package.json")).catch(() => false),
|
||||
)
|
||||
const pkgFile = await fs.readFile(
|
||||
path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json"),
|
||||
)
|
||||
const pkg = JSON.parse(pkgFile.toString())
|
||||
const isVersionFlag = args.includes("--version") || args.includes("-v")
|
||||
const isConfigFileProvided = args.includes("--config") || args.includes("-c")
|
||||
const isGitProvided = args.includes("--git") || args.includes("-g")
|
||||
const isConfigProvided = isConfigFileProvided || isGitProvided || isVersionFlag
|
||||
|
||||
return massarg<ScaffoldCmdConfig>({
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
@@ -29,18 +30,46 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
console.log(pkg.version)
|
||||
return
|
||||
}
|
||||
log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`)
|
||||
log(config, LogLevel.debug, `Simple Scaffold v${pkg.version}`)
|
||||
config.tmpDir = generateUniqueTmpPath()
|
||||
try {
|
||||
// Auto-detect config file in cwd — but only if the user didn't
|
||||
// explicitly provide templates/output (which signals a one-time run)
|
||||
const isOneTimeRun = config.templates?.length > 0 || config.output
|
||||
if (!config.config && !config.git && !isOneTimeRun) {
|
||||
try {
|
||||
config.config = await findConfigFile(process.cwd())
|
||||
log(config, LogLevel.debug, `Auto-detected config file: ${config.config}`)
|
||||
} catch {
|
||||
// No config file found — that's fine, continue without one
|
||||
}
|
||||
}
|
||||
|
||||
// Load config map early so we can prompt for name and template key
|
||||
const hasConfigSource = Boolean(config.config || config.git)
|
||||
let configMap: ScaffoldConfigMap | undefined
|
||||
if (hasConfigSource) {
|
||||
configMap = await getConfigFile(config)
|
||||
}
|
||||
|
||||
// Phase 1: prompt for name + key (needed before parseConfigFile)
|
||||
config = await promptBeforeConfig(config, configMap)
|
||||
|
||||
// Parse and merge the config file
|
||||
log(config, LogLevel.debug, "Parsing config file...", config)
|
||||
const parsed = await parseConfigFile(config)
|
||||
await Scaffold(parsed)
|
||||
|
||||
// Phase 2: prompt for anything still missing after config merge
|
||||
const prompted = await promptAfterConfig(parsed)
|
||||
|
||||
const resolved = await resolveInputs(prompted)
|
||||
await Scaffold(resolved)
|
||||
} catch (e) {
|
||||
const message = "message" in (e as 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...", config.tmpDir)
|
||||
await fs.rm(config.tmpDir, { recursive: true, force: true })
|
||||
if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
.option({
|
||||
@@ -49,9 +78,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 +96,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 +113,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
description:
|
||||
"Template files to use as input. You may provide multiple files, each of which can be a relative or " +
|
||||
"absolute path, " +
|
||||
"or a glob pattern for multiple file matching easily.",
|
||||
required: !isConfigProvided,
|
||||
"or a glob pattern for multiple file matching easily. If omitted in an interactive terminal, " +
|
||||
"you will be prompted for a comma-separated list.",
|
||||
})
|
||||
.flag({
|
||||
name: "overwrite",
|
||||
@@ -139,7 +167,9 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
parse: (v) => {
|
||||
const val = v.toLowerCase()
|
||||
if (!(val in LogLevel)) {
|
||||
throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`)
|
||||
throw new Error(
|
||||
`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`,
|
||||
)
|
||||
}
|
||||
return val
|
||||
},
|
||||
@@ -152,6 +182,14 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
" file. A temporary file path will be passed to the given command and the command should " +
|
||||
"return a string for the final output.",
|
||||
})
|
||||
.option({
|
||||
name: "after-scaffold",
|
||||
aliases: ["A"],
|
||||
description:
|
||||
"Run a shell command after all files have been written. " +
|
||||
"The command is executed in the output directory. " +
|
||||
"For example: `--after-scaffold 'npm install'`",
|
||||
})
|
||||
.flag({
|
||||
name: "dry-run",
|
||||
aliases: ["dr"],
|
||||
@@ -169,7 +207,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
new MassargCommand<ListCommandCliOptions>({
|
||||
name: "list",
|
||||
aliases: ["ls"],
|
||||
description: "List all available templates for a given config. See `list -h` for more information.",
|
||||
description:
|
||||
"List all available templates for a given config. See `list -h` for more information.",
|
||||
run: async (_config) => {
|
||||
const config = {
|
||||
templates: [],
|
||||
@@ -188,18 +227,19 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
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...", config.tmpDir)
|
||||
await fs.rm(config.tmpDir, { recursive: true, force: true })
|
||||
if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
.option({
|
||||
name: "config",
|
||||
aliases: ["c"],
|
||||
description: "Filename or directory to load config from. Defaults to current working directory.",
|
||||
description:
|
||||
"Filename or directory to load config from. Defaults to current working directory.",
|
||||
})
|
||||
.option({
|
||||
name: "git",
|
||||
@@ -217,7 +257,9 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
parse: (v) => {
|
||||
const val = v.toLowerCase()
|
||||
if (!(val in LogLevel)) {
|
||||
throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`)
|
||||
throw new Error(
|
||||
`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`,
|
||||
)
|
||||
}
|
||||
return val
|
||||
},
|
||||
@@ -226,6 +268,38 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
bindOption: true,
|
||||
}),
|
||||
)
|
||||
.command(
|
||||
new MassargCommand<{ dir?: string; format?: string }>({
|
||||
name: "init",
|
||||
aliases: [],
|
||||
description:
|
||||
"Initialize a new scaffold config file and example template in the current directory.",
|
||||
run: async (config) => {
|
||||
try {
|
||||
await initScaffold({
|
||||
dir: config.dir,
|
||||
format: config.format as "js" | "mjs" | "json" | undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
|
||||
console.error(colorize.red(message ?? "Unknown error"))
|
||||
}
|
||||
},
|
||||
})
|
||||
.option({
|
||||
name: "dir",
|
||||
aliases: ["d"],
|
||||
description: "Directory to create the config in. Defaults to current working directory.",
|
||||
})
|
||||
.option({
|
||||
name: "format",
|
||||
aliases: ["f"],
|
||||
description: "Config file format: js, mjs, or json. If omitted, you will be prompted.",
|
||||
})
|
||||
.help({
|
||||
bindOption: true,
|
||||
}),
|
||||
)
|
||||
.example({
|
||||
description: "Usage with config file",
|
||||
input: "simple-scaffold -c scaffold.cmd.js --key component",
|
||||
@@ -236,7 +310,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
})
|
||||
.example({
|
||||
description: "Usage with https git URL (for non-GitHub)",
|
||||
input: "simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component",
|
||||
input:
|
||||
"simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component",
|
||||
})
|
||||
.example({
|
||||
description: "Excluded template key, assumes 'default' key",
|
||||
@@ -251,7 +326,11 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
||||
bindOption: true,
|
||||
lineLength: 100,
|
||||
useGlobalTableColumns: true,
|
||||
usageText: [colorize.yellow`simple-scaffold`, colorize.gray`[options]`, colorize.cyan`<name>`].join(" "),
|
||||
usageText: [
|
||||
colorize.yellow`simple-scaffold`,
|
||||
colorize.gray`[options]`,
|
||||
colorize.cyan`<name>`,
|
||||
].join(" "),
|
||||
optionOptions: {
|
||||
displayNegations: true,
|
||||
},
|
||||
|
||||
74
src/colors.ts
Normal file
74
src/colors.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** ANSI color code mapping for terminal output. */
|
||||
const colorMap = {
|
||||
reset: 0,
|
||||
dim: 2,
|
||||
bold: 1,
|
||||
italic: 3,
|
||||
underline: 4,
|
||||
red: 31,
|
||||
green: 32,
|
||||
yellow: 33,
|
||||
blue: 34,
|
||||
magenta: 35,
|
||||
cyan: 36,
|
||||
white: 37,
|
||||
gray: 90,
|
||||
} as const
|
||||
|
||||
/** Available terminal color names. */
|
||||
export type TermColor = keyof typeof colorMap
|
||||
|
||||
function _colorize(text: string, color: TermColor): string {
|
||||
const c = colorMap[color]!
|
||||
let r: number
|
||||
|
||||
if (c > 1 && c < 30) {
|
||||
r = c + 20
|
||||
} else if (c === 1) {
|
||||
r = 23
|
||||
} else {
|
||||
r = 0
|
||||
}
|
||||
|
||||
return `\x1b[${c}m${text}\x1b[${r}m`
|
||||
}
|
||||
|
||||
function isTemplateStringArray(
|
||||
template: TemplateStringsArray | unknown,
|
||||
): template is TemplateStringsArray {
|
||||
return Array.isArray(template) && typeof template[0] === "string"
|
||||
}
|
||||
|
||||
const createColorize =
|
||||
(color: TermColor) =>
|
||||
(template: TemplateStringsArray | unknown, ...params: unknown[]): string => {
|
||||
return isTemplateStringArray(template)
|
||||
? _colorize(
|
||||
(template as TemplateStringsArray).reduce(
|
||||
(acc, str, i) => acc + str + (params[i] ?? ""),
|
||||
"",
|
||||
),
|
||||
color,
|
||||
)
|
||||
: _colorize(String(template), color)
|
||||
}
|
||||
|
||||
type TemplateStringsFn = ReturnType<typeof createColorize> & ((text: string) => string)
|
||||
type TemplateStringsFns = { [key in TermColor]: TemplateStringsFn }
|
||||
|
||||
/**
|
||||
* Colorize text for terminal output.
|
||||
*
|
||||
* Can be used as a function: `colorize("text", "red")`
|
||||
* Or via named helpers: `colorize.red("text")` / `colorize.red\`template\``
|
||||
*/
|
||||
export const colorize: typeof _colorize & TemplateStringsFns = Object.assign(
|
||||
_colorize,
|
||||
Object.entries(colorMap).reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key as TermColor] = createColorize(key as TermColor)
|
||||
return acc
|
||||
},
|
||||
{} as Record<TermColor, TemplateStringsFn>,
|
||||
),
|
||||
)
|
||||
155
src/config.ts
155
src/config.ts
@@ -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 } 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, { asPath: true }).toString()),
|
||||
path.basename(handlebarsParse(config, filePath, { asPath: 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
|
||||
if (value.includes(":=") && !val.includes(":=")) {
|
||||
return { ...data, [key]: JSON.parse(val) }
|
||||
}
|
||||
@@ -48,13 +29,16 @@ export function parseAppendData(value: string, options: ScaffoldCmdConfig): unkn
|
||||
}
|
||||
|
||||
function isWrappedWithQuotes(string: string): boolean {
|
||||
return (string.startsWith('"') && string.endsWith('"')) || (string.startsWith("'") && string.endsWith("'"))
|
||||
return (
|
||||
(string.startsWith('"') && string.endsWith('"')) ||
|
||||
(string.startsWith("'") && string.endsWith("'"))
|
||||
)
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
/** 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}`)
|
||||
log(config, LogLevel.debug, `Loading config from GitHub ${config.git}`)
|
||||
config.git = githubPartToUrl(config.git)
|
||||
}
|
||||
|
||||
@@ -62,16 +46,19 @@ export async function getConfigFile(config: ScaffoldCmdConfig): Promise<Scaffold
|
||||
const configFilename = config.config
|
||||
const configPath = isGit ? config.git : configFilename
|
||||
|
||||
log(config, LogLevel.info, `Loading config from file ${configFilename}`)
|
||||
log(config, LogLevel.debug, `Loading config from file ${configFilename}`)
|
||||
|
||||
const configPromise = await (isGit
|
||||
? getRemoteConfig({ git: configPath, config: configFilename, logLevel: config.logLevel, tmpDir: config.tmpDir! })
|
||||
? 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,7 +66,10 @@ export async function getConfigFile(config: ScaffoldCmdConfig): Promise<Scaffold
|
||||
return configImport
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
/**
|
||||
* 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,
|
||||
@@ -115,6 +105,8 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
|
||||
...output,
|
||||
...imported,
|
||||
beforeWrite: undefined,
|
||||
templates: config.templates || imported.templates,
|
||||
output: config.output || imported.output,
|
||||
data: {
|
||||
...imported.data,
|
||||
...config.data,
|
||||
@@ -123,8 +115,13 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
|
||||
}
|
||||
|
||||
output.data = { ...output.data, ...config.appendData }
|
||||
const cmdBeforeWrite = config.beforeWrite ? wrapBeforeWrite(config, config.beforeWrite) : undefined
|
||||
const cmdBeforeWrite = config.beforeWrite
|
||||
? wrapBeforeWrite(config, config.beforeWrite)
|
||||
: undefined
|
||||
output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite
|
||||
if (config.afterScaffold) {
|
||||
output.afterScaffold = config.afterScaffold
|
||||
}
|
||||
|
||||
if (!output.name) {
|
||||
throw new Error("simple-scaffold: Missing required option: name")
|
||||
@@ -134,7 +131,7 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
|
||||
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")) {
|
||||
@@ -143,8 +140,10 @@ export function githubPartToUrl(part: string): string {
|
||||
return gitUrl.toString()
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export async function getLocalConfig(config: ConfigLoadConfig & Partial<LogConfig>): Promise<ScaffoldConfigFile> {
|
||||
/** Loads a scaffold config from a local file or directory. @internal */
|
||||
export async function getLocalConfig(
|
||||
config: ConfigLoadConfig & Partial<LogConfig>,
|
||||
): Promise<ScaffoldConfigFile> {
|
||||
const { config: configFile, ...logConfig } = config as Required<typeof config>
|
||||
|
||||
const absolutePath = path.resolve(process.cwd(), configFile)
|
||||
@@ -158,21 +157,25 @@ export async function getLocalConfig(config: ConfigLoadConfig & Partial<LogConfi
|
||||
if (!exists) {
|
||||
throw new Error(`Could not find config file in directory ${absolutePath}`)
|
||||
}
|
||||
log(logConfig, LogLevel.info, `Loading config from: ${path.resolve(absolutePath, file)}`)
|
||||
log(logConfig, LogLevel.debug, `Loading config from: ${path.resolve(absolutePath, file)}`)
|
||||
return wrapNoopResolver(import(path.resolve(absolutePath, file)))
|
||||
}
|
||||
|
||||
log(logConfig, LogLevel.info, `Loading config from: ${absolutePath}`)
|
||||
log(logConfig, LogLevel.debug, `Loading config from: ${absolutePath}`)
|
||||
return wrapNoopResolver(import(absolutePath))
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
/** Loads a scaffold config from a remote git repository. @internal */
|
||||
export async function getRemoteConfig(
|
||||
config: RemoteConfigLoadConfig & Partial<LogConfig>,
|
||||
): Promise<ScaffoldConfigFile> {
|
||||
const { config: configFile, git, tmpDir, ...logConfig } = config as Required<typeof config>
|
||||
|
||||
log(logConfig, LogLevel.info, `Loading config from remote ${git}, file ${configFile}`)
|
||||
log(
|
||||
logConfig,
|
||||
LogLevel.debug,
|
||||
`Loading config from remote ${git}, config file ${configFile || "<auto-detect>"}`,
|
||||
)
|
||||
|
||||
const url = new URL(git!)
|
||||
const isHttp = url.protocol === "http:" || url.protocol === "https:"
|
||||
@@ -185,11 +188,12 @@ export async function getRemoteConfig(
|
||||
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) {
|
||||
@@ -200,72 +204,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 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
|
||||
}
|
||||
|
||||
231
src/file.ts
231
src/file.ts
@@ -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,34 @@ 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)
|
||||
|
||||
export async function getTemplateGlobInfo(config: ScaffoldConfig, template: string): Promise<GlobInfo> {
|
||||
const isGlob = hasMagic(template)
|
||||
log(config, LogLevel.debug, "before isDir", "isGlob:", isGlob, template)
|
||||
let _template = template
|
||||
let nonGlobTemplate = isGlob ? removeGlob(template) : template
|
||||
nonGlobTemplate = path.normalize(nonGlobTemplate)
|
||||
const isDirOrGlob = isGlob ? true : await isDir(template)
|
||||
const _shouldAddGlob = !isGlob && isDirOrGlob
|
||||
log(config, LogLevel.debug, "after", { isDirOrGlob, _shouldAddGlob })
|
||||
const origTemplate = template
|
||||
if (_shouldAddGlob) {
|
||||
_template = path.join(template, "**", "*")
|
||||
let resolvedTemplate = template
|
||||
let baseTemplatePath = _isGlob ? removeGlob(template) : template
|
||||
baseTemplatePath = path.normalize(baseTemplatePath)
|
||||
const isDirOrGlob = _isGlob ? true : await isDir(template)
|
||||
const shouldAddGlob = !_isGlob && isDirOrGlob
|
||||
log(config, LogLevel.debug, "after", { isDirOrGlob, shouldAddGlob })
|
||||
|
||||
if (shouldAddGlob) {
|
||||
resolvedTemplate = path.join(template, "**", "*")
|
||||
}
|
||||
return {
|
||||
baseTemplatePath,
|
||||
origTemplate: template,
|
||||
isDirOrGlob,
|
||||
isGlob: _isGlob,
|
||||
template: resolvedTemplate,
|
||||
}
|
||||
return { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template: _template }
|
||||
}
|
||||
|
||||
/** Complete information about a template file's output destination. */
|
||||
export interface OutputFileInfo {
|
||||
inputPath: string
|
||||
outputPathOpt: string
|
||||
@@ -113,6 +96,7 @@ 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 },
|
||||
@@ -126,6 +110,10 @@ export async function getTemplateFileInfo(
|
||||
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,
|
||||
{
|
||||
@@ -142,27 +130,32 @@ export async function copyFileTransformed(
|
||||
): Promise<void> {
|
||||
if (!exists || overwrite) {
|
||||
if (exists && overwrite) {
|
||||
log(config, LogLevel.info, `File ${outputPath} exists, overwriting`)
|
||||
log(config, LogLevel.debug, `Overwriting ${outputPath}`)
|
||||
}
|
||||
log(config, LogLevel.debug, `Processing file ${inputPath}`)
|
||||
const templateBuffer = await readFile(inputPath)
|
||||
const unprocessedOutputContents = handlebarsParse(config, templateBuffer)
|
||||
const finalOutputContents =
|
||||
(await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ?? unprocessedOutputContents
|
||||
(await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ??
|
||||
unprocessedOutputContents
|
||||
|
||||
if (!config.dryRun) {
|
||||
await writeFile(outputPath, finalOutputContents)
|
||||
} else {
|
||||
log(config, LogLevel.info, "Dry Run. Output should be:")
|
||||
log(config, LogLevel.info, finalOutputContents.toString())
|
||||
log(config, LogLevel.debug, "Dry run — output would be:")
|
||||
log(config, LogLevel.debug, finalOutputContents.toString())
|
||||
}
|
||||
} else if (exists) {
|
||||
log(config, LogLevel.info, `File ${outputPath} already exists, skipping`)
|
||||
log(config, LogLevel.debug, `Skipped ${outputPath} (already exists)`)
|
||||
}
|
||||
log(config, LogLevel.info, "Done.")
|
||||
}
|
||||
|
||||
export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string {
|
||||
/** Computes the output directory for a file, combining the output path, base path, and optional subdir. */
|
||||
export function getOutputDir(
|
||||
config: ScaffoldConfig,
|
||||
outputPathOpt: string,
|
||||
basePath: string,
|
||||
): string {
|
||||
return path.resolve(
|
||||
process.cwd(),
|
||||
...([
|
||||
@@ -177,43 +170,45 @@ export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, base
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single template file: resolves output paths, creates directories,
|
||||
* and writes the transformed output.
|
||||
* Returns the output path if the file was written, or null if skipped.
|
||||
*/
|
||||
export async function handleTemplateFile(
|
||||
config: ScaffoldConfig,
|
||||
{ templatePath, basePath }: { templatePath: string; basePath: string },
|
||||
): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(config, {
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(
|
||||
config,
|
||||
{
|
||||
templatePath,
|
||||
basePath,
|
||||
})
|
||||
const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false)
|
||||
},
|
||||
)
|
||||
const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false)
|
||||
|
||||
log(
|
||||
config,
|
||||
LogLevel.debug,
|
||||
`\nParsing ${templatePath}`,
|
||||
`\nBase path: ${basePath}`,
|
||||
`\nFull input path: ${inputPath}`,
|
||||
`\nOutput Path Opt: ${outputPathOpt}`,
|
||||
`\nFull output dir: ${outputDir}`,
|
||||
`\nFull output path: ${outputPath}`,
|
||||
`\n`,
|
||||
)
|
||||
log(
|
||||
config,
|
||||
LogLevel.debug,
|
||||
`\nParsing ${templatePath}`,
|
||||
`\nBase path: ${basePath}`,
|
||||
`\nFull input path: ${inputPath}`,
|
||||
`\nOutput Path Opt: ${outputPathOpt}`,
|
||||
`\nFull output dir: ${outputDir}`,
|
||||
`\nFull output path: ${outputPath}`,
|
||||
`\n`,
|
||||
)
|
||||
|
||||
await createDirIfNotExists(path.dirname(outputPath), config)
|
||||
await createDirIfNotExists(path.dirname(outputPath), config)
|
||||
|
||||
log(config, LogLevel.info, `Writing to ${outputPath}`)
|
||||
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
|
||||
resolve()
|
||||
} catch (e: any) {
|
||||
handleErr(e)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function getUniqueTmpPath(): string {
|
||||
return path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
const shouldWrite = (!exists || overwrite) && !config.dryRun
|
||||
log(config, LogLevel.debug, `Writing to ${outputPath}`)
|
||||
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
|
||||
return shouldWrite ? outputPath : null
|
||||
} catch (e: unknown) {
|
||||
handleErr(e as NodeJS.ErrnoException)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
64
src/fs-utils.ts
Normal file
64
src/fs-utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import { F_OK } from "node:constants"
|
||||
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
|
||||
import { log } from "./logger"
|
||||
|
||||
const { stat, access, mkdir } = fs
|
||||
|
||||
/** Recursively creates a directory and its parents if they don't exist. */
|
||||
export async function createDirIfNotExists(
|
||||
dir: string,
|
||||
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
|
||||
): Promise<void> {
|
||||
if (config.dryRun) {
|
||||
log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`)
|
||||
return
|
||||
}
|
||||
const parentDir = path.dirname(dir)
|
||||
|
||||
if (!(await pathExists(parentDir))) {
|
||||
await createDirIfNotExists(parentDir, config)
|
||||
}
|
||||
|
||||
if (!(await pathExists(dir))) {
|
||||
try {
|
||||
log(config, LogLevel.debug, `Creating dir ${dir}`)
|
||||
await mkdir(dir)
|
||||
return
|
||||
} catch (e: unknown) {
|
||||
if (e && (e as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks whether a file or directory exists at the given path. */
|
||||
export async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, F_OK)
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
if (e && (e as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the given path is a directory. */
|
||||
export async function isDir(dirPath: string): Promise<boolean> {
|
||||
const tplStat = await stat(dirPath)
|
||||
return tplStat.isDirectory()
|
||||
}
|
||||
|
||||
/** Generates a unique temporary directory path for scaffold operations. @internal */
|
||||
export function getUniqueTmpPath(): string {
|
||||
return path.resolve(
|
||||
os.tmpdir(),
|
||||
`scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
}
|
||||
11
src/git.ts
11
src/git.ts
@@ -13,7 +13,7 @@ export async function getGitConfig(
|
||||
): Promise<AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>> {
|
||||
const repoUrl = `${url.protocol}//${url.host}${url.pathname}`
|
||||
|
||||
log(logConfig, LogLevel.info, `Cloning git repo ${repoUrl}`)
|
||||
log(logConfig, LogLevel.debug, `Cloning git repo ${repoUrl}`)
|
||||
|
||||
return new Promise((res, reject) => {
|
||||
log(logConfig, LogLevel.debug, `Cloning git repo to ${tmpPath}`)
|
||||
@@ -43,13 +43,16 @@ export async function loadGitConfig({
|
||||
file: string
|
||||
tmpPath: string
|
||||
}): Promise<AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>> {
|
||||
log(logConfig, LogLevel.info, `Loading config from git repo: ${repoUrl}`)
|
||||
log(logConfig, LogLevel.debug, `Loading config from git repo: ${repoUrl}`)
|
||||
const filename = file || (await findConfigFile(tmpPath))
|
||||
const absolutePath = path.resolve(tmpPath, filename)
|
||||
log(logConfig, LogLevel.debug, `Resolving config file: ${absolutePath}`)
|
||||
const loadedConfig = await resolve(async () => (await import(absolutePath)).default as ScaffoldConfigMap, logConfig)
|
||||
const loadedConfig = await resolve(
|
||||
async () => (await import(absolutePath)).default as ScaffoldConfigMap,
|
||||
logConfig,
|
||||
)
|
||||
|
||||
log(logConfig, LogLevel.info, `Loaded config from git`)
|
||||
log(logConfig, LogLevel.debug, `Loaded config from git`)
|
||||
log(logConfig, LogLevel.debug, `Raw config:`, loadedConfig)
|
||||
const fixedConfig: ScaffoldConfigMap = {}
|
||||
for (const [k, v] of Object.entries(loadedConfig)) {
|
||||
|
||||
67
src/ignore.ts
Normal file
67
src/ignore.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import { minimatch } from "minimatch"
|
||||
import { pathExists } from "./fs-utils"
|
||||
|
||||
const IGNORE_FILENAME = ".scaffoldignore"
|
||||
|
||||
/**
|
||||
* Reads a `.scaffoldignore` file from the given directory and returns
|
||||
* the parsed patterns for filtering.
|
||||
*
|
||||
* Lines starting with `#` are comments. Empty lines are skipped.
|
||||
*
|
||||
* @param dir The directory to search for `.scaffoldignore`
|
||||
* @returns Array of glob patterns to ignore
|
||||
*/
|
||||
export async function loadIgnorePatterns(dir: string): Promise<string[]> {
|
||||
const ignorePath = path.resolve(dir, IGNORE_FILENAME)
|
||||
|
||||
if (!(await pathExists(ignorePath))) {
|
||||
return []
|
||||
}
|
||||
|
||||
const content = await fs.readFile(ignorePath, "utf-8")
|
||||
return parseIgnoreFile(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the contents of a `.scaffoldignore` file into glob patterns.
|
||||
* @internal
|
||||
*/
|
||||
export function parseIgnoreFile(content: string): string[] {
|
||||
return content
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a list of file paths, removing any that match the ignore patterns.
|
||||
* Patterns are matched against the relative path from baseDir.
|
||||
* Also always excludes `.scaffoldignore` itself.
|
||||
*/
|
||||
export function filterIgnoredFiles(
|
||||
files: string[],
|
||||
ignorePatterns: string[],
|
||||
baseDir: string,
|
||||
): string[] {
|
||||
return files.filter((file) => {
|
||||
const basename = path.basename(file)
|
||||
if (basename === IGNORE_FILENAME) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relPath = path.relative(baseDir, file)
|
||||
|
||||
for (const pattern of ignorePatterns) {
|
||||
if (
|
||||
minimatch(relPath, pattern, { dot: true }) ||
|
||||
minimatch(basename, pattern, { dot: true })
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./scaffold"
|
||||
export * from "./types"
|
||||
export { validateConfig, assertConfigValid, scaffoldConfigSchema } from "./validate"
|
||||
import Scaffold from "./scaffold"
|
||||
|
||||
export default Scaffold
|
||||
|
||||
111
src/init.ts
Normal file
111
src/init.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import select from "@inquirer/select"
|
||||
import { colorize } from "./colors"
|
||||
import { pathExists } from "./fs-utils"
|
||||
|
||||
const CONFIG_EXTENSIONS = {
|
||||
js: "scaffold.config.js",
|
||||
mjs: "scaffold.config.mjs",
|
||||
json: "scaffold.config.json",
|
||||
} as const
|
||||
|
||||
type ConfigFormat = keyof typeof CONFIG_EXTENSIONS
|
||||
|
||||
const CONFIG_TEMPLATES: Record<ConfigFormat, string> = {
|
||||
js: `/** @type {import('simple-scaffold').ScaffoldConfigMap} */
|
||||
module.exports = {
|
||||
default: {
|
||||
templates: ["templates/default"],
|
||||
output: ".",
|
||||
// inputs: {
|
||||
// author: { message: "Author name", required: true },
|
||||
// license: { message: "License", default: "MIT" },
|
||||
// },
|
||||
},
|
||||
}
|
||||
`,
|
||||
mjs: `/** @type {import('simple-scaffold').ScaffoldConfigMap} */
|
||||
export default {
|
||||
default: {
|
||||
templates: ["templates/default"],
|
||||
output: ".",
|
||||
// inputs: {
|
||||
// author: { message: "Author name", required: true },
|
||||
// license: { message: "License", default: "MIT" },
|
||||
// },
|
||||
},
|
||||
}
|
||||
`,
|
||||
json: `{
|
||||
"default": {
|
||||
"templates": ["templates/default"],
|
||||
"output": "."
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
const EXAMPLE_TEMPLATE_CONTENT = `# {{ name }}
|
||||
|
||||
Created by Simple Scaffold.
|
||||
|
||||
{{#if description}}{{ description }}{{/if}}
|
||||
`
|
||||
|
||||
export interface InitOptions {
|
||||
/** Working directory to create the config in. Defaults to cwd. */
|
||||
dir?: string
|
||||
/** Config format to use. If not provided, the user is prompted. */
|
||||
format?: ConfigFormat
|
||||
/** Whether to create an example template directory. Defaults to true. */
|
||||
createExample?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new Simple Scaffold project by creating a config file
|
||||
* and an optional example template directory.
|
||||
*/
|
||||
export async function initScaffold(options: InitOptions = {}): Promise<void> {
|
||||
const dir = options.dir ?? process.cwd()
|
||||
|
||||
const format =
|
||||
options.format ??
|
||||
(await select<ConfigFormat>({
|
||||
message: colorize.cyan("Config file format:"),
|
||||
choices: [
|
||||
{ name: "JavaScript (CommonJS)", value: "js" },
|
||||
{ name: "JavaScript (ESM)", value: "mjs" },
|
||||
{ name: "JSON", value: "json" },
|
||||
],
|
||||
}))
|
||||
|
||||
const filename = CONFIG_EXTENSIONS[format]
|
||||
const configPath = path.resolve(dir, filename)
|
||||
|
||||
if (await pathExists(configPath)) {
|
||||
console.log(colorize.yellow(`${filename} already exists, skipping config creation.`))
|
||||
} else {
|
||||
await fs.writeFile(configPath, CONFIG_TEMPLATES[format])
|
||||
console.log(colorize.green(`Created ${filename}`))
|
||||
}
|
||||
|
||||
const createExample = options.createExample ?? true
|
||||
if (createExample) {
|
||||
const templateDir = path.resolve(dir, "templates", "default")
|
||||
const templateFile = path.join(templateDir, "{{name}}.md")
|
||||
|
||||
if (await pathExists(templateDir)) {
|
||||
console.log(colorize.yellow("templates/default/ already exists, skipping example template."))
|
||||
} else {
|
||||
await fs.mkdir(templateDir, { recursive: true })
|
||||
await fs.writeFile(templateFile, EXAMPLE_TEMPLATE_CONTENT)
|
||||
console.log(colorize.green("Created templates/default/{{name}}.md"))
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(colorize.dim("Get started:"))
|
||||
console.log(colorize.dim(` npx simple-scaffold MyProject`))
|
||||
console.log()
|
||||
}
|
||||
108
src/logger.ts
108
src/logger.ts
@@ -1,31 +1,39 @@
|
||||
import util from "util"
|
||||
import path from "node:path"
|
||||
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]: "dim",
|
||||
[LogLevel.info]: "reset",
|
||||
[LogLevel.warning]: "yellow",
|
||||
[LogLevel.error]: "red",
|
||||
}
|
||||
|
||||
/** Logs a message at the given level, respecting the configured log level filter. */
|
||||
export function log(config: LogConfig, level: LogLevel, ...obj: unknown[]): void {
|
||||
if (
|
||||
config.logLevel === LogLevel.none ||
|
||||
LOG_PRIORITY[level] < LOG_PRIORITY[config.logLevel ?? LogLevel.info]
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const levelColor: Record<keyof typeof LogLevel, TermColor> = {
|
||||
[LogLevel.none]: "reset",
|
||||
[LogLevel.debug]: "blue",
|
||||
[LogLevel.info]: "dim",
|
||||
[LogLevel.warning]: "yellow",
|
||||
[LogLevel.error]: "red",
|
||||
}
|
||||
|
||||
const colorFn = colorize[levelColor[level]]
|
||||
const key: "log" | "warn" | "error" = level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
|
||||
const logFn: any = console[key]
|
||||
const colorFn = colorize[LOG_LEVEL_COLOR[level]]
|
||||
const key: "log" | "warn" | "error" =
|
||||
level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
|
||||
const logFn: (..._args: unknown[]) => void = console[key]
|
||||
logFn(
|
||||
...obj.map((i) =>
|
||||
i instanceof Error
|
||||
@@ -37,6 +45,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 +65,7 @@ export function logInputFile(
|
||||
log(config, LogLevel.debug, data)
|
||||
}
|
||||
|
||||
/** Logs the full scaffold configuration at debug level. */
|
||||
export function logInitStep(config: ScaffoldConfig): void {
|
||||
log(config, LogLevel.debug, "Full config:", {
|
||||
name: config.name,
|
||||
@@ -67,5 +80,56 @@ export function logInitStep(config: ScaffoldConfig): void {
|
||||
dryRun: config.dryRun,
|
||||
beforeWrite: config.beforeWrite,
|
||||
} as Record<keyof ScaffoldConfig, unknown>)
|
||||
log(config, LogLevel.info, "Data:", config.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a tree of created files, grouped by directory.
|
||||
*/
|
||||
export function logFileTree(config: LogConfig, files: string[]): void {
|
||||
if (files.length === 0) return
|
||||
|
||||
// Find common prefix to make paths relative
|
||||
const commonDir = files.reduce((prefix, file) => {
|
||||
while (!file.startsWith(prefix)) {
|
||||
prefix = path.dirname(prefix)
|
||||
}
|
||||
return prefix
|
||||
}, path.dirname(files[0]))
|
||||
|
||||
log(config, LogLevel.info, "")
|
||||
log(config, LogLevel.info, colorize.bold(`📁 ${commonDir}`))
|
||||
|
||||
const relPaths = files.map((f) => path.relative(commonDir, f)).sort()
|
||||
|
||||
for (let i = 0; i < relPaths.length; i++) {
|
||||
const isLast = i === relPaths.length - 1
|
||||
const prefix = isLast ? "└── " : "├── "
|
||||
log(config, LogLevel.info, colorize.dim(prefix) + relPaths[i])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a final summary line with file count and elapsed time.
|
||||
*/
|
||||
export function logSummary(
|
||||
config: LogConfig,
|
||||
fileCount: number,
|
||||
elapsedMs: number,
|
||||
dryRun?: boolean,
|
||||
): void {
|
||||
const timeStr =
|
||||
elapsedMs < 1000 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`
|
||||
|
||||
log(config, LogLevel.info, "")
|
||||
if (dryRun) {
|
||||
log(
|
||||
config,
|
||||
LogLevel.info,
|
||||
colorize.yellow(`🏜️ Dry run complete — ${fileCount} file(s) would be created (${timeStr})`),
|
||||
)
|
||||
} else if (fileCount === 0) {
|
||||
log(config, LogLevel.info, colorize.yellow(`⚠️ No files created (${timeStr})`))
|
||||
} else {
|
||||
log(config, LogLevel.info, colorize.green(`✅ Created ${fileCount} file(s) in ${timeStr}`))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import path from "node:path"
|
||||
import { DefaultHelpers, Helper, LogLevel, ScaffoldConfig } from "./types"
|
||||
import Handlebars from "handlebars"
|
||||
import dtAdd from "date-fns/add"
|
||||
import dtFormat from "date-fns/format"
|
||||
import dtParseISO from "date-fns/parseISO"
|
||||
import { add, format, parseISO, type Duration } from "date-fns"
|
||||
import { log } from "./logger"
|
||||
import { Duration } from "date-fns"
|
||||
|
||||
const dateFns = {
|
||||
add: dtAdd.add,
|
||||
format: dtFormat.format,
|
||||
parseISO: dtParseISO.parseISO,
|
||||
}
|
||||
const dateFns = { add, format, parseISO }
|
||||
|
||||
export const defaultHelpers: Record<DefaultHelpers, Helper> = {
|
||||
camelCase,
|
||||
@@ -27,7 +20,12 @@ export const defaultHelpers: Record<DefaultHelpers, Helper> = {
|
||||
}
|
||||
|
||||
function _dateHelper(date: Date, formatString: string): string
|
||||
function _dateHelper(date: Date, formatString: string, durationDifference: number, durationType: keyof Duration): string
|
||||
function _dateHelper(
|
||||
date: Date,
|
||||
formatString: string,
|
||||
durationDifference: number,
|
||||
durationType: keyof Duration,
|
||||
): string
|
||||
function _dateHelper(
|
||||
date: Date,
|
||||
formatString: string,
|
||||
@@ -41,8 +39,16 @@ function _dateHelper(
|
||||
}
|
||||
|
||||
export function nowHelper(formatString: string): string
|
||||
export function nowHelper(formatString: string, durationDifference: number, durationType: keyof Duration): string
|
||||
export function nowHelper(formatString: string, durationDifference?: number, durationType?: keyof Duration): string {
|
||||
export function nowHelper(
|
||||
formatString: string,
|
||||
durationDifference: number,
|
||||
durationType: keyof Duration,
|
||||
): string
|
||||
export function nowHelper(
|
||||
formatString: string,
|
||||
durationDifference?: number,
|
||||
durationType?: keyof Duration,
|
||||
): string {
|
||||
return _dateHelper(new Date(), formatString, durationDifference!, durationType!)
|
||||
}
|
||||
|
||||
@@ -62,9 +68,16 @@ export function dateHelper(
|
||||
return _dateHelper(dateFns.parseISO(date), formatString, durationDifference!, durationType!)
|
||||
}
|
||||
|
||||
// splits by either non-alpha character or capital letter
|
||||
// splits by either non-alphanumeric character or capital letter boundaries
|
||||
function toWordParts(string: string): string[] {
|
||||
return string.split(/(?=[A-Z])|[^a-zA-Z]/).filter((s) => s.length > 0)
|
||||
// First split on non-alphanumeric characters
|
||||
return string
|
||||
.split(/[^a-zA-Z0-9]/)
|
||||
.flatMap((segment) =>
|
||||
// Then split camelCase/PascalCase boundaries, handling consecutive uppercase (e.g. "HTMLParser" -> "HTML", "Parser")
|
||||
segment.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/),
|
||||
)
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
function camelCase(s: string): string {
|
||||
@@ -121,7 +134,7 @@ export function handlebarsParse(
|
||||
return Buffer.from(outputContents)
|
||||
} catch (e) {
|
||||
log(config, LogLevel.debug, e)
|
||||
log(config, LogLevel.info, "Couldn't parse file with handlebars, returning original content")
|
||||
log(config, LogLevel.debug, "Couldn't parse file with handlebars, returning original content")
|
||||
return Buffer.from(templateBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
19
src/path-utils.ts
Normal file
19
src/path-utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from "node:path"
|
||||
|
||||
/** Strips glob wildcard characters from a template path. */
|
||||
export function removeGlob(template: string): string {
|
||||
return path.normalize(template.replace(/\*/g, ""))
|
||||
}
|
||||
|
||||
/** Removes a leading path separator, making the path relative. */
|
||||
export function makeRelativePath(str: string): string {
|
||||
return str.startsWith(path.sep) ? str.slice(1) : str
|
||||
}
|
||||
|
||||
/** Computes a base path relative to the current working directory. */
|
||||
export function getBasePath(relPath: string): string {
|
||||
return path
|
||||
.resolve(process.cwd(), relPath)
|
||||
.replace(process.cwd() + path.sep, "")
|
||||
.replace(process.cwd(), "")
|
||||
}
|
||||
253
src/prompts.ts
Normal file
253
src/prompts.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import input from "@inquirer/input"
|
||||
import select from "@inquirer/select"
|
||||
import confirm from "@inquirer/confirm"
|
||||
import number from "@inquirer/number"
|
||||
import { colorize } from "./colors"
|
||||
import {
|
||||
ScaffoldCmdConfig,
|
||||
ScaffoldConfig,
|
||||
ScaffoldConfigMap,
|
||||
ScaffoldInput,
|
||||
ScaffoldInputType,
|
||||
} from "./types"
|
||||
|
||||
/** Prompts the user for a scaffold name. */
|
||||
export async function promptForName(): Promise<string> {
|
||||
return input({
|
||||
message: colorize.cyan("Scaffold name:"),
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "Name cannot be empty"
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Prompts the user to select a template key from the available config keys. */
|
||||
export async function promptForTemplateKey(configMap: ScaffoldConfigMap): Promise<string> {
|
||||
const keys = Object.keys(configMap)
|
||||
if (keys.length === 0) {
|
||||
throw new Error("No templates found in config file")
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
return keys[0]
|
||||
}
|
||||
return select({
|
||||
message: colorize.cyan("Select a template:"),
|
||||
choices: keys.map((key) => ({
|
||||
name: key,
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/** Prompts the user for an output directory path. */
|
||||
export async function promptForOutput(): Promise<string> {
|
||||
return input({
|
||||
message: colorize.cyan("Output directory:"),
|
||||
required: true,
|
||||
default: ".",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "Output directory cannot be empty"
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Prompts the user for template paths (comma-separated). */
|
||||
export async function promptForTemplates(): Promise<string[]> {
|
||||
const value = await input({
|
||||
message: colorize.cyan("Template paths (comma-separated):"),
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "At least one template path is required"
|
||||
return true
|
||||
},
|
||||
})
|
||||
return value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Prompts for a single input based on its type. */
|
||||
async function promptSingleInput(
|
||||
key: string,
|
||||
def: ScaffoldInput,
|
||||
): Promise<string | boolean | number | undefined> {
|
||||
const type: ScaffoldInputType = def.type ?? "text"
|
||||
const message = colorize.cyan(def.message ?? `${key}:`)
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
return input({
|
||||
message,
|
||||
required: def.required,
|
||||
default: def.default as string | undefined,
|
||||
validate: def.required
|
||||
? (value) => {
|
||||
if (!value.trim()) return `${key} is required`
|
||||
return true
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
case "select": {
|
||||
const choices = (def.options ?? []).map((opt) =>
|
||||
typeof opt === "string" ? { name: opt, value: opt } : opt,
|
||||
)
|
||||
if (choices.length === 0) {
|
||||
throw new Error(`Input "${key}" has type "select" but no options defined`)
|
||||
}
|
||||
return select({
|
||||
message,
|
||||
choices,
|
||||
default: def.default as string | undefined,
|
||||
})
|
||||
}
|
||||
|
||||
case "confirm":
|
||||
return confirm({
|
||||
message,
|
||||
default: (def.default as boolean | undefined) ?? false,
|
||||
})
|
||||
|
||||
case "number":
|
||||
return (
|
||||
(await number({
|
||||
message,
|
||||
required: def.required,
|
||||
default: def.default as number | undefined,
|
||||
})) ?? def.default
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user for any required scaffold inputs that are not already provided in data.
|
||||
* Also applies default values for optional inputs that have one.
|
||||
* Returns the merged data object.
|
||||
*/
|
||||
export async function promptForInputs(
|
||||
inputs: Record<string, ScaffoldInput>,
|
||||
existingData: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = { ...existingData }
|
||||
|
||||
for (const [key, def] of Object.entries(inputs)) {
|
||||
// Skip if already provided via data/CLI
|
||||
if (key in data && data[key] !== undefined && data[key] !== "") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (def.required || def.type === "select" || def.type === "confirm") {
|
||||
data[key] = await promptSingleInput(key, def)
|
||||
} else if (def.default !== undefined && !(key in data)) {
|
||||
data[key] = def.default
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/** Returns true if the process is running in an interactive terminal. */
|
||||
export function isInteractive(): boolean {
|
||||
return Boolean(process.stdin.isTTY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for name and template key before the config file is parsed.
|
||||
* These are needed by parseConfigFile to know which template to load.
|
||||
*/
|
||||
export async function promptBeforeConfig(
|
||||
config: ScaffoldCmdConfig,
|
||||
configMap?: ScaffoldConfigMap,
|
||||
): Promise<ScaffoldCmdConfig> {
|
||||
if (!isInteractive()) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (!config.name) {
|
||||
config.name = await promptForName()
|
||||
}
|
||||
|
||||
if (configMap && !config.key) {
|
||||
const keys = Object.keys(configMap)
|
||||
if (keys.length > 1) {
|
||||
config.key = await promptForTemplateKey(configMap)
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for any values still missing after the config file has been parsed.
|
||||
* Only prompts in interactive mode.
|
||||
*/
|
||||
export async function promptAfterConfig(config: ScaffoldConfig): Promise<ScaffoldConfig> {
|
||||
if (!isInteractive()) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (!config.output || (typeof config.output === "string" && !config.output)) {
|
||||
config.output = await promptForOutput()
|
||||
}
|
||||
|
||||
if (!config.templates || config.templates.length === 0) {
|
||||
config.templates = await promptForTemplates()
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link promptBeforeConfig} and {@link promptAfterConfig} instead.
|
||||
*/
|
||||
export async function promptForMissingConfig(
|
||||
config: ScaffoldCmdConfig,
|
||||
configMap?: ScaffoldConfigMap,
|
||||
): Promise<ScaffoldCmdConfig> {
|
||||
const afterPre = await promptBeforeConfig(config, configMap)
|
||||
|
||||
if (!isInteractive()) {
|
||||
return afterPre
|
||||
}
|
||||
|
||||
if (!afterPre.output) {
|
||||
afterPre.output = await promptForOutput()
|
||||
}
|
||||
|
||||
if (!afterPre.templates || afterPre.templates.length === 0) {
|
||||
afterPre.templates = await promptForTemplates()
|
||||
}
|
||||
|
||||
return afterPre
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for any required inputs defined in the scaffold config and merges them into data.
|
||||
* Only prompts in interactive mode; in non-interactive mode, only applies defaults.
|
||||
*/
|
||||
export async function resolveInputs(config: ScaffoldConfig): Promise<ScaffoldConfig> {
|
||||
if (!config.inputs) {
|
||||
return config
|
||||
}
|
||||
|
||||
const interactive = isInteractive()
|
||||
|
||||
if (interactive) {
|
||||
config.data = await promptForInputs(config.inputs, config.data)
|
||||
} else {
|
||||
// Non-interactive: only apply defaults
|
||||
const data = { ...config.data }
|
||||
for (const [key, def] of Object.entries(config.inputs)) {
|
||||
if (def.default !== undefined && !(key in data)) {
|
||||
data[key] = def.default
|
||||
}
|
||||
}
|
||||
config.data = data
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
175
src/scaffold.ts
175
src/scaffold.ts
@@ -6,22 +6,18 @@
|
||||
*/
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { exec } from "node:child_process"
|
||||
|
||||
import { handleErr, resolve } from "./utils"
|
||||
import {
|
||||
isDir,
|
||||
removeGlob,
|
||||
makeRelativePath,
|
||||
getTemplateGlobInfo,
|
||||
getFileList,
|
||||
getBasePath,
|
||||
handleTemplateFile,
|
||||
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, logFileTree, logSummary } from "./logger"
|
||||
import { parseConfigFile } from "./config"
|
||||
import { resolveInputs } from "./prompts"
|
||||
import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
|
||||
import { assertConfigValid } from "./validate"
|
||||
|
||||
/**
|
||||
* Create a scaffold using given `options`.
|
||||
@@ -58,53 +54,135 @@ import { parseConfigFile } from "./config"
|
||||
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
|
||||
config.output ??= process.cwd()
|
||||
|
||||
await assertConfigValid(config)
|
||||
config = await resolveInputs(config)
|
||||
registerHelpers(config)
|
||||
|
||||
const startTime = performance.now()
|
||||
const writtenFiles: string[] = []
|
||||
try {
|
||||
config.data = { name: config.name, ...config.data }
|
||||
logInitStep(config)
|
||||
|
||||
log(config, LogLevel.info, `Scaffolding "${config.name}"...`)
|
||||
|
||||
const excludes = config.templates.filter((t) => t.startsWith("!"))
|
||||
const includes = config.templates.filter((t) => !t.startsWith("!"))
|
||||
const templates: 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,
|
||||
})
|
||||
}
|
||||
const files = await processTemplateGlob(config, tpl, excludes)
|
||||
writtenFiles.push(...files)
|
||||
}
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
log(config, LogLevel.error, e)
|
||||
throw e
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
logFileTree(config, writtenFiles)
|
||||
logSummary(config, writtenFiles.length, elapsed, config.dryRun)
|
||||
|
||||
if (config.afterScaffold) {
|
||||
await runAfterScaffoldHook(config, writtenFiles)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves included template paths into GlobInfo objects. */
|
||||
async function resolveTemplateGlobs(
|
||||
config: ScaffoldConfig,
|
||||
includes: string[],
|
||||
): Promise<GlobInfo[]> {
|
||||
const templates: GlobInfo[] = []
|
||||
for (const includedTemplate of includes) {
|
||||
try {
|
||||
templates.push(await getTemplateGlobInfo(config, includedTemplate))
|
||||
} catch (e: unknown) {
|
||||
handleErr(e as NodeJS.ErrnoException)
|
||||
}
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
/** Processes all files matching a single template glob pattern. Returns paths of written files. */
|
||||
async function processTemplateGlob(
|
||||
config: ScaffoldConfig,
|
||||
tpl: GlobInfo,
|
||||
excludes: string[],
|
||||
): Promise<string[]> {
|
||||
const written: string[] = []
|
||||
|
||||
// Load .scaffoldignore from the template base directory
|
||||
const ignorePatterns = await loadIgnorePatterns(tpl.baseTemplatePath)
|
||||
if (ignorePatterns.length > 0) {
|
||||
log(config, LogLevel.debug, `Loaded .scaffoldignore patterns:`, ignorePatterns)
|
||||
}
|
||||
|
||||
const allFiles = await getFileList(config, [tpl.template, ...excludes])
|
||||
const files = filterIgnoredFiles(allFiles, ignorePatterns, tpl.baseTemplatePath)
|
||||
for (const file of files) {
|
||||
if (await isDir(file)) {
|
||||
continue
|
||||
}
|
||||
log(config, LogLevel.debug, "Iterating files", { files, file })
|
||||
const relPath = makeRelativePath(
|
||||
path.dirname(removeGlob(file).replace(tpl.baseTemplatePath, "")),
|
||||
)
|
||||
const basePath = getBasePath(relPath)
|
||||
|
||||
log(config, LogLevel.debug, {
|
||||
originalTemplate: tpl.origTemplate,
|
||||
relativePath: relPath,
|
||||
parsedTemplate: tpl.template,
|
||||
inputFilePath: file,
|
||||
baseTemplatePath: tpl.baseTemplatePath,
|
||||
basePath,
|
||||
isDirOrGlob: tpl.isDirOrGlob,
|
||||
isGlob: tpl.isGlob,
|
||||
})
|
||||
|
||||
const outputPath = await handleTemplateFile(config, { templatePath: file, basePath })
|
||||
if (outputPath) {
|
||||
written.push(outputPath)
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
/** Executes the afterScaffold hook — either a function or a shell command string. */
|
||||
async function runAfterScaffoldHook(config: ScaffoldConfig, files: string[]): Promise<void> {
|
||||
const hook = config.afterScaffold!
|
||||
|
||||
if (typeof hook === "function") {
|
||||
log(config, LogLevel.debug, "Running afterScaffold function hook")
|
||||
await hook({ config, files })
|
||||
return
|
||||
}
|
||||
|
||||
// Shell command string
|
||||
const outputDir = typeof config.output === "string" ? config.output : process.cwd()
|
||||
const cwd = path.resolve(process.cwd(), outputDir)
|
||||
|
||||
log(config, LogLevel.info, `Running afterScaffold command: ${hook}`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = exec(hook, { cwd })
|
||||
proc.stdout?.on("data", (data: string) => {
|
||||
log(config, LogLevel.info, data.toString().trimEnd())
|
||||
})
|
||||
proc.stderr?.on("data", (data: string) => {
|
||||
log(config, LogLevel.warning, data.toString().trimEnd())
|
||||
})
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`afterScaffold command exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
proc.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,11 +197,8 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
|
||||
* @return {Promise<void>} A promise that resolves when the scaffold is complete
|
||||
*/
|
||||
Scaffold.fromConfig = async function (
|
||||
/** The path or URL to the config file */
|
||||
pathOrUrl: string,
|
||||
/** Information needed before loading the config */
|
||||
config: MinimalConfig,
|
||||
/** Any overrides to the loaded config */
|
||||
overrides?: Resolver<ScaffoldCmdConfig, Partial<Omit<ScaffoldConfig, "name">>>,
|
||||
): Promise<void> {
|
||||
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)
|
||||
|
||||
120
src/types.ts
120
src/types.ts
@@ -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.
|
||||
@@ -166,10 +166,116 @@ export interface ScaffoldConfig {
|
||||
outputPath: string,
|
||||
): string | Buffer | undefined | Promise<string | Buffer | undefined>
|
||||
|
||||
/**
|
||||
* Defines interactive inputs for the template. Each input becomes a template data variable.
|
||||
*
|
||||
* When running interactively, required inputs that are not already provided via `data` or CLI args
|
||||
* will be prompted for. Optional inputs without a value will use their `default` if defined.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Scaffold({
|
||||
* // ...
|
||||
* inputs: {
|
||||
* author: { message: "Author name", required: true },
|
||||
* license: { message: "License", default: "MIT" },
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* In templates: `{{ author }}`, `{{ license }}`
|
||||
*
|
||||
* @see {@link ScaffoldInput}
|
||||
*/
|
||||
inputs?: Record<string, ScaffoldInput>
|
||||
|
||||
/**
|
||||
* A callback or shell command that runs after all files have been written.
|
||||
*
|
||||
* When provided as a **function** (Node.js API), it receives a context object with the scaffold
|
||||
* config and the list of files that were written:
|
||||
*
|
||||
* ```typescript
|
||||
* Scaffold({
|
||||
* // ...
|
||||
* afterScaffold: async ({ config, files }) => {
|
||||
* console.log(`Created ${files.length} files`)
|
||||
* execSync("npm install", { cwd: config.output })
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* When provided as a **string** (CLI `--after` flag), it is executed as a shell command
|
||||
* in the output directory after scaffolding completes.
|
||||
*
|
||||
* @see {@link AfterScaffoldContext}
|
||||
*/
|
||||
afterScaffold?: AfterScaffoldHook
|
||||
|
||||
/** @internal */
|
||||
tmpDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to the {@link ScaffoldConfig.afterScaffold} hook.
|
||||
*
|
||||
* @category Config
|
||||
*/
|
||||
export interface AfterScaffoldContext {
|
||||
/** The resolved scaffold config that was used. */
|
||||
config: ScaffoldConfig
|
||||
/** List of absolute paths to files that were written. */
|
||||
files: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that runs after scaffolding completes.
|
||||
* Can be a function receiving context, or a shell command string.
|
||||
*
|
||||
* @category Config
|
||||
*/
|
||||
export type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
|
||||
|
||||
/**
|
||||
* The type of an interactive input prompt.
|
||||
*
|
||||
* - `"text"` — free-form text input (default)
|
||||
* - `"select"` — choose from a list of options
|
||||
* - `"confirm"` — yes/no boolean prompt
|
||||
* - `"number"` — numeric input
|
||||
*
|
||||
* @category Config
|
||||
*/
|
||||
export type ScaffoldInputType = "text" | "select" | "confirm" | "number"
|
||||
|
||||
/**
|
||||
* Defines a single interactive input for a scaffold template.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* inputs: {
|
||||
* author: { message: "Author name", required: true },
|
||||
* license: { type: "select", message: "License", options: ["MIT", "Apache-2.0", "GPL-3.0"] },
|
||||
* private: { type: "confirm", message: "Private package?", default: false },
|
||||
* port: { type: "number", message: "Dev server port", default: 3000 },
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @category Config
|
||||
*/
|
||||
export interface ScaffoldInput {
|
||||
/** The type of prompt. Defaults to `"text"`. */
|
||||
type?: ScaffoldInputType
|
||||
/** The prompt message shown to the user. Defaults to the input key name if omitted. */
|
||||
message?: string
|
||||
/** Whether this input must be provided. If true and missing, the user will be prompted interactively. */
|
||||
required?: boolean
|
||||
/** Default value. Type depends on the input type: string for text/select, boolean for confirm, number for number. */
|
||||
default?: string | boolean | number
|
||||
/** List of options for `type: "select"`. Each can be a string or `{ name, value }`. */
|
||||
options?: (string | { name: string; value: string })[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of the available helper functions that relate to text capitalization.
|
||||
*
|
||||
@@ -342,6 +448,8 @@ export type FileResponse<T> = T | FileResponseHandler<T>
|
||||
* Contains less and more specific options than {@link ScaffoldConfig}.
|
||||
*
|
||||
* For more information about each option, see {@link ScaffoldConfig}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type ScaffoldCmdConfig = {
|
||||
/** The name of the scaffold template to use. */
|
||||
@@ -380,6 +488,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
|
||||
/** Run a shell command after all files have been written. Executed in the output directory. */
|
||||
afterScaffold?: string
|
||||
/** @internal */
|
||||
tmpDir?: string
|
||||
}
|
||||
@@ -395,6 +505,7 @@ export type ScaffoldCmdConfig = {
|
||||
*
|
||||
* @see {@link ScaffoldConfig}
|
||||
*
|
||||
* @internal
|
||||
* @category Config
|
||||
*/
|
||||
export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
|
||||
@@ -406,12 +517,13 @@ export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
|
||||
* - A promise that resolves to a {@link ScaffoldConfigMap} object
|
||||
* - A function that returns a promise that resolves to a {@link ScaffoldConfigMap} object
|
||||
*
|
||||
* @internal
|
||||
* @category Config
|
||||
*/
|
||||
export type ScaffoldConfigFile = AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>
|
||||
|
||||
/** @internal */
|
||||
export type Resolver<T, R = T> = R | ((value: T) => R)
|
||||
export type Resolver<T, R = T> = R | ((_value: T) => R)
|
||||
|
||||
/** @internal */
|
||||
export type AsyncResolver<T, R = T> = Resolver<T, Promise<R> | R>
|
||||
@@ -423,9 +535,11 @@ export type LogConfig = Pick<ScaffoldConfig, "logLevel">
|
||||
export type ConfigLoadConfig = LogConfig & Pick<ScaffoldCmdConfig, "config">
|
||||
|
||||
/** @internal */
|
||||
export type RemoteConfigLoadConfig = LogConfig & Pick<ScaffoldCmdConfig, "config" | "git" | "tmpDir">
|
||||
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">
|
||||
|
||||
68
src/utils.ts
68
src/utils.ts
@@ -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>,
|
||||
),
|
||||
)
|
||||
|
||||
171
src/validate.ts
Normal file
171
src/validate.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { z } from "zod/v4"
|
||||
import { pathExists } from "./fs-utils"
|
||||
|
||||
// --- Reusable schemas ---
|
||||
|
||||
/** Schema for a JavaScript function value. */
|
||||
const functionSchema = z
|
||||
.any()
|
||||
.refine((v) => typeof v === "function", { message: "Expected a function" })
|
||||
|
||||
/** Schema for a value that can be either a string or a function. */
|
||||
const stringOrFunctionSchema = z.union([z.string(), functionSchema])
|
||||
|
||||
/** Schema for a value that can be either a boolean or a function. */
|
||||
const booleanOrFunctionSchema = z.union([z.boolean(), functionSchema])
|
||||
|
||||
/** Schema for a select input option — either a plain string or a `{ name, value }` object. */
|
||||
const selectOptionSchema = z.union([z.string(), z.object({ name: z.string(), value: z.string() })])
|
||||
|
||||
/** Schema for the input type enum. */
|
||||
const inputTypeSchema = z.enum(["text", "select", "confirm", "number"])
|
||||
|
||||
/** Schema for the log level enum. */
|
||||
const logLevelSchema = z.enum(["none", "debug", "info", "warning", "error"])
|
||||
|
||||
// --- Input schema ---
|
||||
|
||||
/** Zod schema for a single scaffold input definition. */
|
||||
const scaffoldInputSchema = z.object({
|
||||
type: inputTypeSchema.optional(),
|
||||
message: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
default: z.union([z.string(), z.boolean(), z.number()]).optional(),
|
||||
options: z.array(selectOptionSchema).optional(),
|
||||
})
|
||||
|
||||
type InputDef = z.infer<typeof scaffoldInputSchema>
|
||||
|
||||
function validateInputSemantics(
|
||||
key: string,
|
||||
input: InputDef,
|
||||
): { path: (string | number)[]; message: string }[] {
|
||||
const issues: { path: (string | number)[]; message: string }[] = []
|
||||
if (input.type === "select" && (!input.options || input.options.length === 0)) {
|
||||
issues.push({
|
||||
path: ["inputs", key, "options"],
|
||||
message: "select input must have a non-empty options array",
|
||||
})
|
||||
}
|
||||
if (
|
||||
input.type === "confirm" &&
|
||||
input.default !== undefined &&
|
||||
typeof input.default !== "boolean"
|
||||
) {
|
||||
issues.push({
|
||||
path: ["inputs", key, "default"],
|
||||
message: "confirm input default must be a boolean",
|
||||
})
|
||||
}
|
||||
if (input.type === "number" && input.default !== undefined && typeof input.default !== "number") {
|
||||
issues.push({
|
||||
path: ["inputs", key, "default"],
|
||||
message: "number input default must be a number",
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// --- Config schema ---
|
||||
|
||||
/** Zod schema for ScaffoldConfig. */
|
||||
const scaffoldConfigSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "name is required"),
|
||||
templates: z.array(z.string()).min(1, "templates must contain at least one entry"),
|
||||
output: stringOrFunctionSchema,
|
||||
subdir: z.boolean().optional(),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
overwrite: booleanOrFunctionSchema.optional(),
|
||||
logLevel: logLevelSchema.optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
helpers: z.record(z.string(), functionSchema).optional(),
|
||||
subdirHelper: z.string().optional(),
|
||||
inputs: z.record(z.string(), scaffoldInputSchema).optional(),
|
||||
beforeWrite: functionSchema.optional(),
|
||||
afterScaffold: stringOrFunctionSchema.optional(),
|
||||
tmpDir: z.string().optional(),
|
||||
})
|
||||
.check((ctx) => {
|
||||
const config = ctx.value
|
||||
|
||||
if (config.subdirHelper && !config.subdir) {
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
message: "subdirHelper is set but subdir is not enabled",
|
||||
path: ["subdirHelper"],
|
||||
input: config,
|
||||
})
|
||||
}
|
||||
|
||||
if (config.inputs) {
|
||||
for (const [key, val] of Object.entries(config.inputs)) {
|
||||
for (const issue of validateInputSemantics(key, val)) {
|
||||
ctx.issues.push({ code: "custom", ...issue, input: config })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export {
|
||||
scaffoldConfigSchema,
|
||||
scaffoldInputSchema,
|
||||
functionSchema,
|
||||
stringOrFunctionSchema,
|
||||
booleanOrFunctionSchema,
|
||||
selectOptionSchema,
|
||||
inputTypeSchema,
|
||||
logLevelSchema,
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a scaffold config and returns a list of human-readable errors.
|
||||
* Returns an empty array if the config is valid.
|
||||
*/
|
||||
export function validateConfig(config: unknown): string[] {
|
||||
const result = scaffoldConfigSchema.safeParse(config)
|
||||
if (result.success) {
|
||||
return []
|
||||
}
|
||||
return result.error.issues.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"
|
||||
return `${path}: ${issue.message}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates template paths exist on disk.
|
||||
* Only checks non-glob, non-negation paths.
|
||||
*/
|
||||
export async function validateTemplatePaths(templates: string[]): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
for (const tpl of templates) {
|
||||
if (tpl.startsWith("!") || tpl.includes("*")) continue
|
||||
if (!(await pathExists(tpl))) {
|
||||
errors.push(`templates: path does not exist: ${tpl}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the config and throws a formatted error if any issues are found.
|
||||
* Checks both schema validity and template path existence.
|
||||
*/
|
||||
export async function assertConfigValid(config: unknown): Promise<void> {
|
||||
const schemaErrors = validateConfig(config)
|
||||
|
||||
const pathErrors =
|
||||
config &&
|
||||
typeof config === "object" &&
|
||||
"templates" in config &&
|
||||
Array.isArray((config as { templates: unknown }).templates)
|
||||
? await validateTemplatePaths((config as { templates: string[] }).templates)
|
||||
: []
|
||||
|
||||
const allErrors = [...schemaErrors, ...pathErrors]
|
||||
if (allErrors.length > 0) {
|
||||
const lines = allErrors.map((e) => ` - ${e}`)
|
||||
throw new Error(`Invalid scaffold config:\n${lines.join("\n")}`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
@@ -53,23 +55,75 @@ describe("config", () => {
|
||||
})
|
||||
|
||||
test("works with quotes", () => {
|
||||
expect(parseAppendData('key="value test"', blankCliConf)).toEqual({ key: "value test", name: "test" })
|
||||
expect(parseAppendData('key="value test"', blankCliConf)).toEqual({
|
||||
key: "value test",
|
||||
name: "test",
|
||||
})
|
||||
})
|
||||
|
||||
test("handles JSON array values with :=", () => {
|
||||
expect(parseAppendData('items:=["a","b"]', blankCliConf)).toEqual({
|
||||
items: ["a", "b"],
|
||||
name: "test",
|
||||
})
|
||||
})
|
||||
|
||||
test("handles JSON boolean with :=", () => {
|
||||
expect(parseAppendData("flag:=true", blankCliConf)).toEqual({ flag: true, name: "test" })
|
||||
expect(parseAppendData("flag:=false", blankCliConf)).toEqual({ flag: false, name: "test" })
|
||||
})
|
||||
|
||||
test("handles JSON null with :=", () => {
|
||||
expect(parseAppendData("val:=null", blankCliConf)).toEqual({ val: null, name: "test" })
|
||||
})
|
||||
|
||||
test("handles JSON object with :=", () => {
|
||||
expect(parseAppendData('obj:={"a":1}', blankCliConf)).toEqual({ obj: { a: 1 }, name: "test" })
|
||||
})
|
||||
|
||||
test("handles single quoted values", () => {
|
||||
expect(parseAppendData("key='value test'", blankCliConf)).toEqual({
|
||||
key: "value test",
|
||||
name: "test",
|
||||
})
|
||||
})
|
||||
|
||||
test("handles empty string value", () => {
|
||||
expect(parseAppendData("key=", blankCliConf)).toEqual({ key: "", name: "test" })
|
||||
})
|
||||
|
||||
test("handles negative number with :=", () => {
|
||||
expect(parseAppendData("num:=-42", blankCliConf)).toEqual({ num: -42, name: "test" })
|
||||
})
|
||||
|
||||
test("handles float with :=", () => {
|
||||
expect(parseAppendData("num:=3.14", blankCliConf)).toEqual({ num: 3.14, name: "test" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("githubPartToUrl", () => {
|
||||
test("works", () => {
|
||||
expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual("https://github.com/chenasraf/simple-scaffold.git")
|
||||
expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual(
|
||||
"https://github.com/chenasraf/simple-scaffold.git",
|
||||
)
|
||||
expect(githubPartToUrl("chenasraf/simple-scaffold.git")).toEqual(
|
||||
"https://github.com/chenasraf/simple-scaffold.git",
|
||||
)
|
||||
})
|
||||
|
||||
test("handles organization repos", () => {
|
||||
expect(githubPartToUrl("org/sub-repo")).toEqual("https://github.com/org/sub-repo.git")
|
||||
})
|
||||
|
||||
test("handles repos with dots in name", () => {
|
||||
expect(githubPartToUrl("user/my.repo")).toEqual("https://github.com/user/my.repo.git")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseConfigFile", () => {
|
||||
test("normal config does not change", async () => {
|
||||
const tmpDir = `/tmp/scaffold-config-${Date.now()}`
|
||||
const { quiet, tmpDir: _tmpDir, version, ...conf } = blankCliConf
|
||||
const { quiet: _, tmpDir: _tmpDir, version: __, ...conf } = blankCliConf
|
||||
expect(
|
||||
await parseConfigFile({
|
||||
...blankCliConf,
|
||||
@@ -78,6 +132,7 @@ describe("config", () => {
|
||||
}),
|
||||
).toEqual({ ...conf, name: "-", tmpDir, subdirHelper: undefined, beforeWrite: undefined })
|
||||
})
|
||||
|
||||
describe("appendData", () => {
|
||||
test("appends", async () => {
|
||||
const result = await parseConfigFile({
|
||||
@@ -88,6 +143,7 @@ describe("config", () => {
|
||||
})
|
||||
expect(result?.data?.key).toEqual("value")
|
||||
})
|
||||
|
||||
test("overwrites existing value", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
@@ -98,6 +154,126 @@ describe("config", () => {
|
||||
})
|
||||
expect(result?.data?.num).toEqual("1234")
|
||||
})
|
||||
|
||||
test("CLI output overrides config file output", async () => {
|
||||
const tmpDir = `/tmp/scaffold-config-${Date.now()}`
|
||||
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
config: path.resolve(__dirname, "test-config.js"),
|
||||
key: "component",
|
||||
output: "examples/test-output/override",
|
||||
name: "Component",
|
||||
tmpDir,
|
||||
})
|
||||
|
||||
expect(result.output).toEqual("examples/test-output/override")
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when name is missing", async () => {
|
||||
await expect(
|
||||
parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "",
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
}),
|
||||
).rejects.toThrow("Missing required option: name")
|
||||
})
|
||||
|
||||
test("preserves dryRun setting", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
dryRun: true,
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.dryRun).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves subdir setting", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
subdir: true,
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.subdir).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves overwrite setting", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
overwrite: true,
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.overwrite).toBe(true)
|
||||
})
|
||||
|
||||
test("merges data from config and appendData", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
data: { key1: "val1" },
|
||||
appendData: { key2: "val2" },
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.data).toEqual({ key1: "val1", key2: "val2" })
|
||||
})
|
||||
|
||||
test("appendData overrides data", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
data: { key: "original" },
|
||||
appendData: { key: "overridden" },
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.data?.key).toEqual("overridden")
|
||||
})
|
||||
|
||||
test("sets subdirHelper from config", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
subdirHelper: "pascalCase",
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.subdirHelper).toEqual("pascalCase")
|
||||
})
|
||||
|
||||
test("handles empty templates array", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
templates: [],
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.templates).toEqual([])
|
||||
})
|
||||
|
||||
test("throws when config key not found", async () => {
|
||||
await expect(
|
||||
parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "test",
|
||||
config: path.resolve(__dirname, "test-config.js"),
|
||||
key: "nonexistent",
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
}),
|
||||
).rejects.toThrow('Template "nonexistent" not found')
|
||||
})
|
||||
|
||||
test("uses default key when key not specified", async () => {
|
||||
const result = await parseConfigFile({
|
||||
...blankCliConf,
|
||||
name: "MyComponent",
|
||||
templates: undefined as unknown as string[],
|
||||
config: path.resolve(__dirname, "test-config.js"),
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
})
|
||||
expect(result.templates.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,14 +290,68 @@ describe("config", () => {
|
||||
|
||||
test("gets local file config", async () => {
|
||||
const resultFn = await config.getLocalConfig({
|
||||
config: "scaffold.config.js",
|
||||
config: path.join(__dirname, "test-config.js"),
|
||||
logLevel: LogLevel.none,
|
||||
})
|
||||
const result = await resolve(resultFn, {} as any)
|
||||
const result = (await resolve(resultFn, {} as ScaffoldCmdConfig)).default
|
||||
expect(result).toEqual(configFile)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRemoteConfig", () => {
|
||||
test("throws for unsupported protocol", async () => {
|
||||
await expect(
|
||||
config.getRemoteConfig({
|
||||
git: "ftp://example.com/repo.git",
|
||||
logLevel: LogLevel.none,
|
||||
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
|
||||
}),
|
||||
).rejects.toThrow("Unsupported protocol")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOptionValueForFile", () => {
|
||||
const conf: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "output",
|
||||
templates: [],
|
||||
logLevel: LogLevel.none,
|
||||
data: { name: "test" },
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
registerHelpers(conf)
|
||||
})
|
||||
|
||||
test("returns static string value", () => {
|
||||
expect(getOptionValueForFile(conf, "/some/path", "static-value")).toEqual("static-value")
|
||||
})
|
||||
|
||||
test("returns static boolean value", () => {
|
||||
expect(getOptionValueForFile(conf, "/some/path", true)).toBe(true)
|
||||
expect(getOptionValueForFile(conf, "/some/path", false)).toBe(false)
|
||||
})
|
||||
|
||||
test("calls function with file path info", () => {
|
||||
const fn = vi.fn().mockReturnValue("custom-output")
|
||||
const result = getOptionValueForFile(conf, "/home/user/file.txt", fn)
|
||||
expect(result).toEqual("custom-output")
|
||||
expect(fn).toHaveBeenCalledWith("/home/user/file.txt", expect.any(String), expect.any(String))
|
||||
})
|
||||
|
||||
test("returns default value when fn is not a function and no value", () => {
|
||||
expect(
|
||||
getOptionValueForFile(conf, "/some/path", undefined as unknown as string, "default"),
|
||||
).toEqual("default")
|
||||
})
|
||||
|
||||
test("function receives parsed basename", () => {
|
||||
const fn = (_fullPath: string, _basedir: string, basename: string) => basename
|
||||
const result = getOptionValueForFile(conf, "/home/user/{{name}}.txt", fn)
|
||||
expect(result).toEqual("test.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("findConfigFile", () => {
|
||||
const struct1 = {
|
||||
"scaffold.config.js": `module.exports = '${JSON.stringify(blankConfig)}'`,
|
||||
@@ -136,7 +366,7 @@ describe("config", () => {
|
||||
"scaffold.json": JSON.stringify(blankConfig),
|
||||
}
|
||||
|
||||
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction {
|
||||
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
|
||||
return () => {
|
||||
beforeEach(() => {
|
||||
// console.log("Mocking:", fileStruct)
|
||||
@@ -157,12 +387,153 @@ describe("config", () => {
|
||||
|
||||
for (const struct of [struct1, struct2, struct3, struct4]) {
|
||||
const [k] = Object.keys(struct)
|
||||
describe(`finds config file ${k}`, () => {
|
||||
withMock(struct, async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual(k)
|
||||
})
|
||||
})
|
||||
|
||||
describe(
|
||||
`finds config file ${k}`,
|
||||
withMock(struct, () => {
|
||||
test(`finds ${k}`, async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual(k)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe(
|
||||
"finds .mjs config file",
|
||||
withMock({ "scaffold.config.mjs": "export default {}" }, () => {
|
||||
test("finds scaffold.config.mjs", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.config.mjs")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"priority order",
|
||||
withMock(
|
||||
{
|
||||
"scaffold.config.js": "module.exports = {}",
|
||||
"scaffold.js": "module.exports = {}",
|
||||
},
|
||||
() => {
|
||||
test("prefers scaffold.config.js over scaffold.js", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.config.js")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"throws when no config found",
|
||||
withMock({ "unrelated-file.txt": "content" }, () => {
|
||||
test("throws error when no config file exists", async () => {
|
||||
await expect(findConfigFile(process.cwd())).rejects.toThrow("Could not find config file")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds scaffold.config.cjs",
|
||||
withMock({ "scaffold.config.cjs": "module.exports = {}" }, () => {
|
||||
test("finds .cjs config file", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.config.cjs")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds scaffold.config.json",
|
||||
withMock({ "scaffold.config.json": "{}" }, () => {
|
||||
test("finds .json config file", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.config.json")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds scaffold.mjs",
|
||||
withMock({ "scaffold.mjs": "export default {}" }, () => {
|
||||
test("finds scaffold.mjs", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.mjs")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds scaffold.cjs",
|
||||
withMock({ "scaffold.cjs": "module.exports = {}" }, () => {
|
||||
test("finds scaffold.cjs", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.cjs")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds scaffold.json",
|
||||
withMock({ "scaffold.json": "{}" }, () => {
|
||||
test("finds scaffold.json", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.json")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds .scaffold.js",
|
||||
withMock({ ".scaffold.js": "module.exports = {}" }, () => {
|
||||
test("finds dotfile config", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual(".scaffold.js")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"finds .scaffold.json",
|
||||
withMock({ ".scaffold.json": "{}" }, () => {
|
||||
test("finds dotfile json config", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual(".scaffold.json")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"prefers scaffold.config over .scaffold",
|
||||
withMock(
|
||||
{
|
||||
"scaffold.config.js": "module.exports = {}",
|
||||
".scaffold.js": "module.exports = {}",
|
||||
},
|
||||
() => {
|
||||
test("prefers scaffold.config.js over .scaffold.js", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.config.js")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"prefers scaffold over .scaffold",
|
||||
withMock(
|
||||
{
|
||||
"scaffold.js": "module.exports = {}",
|
||||
".scaffold.js": "module.exports = {}",
|
||||
},
|
||||
() => {
|
||||
test("prefers scaffold.js over .scaffold.js", async () => {
|
||||
const result = await findConfigFile(process.cwd())
|
||||
expect(result).toEqual("scaffold.js")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
456
tests/file.test.ts
Normal file
456
tests/file.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest"
|
||||
import mockFs from "mock-fs"
|
||||
import FileSystem from "mock-fs/lib/filesystem"
|
||||
import { Console } from "console"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import {
|
||||
removeGlob,
|
||||
makeRelativePath,
|
||||
getBasePath,
|
||||
getOutputDir,
|
||||
getUniqueTmpPath,
|
||||
pathExists,
|
||||
createDirIfNotExists,
|
||||
getTemplateGlobInfo,
|
||||
getTemplateFileInfo,
|
||||
copyFileTransformed,
|
||||
handleTemplateFile,
|
||||
getFileList,
|
||||
} from "../src/file"
|
||||
import { ScaffoldConfig, LogLevel } from "../src/types"
|
||||
import { registerHelpers } from "../src/parser"
|
||||
import { readFileSync } from "fs"
|
||||
|
||||
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
|
||||
return () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
mockFs(fileStruct)
|
||||
})
|
||||
testFn()
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const baseConfig: ScaffoldConfig = {
|
||||
name: "test_app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: LogLevel.none,
|
||||
data: { name: "test_app" },
|
||||
tmpDir: ".",
|
||||
}
|
||||
|
||||
describe("file utilities", () => {
|
||||
describe("removeGlob", () => {
|
||||
test("removes single wildcard", () => {
|
||||
expect(removeGlob("input/*")).toEqual(path.normalize("input/"))
|
||||
})
|
||||
|
||||
test("removes double wildcard", () => {
|
||||
expect(removeGlob("input/**/*")).toEqual(path.normalize("input///"))
|
||||
})
|
||||
|
||||
test("returns path unchanged when no glob", () => {
|
||||
expect(removeGlob("input/file.txt")).toEqual(path.normalize("input/file.txt"))
|
||||
})
|
||||
|
||||
test("removes wildcards from nested path", () => {
|
||||
expect(removeGlob("a/b/*/c/**")).toEqual(path.normalize("a/b//c//"))
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(removeGlob("")).toEqual(".")
|
||||
})
|
||||
})
|
||||
|
||||
describe("makeRelativePath", () => {
|
||||
test("removes leading separator", () => {
|
||||
expect(makeRelativePath(path.sep + "some/path")).toEqual("some/path")
|
||||
})
|
||||
|
||||
test("returns path unchanged if no leading separator", () => {
|
||||
expect(makeRelativePath("some/path")).toEqual("some/path")
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(makeRelativePath("")).toEqual("")
|
||||
})
|
||||
|
||||
test("removes only the first separator", () => {
|
||||
expect(makeRelativePath(path.sep + "a" + path.sep + "b")).toEqual("a" + path.sep + "b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getBasePath", () => {
|
||||
test("resolves relative path against cwd", () => {
|
||||
const result = getBasePath("some/path")
|
||||
expect(result).toEqual("some/path")
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = getBasePath("")
|
||||
expect(result).toEqual("")
|
||||
})
|
||||
|
||||
test("handles current directory", () => {
|
||||
const result = getBasePath(".")
|
||||
expect(result).toEqual("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOutputDir", () => {
|
||||
test("returns output path without subdir", () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, subdir: false }
|
||||
registerHelpers(config)
|
||||
const result = getOutputDir(config, "output", "")
|
||||
expect(result).toEqual(path.resolve(process.cwd(), "output"))
|
||||
})
|
||||
|
||||
test("returns output path with subdir", () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, subdir: true }
|
||||
registerHelpers(config)
|
||||
const result = getOutputDir(config, "output", "")
|
||||
expect(result).toEqual(path.resolve(process.cwd(), "output", "test_app"))
|
||||
})
|
||||
|
||||
test("applies subdirHelper to subdir name", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...baseConfig,
|
||||
subdir: true,
|
||||
subdirHelper: "pascalCase",
|
||||
}
|
||||
registerHelpers(config)
|
||||
const result = getOutputDir(config, "output", "")
|
||||
expect(result).toEqual(path.resolve(process.cwd(), "output", "TestApp"))
|
||||
})
|
||||
|
||||
test("includes basePath in output", () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, subdir: false }
|
||||
registerHelpers(config)
|
||||
const result = getOutputDir(config, "output", "nested/dir")
|
||||
expect(result).toEqual(path.resolve(process.cwd(), "output", "nested/dir"))
|
||||
})
|
||||
|
||||
test("combines output, basePath, and subdir", () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, subdir: true }
|
||||
registerHelpers(config)
|
||||
const result = getOutputDir(config, "output", "nested")
|
||||
expect(result).toEqual(path.resolve(process.cwd(), "output", "nested", "test_app"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUniqueTmpPath", () => {
|
||||
test("returns a path in os temp directory", () => {
|
||||
const result = getUniqueTmpPath()
|
||||
expect(result.startsWith(os.tmpdir())).toBe(true)
|
||||
})
|
||||
|
||||
test("includes scaffold-config prefix", () => {
|
||||
const result = getUniqueTmpPath()
|
||||
expect(path.basename(result)).toMatch(/^scaffold-config-/)
|
||||
})
|
||||
|
||||
test("generates unique paths", () => {
|
||||
const a = getUniqueTmpPath()
|
||||
const b = getUniqueTmpPath()
|
||||
expect(a).not.toEqual(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe(
|
||||
"pathExists",
|
||||
withMock(
|
||||
{
|
||||
"existing-file.txt": "content",
|
||||
"existing-dir": {},
|
||||
},
|
||||
() => {
|
||||
test("returns true for existing file", async () => {
|
||||
expect(await pathExists("existing-file.txt")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for existing directory", async () => {
|
||||
expect(await pathExists("existing-dir")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for non-existing path", async () => {
|
||||
expect(await pathExists("non-existing")).toBe(false)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"createDirIfNotExists",
|
||||
withMock({}, () => {
|
||||
test("creates directory", async () => {
|
||||
await createDirIfNotExists("new-dir", { logLevel: LogLevel.none, dryRun: false })
|
||||
expect(await pathExists("new-dir")).toBe(true)
|
||||
})
|
||||
|
||||
test("creates nested directories recursively", async () => {
|
||||
await createDirIfNotExists("a/b/c", { logLevel: LogLevel.none, dryRun: false })
|
||||
expect(await pathExists("a/b/c")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not create directory in dry run mode", async () => {
|
||||
await createDirIfNotExists("dry-dir", { logLevel: LogLevel.none, dryRun: true })
|
||||
expect(await pathExists("dry-dir")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not throw if directory already exists", async () => {
|
||||
await createDirIfNotExists("existing", { logLevel: LogLevel.none, dryRun: false })
|
||||
await expect(
|
||||
createDirIfNotExists("existing", { logLevel: LogLevel.none, dryRun: false }),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"getTemplateGlobInfo",
|
||||
withMock(
|
||||
{
|
||||
"template-dir": {
|
||||
"file1.txt": "content1",
|
||||
"file2.txt": "content2",
|
||||
},
|
||||
"single-file.txt": "content",
|
||||
},
|
||||
() => {
|
||||
test("detects directory template", async () => {
|
||||
const result = await getTemplateGlobInfo(baseConfig, "template-dir")
|
||||
expect(result.isDirOrGlob).toBe(true)
|
||||
expect(result.isGlob).toBe(false)
|
||||
expect(result.template).toEqual(path.join("template-dir", "**", "*"))
|
||||
})
|
||||
|
||||
test("detects glob template", async () => {
|
||||
const result = await getTemplateGlobInfo(baseConfig, "template-dir/**/*.txt")
|
||||
expect(result.isDirOrGlob).toBe(true)
|
||||
expect(result.isGlob).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves non-glob single file", async () => {
|
||||
const result = await getTemplateGlobInfo(baseConfig, "single-file.txt")
|
||||
expect(result.isDirOrGlob).toBe(false)
|
||||
expect(result.isGlob).toBe(false)
|
||||
expect(result.template).toEqual("single-file.txt")
|
||||
})
|
||||
|
||||
test("stores original template", async () => {
|
||||
const result = await getTemplateGlobInfo(baseConfig, "template-dir")
|
||||
expect(result.origTemplate).toEqual("template-dir")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"getFileList",
|
||||
withMock(
|
||||
{
|
||||
templates: {
|
||||
"file1.txt": "content1",
|
||||
"file2.js": "content2",
|
||||
".hidden": "hidden content",
|
||||
nested: {
|
||||
"file3.txt": "content3",
|
||||
},
|
||||
},
|
||||
},
|
||||
() => {
|
||||
test("lists all files with glob", async () => {
|
||||
const files = await getFileList(baseConfig, ["templates/**/*"])
|
||||
expect(files.length).toBe(4)
|
||||
})
|
||||
|
||||
test("includes dotfiles", async () => {
|
||||
const files = await getFileList(baseConfig, ["templates/**/*"])
|
||||
expect(files.some((f) => f.includes(".hidden"))).toBe(true)
|
||||
})
|
||||
|
||||
test("filters by extension", async () => {
|
||||
const files = await getFileList(baseConfig, ["templates/**/*.txt"])
|
||||
expect(files.length).toBe(2)
|
||||
expect(files.every((f) => f.endsWith(".txt"))).toBe(true)
|
||||
})
|
||||
|
||||
test("supports exclusion patterns", async () => {
|
||||
const files = await getFileList(baseConfig, ["templates/**/*.txt"])
|
||||
expect(files.some((f) => f.includes(".hidden"))).toBe(false)
|
||||
expect(files.every((f) => f.endsWith(".txt"))).toBe(true)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"getTemplateFileInfo",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"{{name}}.txt": "Hello {{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("calculates correct output path", async () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
|
||||
registerHelpers(config)
|
||||
const info = await getTemplateFileInfo(config, {
|
||||
templatePath: "input/{{name}}.txt",
|
||||
basePath: "",
|
||||
})
|
||||
expect(info.inputPath).toEqual(path.resolve(process.cwd(), "input/{{name}}.txt"))
|
||||
expect(info.outputPath).toContain("test_app.txt")
|
||||
expect(info.exists).toBe(false)
|
||||
})
|
||||
|
||||
test("detects existing output file", async () => {
|
||||
mockFs.restore()
|
||||
mockFs({
|
||||
input: { "{{name}}.txt": "Hello {{name}}" },
|
||||
output: { "test_app.txt": "existing" },
|
||||
})
|
||||
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
|
||||
registerHelpers(config)
|
||||
const info = await getTemplateFileInfo(config, {
|
||||
templatePath: "input/{{name}}.txt",
|
||||
basePath: "",
|
||||
})
|
||||
expect(info.exists).toBe(true)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"copyFileTransformed",
|
||||
withMock(
|
||||
{
|
||||
"input.txt": "Hello {{name}}",
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("writes transformed content", async () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig }
|
||||
registerHelpers(config)
|
||||
await createDirIfNotExists("output", { logLevel: LogLevel.none, dryRun: false })
|
||||
await copyFileTransformed(config, {
|
||||
exists: false,
|
||||
overwrite: false,
|
||||
outputPath: "output/result.txt",
|
||||
inputPath: "input.txt",
|
||||
})
|
||||
const content = readFileSync("output/result.txt").toString()
|
||||
expect(content).toEqual("Hello test_app")
|
||||
})
|
||||
|
||||
test("does not write in dry run mode", async () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, dryRun: true }
|
||||
registerHelpers(config)
|
||||
await copyFileTransformed(config, {
|
||||
exists: false,
|
||||
overwrite: false,
|
||||
outputPath: "output/result.txt",
|
||||
inputPath: "input.txt",
|
||||
})
|
||||
expect(await pathExists("output/result.txt")).toBe(false)
|
||||
})
|
||||
|
||||
test("skips existing file without overwrite", async () => {
|
||||
mockFs.restore()
|
||||
mockFs({
|
||||
"input.txt": "Hello {{name}}",
|
||||
output: { "result.txt": "original" },
|
||||
})
|
||||
const config: ScaffoldConfig = { ...baseConfig }
|
||||
registerHelpers(config)
|
||||
await copyFileTransformed(config, {
|
||||
exists: true,
|
||||
overwrite: false,
|
||||
outputPath: "output/result.txt",
|
||||
inputPath: "input.txt",
|
||||
})
|
||||
const content = readFileSync("output/result.txt").toString()
|
||||
expect(content).toEqual("original")
|
||||
})
|
||||
|
||||
test("overwrites existing file with overwrite flag", async () => {
|
||||
mockFs.restore()
|
||||
mockFs({
|
||||
"input.txt": "Hello {{name}}",
|
||||
output: { "result.txt": "original" },
|
||||
})
|
||||
const config: ScaffoldConfig = { ...baseConfig }
|
||||
registerHelpers(config)
|
||||
await copyFileTransformed(config, {
|
||||
exists: true,
|
||||
overwrite: true,
|
||||
outputPath: "output/result.txt",
|
||||
inputPath: "input.txt",
|
||||
})
|
||||
const content = readFileSync("output/result.txt").toString()
|
||||
expect(content).toEqual("Hello test_app")
|
||||
})
|
||||
|
||||
test("calls beforeWrite callback", async () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...baseConfig,
|
||||
beforeWrite: (content) => content.toString().toUpperCase(),
|
||||
}
|
||||
registerHelpers(config)
|
||||
await createDirIfNotExists("output", { logLevel: LogLevel.none, dryRun: false })
|
||||
await copyFileTransformed(config, {
|
||||
exists: false,
|
||||
overwrite: false,
|
||||
outputPath: "output/result.txt",
|
||||
inputPath: "input.txt",
|
||||
})
|
||||
const content = readFileSync("output/result.txt").toString()
|
||||
expect(content).toEqual("HELLO TEST_APP")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"handleTemplateFile",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"{{name}}.txt": "Content for {{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("processes template file end to end", async () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
|
||||
registerHelpers(config)
|
||||
await handleTemplateFile(config, {
|
||||
templatePath: "input/{{name}}.txt",
|
||||
basePath: "",
|
||||
})
|
||||
const content = readFileSync(path.join("output", "test_app.txt")).toString()
|
||||
expect(content).toEqual("Content for test_app")
|
||||
})
|
||||
|
||||
test("throws for non-existing template", async () => {
|
||||
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
|
||||
registerHelpers(config)
|
||||
await expect(
|
||||
handleTemplateFile(config, {
|
||||
templatePath: "non-existing.txt",
|
||||
basePath: "",
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
122
tests/ignore.test.ts
Normal file
122
tests/ignore.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest"
|
||||
import mockFs from "mock-fs"
|
||||
import { Console } from "console"
|
||||
import path from "node:path"
|
||||
import { parseIgnoreFile, loadIgnorePatterns, filterIgnoredFiles } from "../src/ignore"
|
||||
|
||||
describe("ignore", () => {
|
||||
describe("parseIgnoreFile", () => {
|
||||
test("parses patterns", () => {
|
||||
const result = parseIgnoreFile("node_modules\n*.log\n")
|
||||
expect(result).toEqual(["node_modules", "*.log"])
|
||||
})
|
||||
|
||||
test("skips comments", () => {
|
||||
const result = parseIgnoreFile("# comment\nfoo\n# another\nbar")
|
||||
expect(result).toEqual(["foo", "bar"])
|
||||
})
|
||||
|
||||
test("skips empty lines", () => {
|
||||
const result = parseIgnoreFile("foo\n\n\nbar\n")
|
||||
expect(result).toEqual(["foo", "bar"])
|
||||
})
|
||||
|
||||
test("trims whitespace", () => {
|
||||
const result = parseIgnoreFile(" foo \n bar ")
|
||||
expect(result).toEqual(["foo", "bar"])
|
||||
})
|
||||
|
||||
test("handles empty file", () => {
|
||||
expect(parseIgnoreFile("")).toEqual([])
|
||||
})
|
||||
|
||||
test("handles comments-only file", () => {
|
||||
expect(parseIgnoreFile("# just comments\n# nothing here")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterIgnoredFiles", () => {
|
||||
test("filters files matching patterns", () => {
|
||||
const files = ["templates/file.txt", "templates/debug.log", "templates/other.js"]
|
||||
const result = filterIgnoredFiles(files, ["*.log"], "templates")
|
||||
expect(result).toEqual(["templates/file.txt", "templates/other.js"])
|
||||
})
|
||||
|
||||
test("always excludes .scaffoldignore", () => {
|
||||
const files = ["templates/file.txt", "templates/.scaffoldignore"]
|
||||
const result = filterIgnoredFiles(files, [], "templates")
|
||||
expect(result).toEqual(["templates/file.txt"])
|
||||
})
|
||||
|
||||
test("matches by relative path", () => {
|
||||
const files = ["templates/src/index.ts", "templates/dist/index.js", "templates/src/utils.ts"]
|
||||
const result = filterIgnoredFiles(files, ["dist/**"], "templates")
|
||||
expect(result).toEqual(["templates/src/index.ts", "templates/src/utils.ts"])
|
||||
})
|
||||
|
||||
test("matches by basename", () => {
|
||||
const files = ["templates/README.md", "templates/nested/README.md", "templates/file.txt"]
|
||||
const result = filterIgnoredFiles(files, ["README.md"], "templates")
|
||||
expect(result).toEqual(["templates/file.txt"])
|
||||
})
|
||||
|
||||
test("handles multiple patterns", () => {
|
||||
const files = ["tpl/a.txt", "tpl/b.log", "tpl/c.tmp", "tpl/d.ts"]
|
||||
const result = filterIgnoredFiles(files, ["*.log", "*.tmp"], "tpl")
|
||||
expect(result).toEqual(["tpl/a.txt", "tpl/d.ts"])
|
||||
})
|
||||
|
||||
test("returns all files when no patterns", () => {
|
||||
const files = ["tpl/a.txt", "tpl/b.txt"]
|
||||
const result = filterIgnoredFiles(files, [], "tpl")
|
||||
expect(result).toEqual(files)
|
||||
})
|
||||
|
||||
test("handles glob patterns with directories", () => {
|
||||
const files = [
|
||||
path.join("tpl", "src", "index.ts"),
|
||||
path.join("tpl", "node_modules", "pkg", "index.js"),
|
||||
path.join("tpl", "file.txt"),
|
||||
]
|
||||
const result = filterIgnoredFiles(files, ["node_modules/**"], "tpl")
|
||||
expect(result).toEqual([path.join("tpl", "src", "index.ts"), path.join("tpl", "file.txt")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadIgnorePatterns", () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
test("returns empty array when no .scaffoldignore exists", async () => {
|
||||
mockFs({ templates: { "file.txt": "content" } })
|
||||
const result = await loadIgnorePatterns("templates")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("reads and parses .scaffoldignore", async () => {
|
||||
mockFs({
|
||||
templates: {
|
||||
".scaffoldignore": "*.log\nnode_modules\n",
|
||||
"file.txt": "content",
|
||||
},
|
||||
})
|
||||
const result = await loadIgnorePatterns("templates")
|
||||
expect(result).toEqual(["*.log", "node_modules"])
|
||||
})
|
||||
|
||||
test("ignores comments in .scaffoldignore", async () => {
|
||||
mockFs({
|
||||
templates: {
|
||||
".scaffoldignore": "# This is a comment\n*.tmp\n",
|
||||
},
|
||||
})
|
||||
const result = await loadIgnorePatterns("templates")
|
||||
expect(result).toEqual(["*.tmp"])
|
||||
})
|
||||
})
|
||||
})
|
||||
115
tests/init.test.ts
Normal file
115
tests/init.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import mockFs from "mock-fs"
|
||||
import { Console } from "console"
|
||||
import { readFileSync, existsSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
vi.mock("@inquirer/input", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@inquirer/select", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
import selectMock from "@inquirer/select"
|
||||
import { initScaffold } from "../src/init"
|
||||
|
||||
describe("init", () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
vi.clearAllMocks()
|
||||
mockFs({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
test("creates js config and example template", async () => {
|
||||
await initScaffold({ format: "js", dir: process.cwd() })
|
||||
|
||||
expect(existsSync("scaffold.config.js")).toBe(true)
|
||||
const config = readFileSync("scaffold.config.js", "utf-8")
|
||||
expect(config).toContain("module.exports")
|
||||
expect(config).toContain("templates/default")
|
||||
|
||||
expect(existsSync(path.join("templates", "default", "{{name}}.md"))).toBe(true)
|
||||
const template = readFileSync(path.join("templates", "default", "{{name}}.md"), "utf-8")
|
||||
expect(template).toContain("{{ name }}")
|
||||
})
|
||||
|
||||
test("creates mjs config", async () => {
|
||||
await initScaffold({ format: "mjs", dir: process.cwd() })
|
||||
|
||||
expect(existsSync("scaffold.config.mjs")).toBe(true)
|
||||
const config = readFileSync("scaffold.config.mjs", "utf-8")
|
||||
expect(config).toContain("export default")
|
||||
})
|
||||
|
||||
test("creates json config", async () => {
|
||||
await initScaffold({ format: "json", dir: process.cwd() })
|
||||
|
||||
expect(existsSync("scaffold.config.json")).toBe(true)
|
||||
const config = readFileSync("scaffold.config.json", "utf-8")
|
||||
const parsed = JSON.parse(config)
|
||||
expect(parsed.default).toBeDefined()
|
||||
expect(parsed.default.templates).toEqual(["templates/default"])
|
||||
})
|
||||
|
||||
test("does not overwrite existing config", async () => {
|
||||
mockFs.restore()
|
||||
mockFs({
|
||||
"scaffold.config.js": "// existing config",
|
||||
})
|
||||
|
||||
await initScaffold({ format: "js", dir: process.cwd() })
|
||||
|
||||
const config = readFileSync("scaffold.config.js", "utf-8")
|
||||
expect(config).toBe("// existing config")
|
||||
})
|
||||
|
||||
test("does not overwrite existing template dir", async () => {
|
||||
mockFs.restore()
|
||||
mockFs({
|
||||
templates: {
|
||||
default: {
|
||||
"existing.md": "# Existing",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await initScaffold({ format: "js", dir: process.cwd() })
|
||||
|
||||
expect(existsSync(path.join("templates", "default", "existing.md"))).toBe(true)
|
||||
expect(existsSync(path.join("templates", "default", "{{name}}.md"))).toBe(false)
|
||||
})
|
||||
|
||||
test("skips example template when createExample is false", async () => {
|
||||
await initScaffold({ format: "js", dir: process.cwd(), createExample: false })
|
||||
|
||||
expect(existsSync("scaffold.config.js")).toBe(true)
|
||||
expect(existsSync("templates")).toBe(false)
|
||||
})
|
||||
|
||||
test("prompts for format when not provided", async () => {
|
||||
vi.mocked(selectMock).mockResolvedValue("js")
|
||||
|
||||
await initScaffold({ dir: process.cwd() })
|
||||
|
||||
expect(selectMock).toHaveBeenCalledOnce()
|
||||
expect(existsSync("scaffold.config.js")).toBe(true)
|
||||
})
|
||||
|
||||
test("creates config in custom directory", async () => {
|
||||
mockFs.restore()
|
||||
mockFs({
|
||||
"my-project": {},
|
||||
})
|
||||
|
||||
await initScaffold({ format: "js", dir: path.resolve("my-project") })
|
||||
|
||||
expect(existsSync(path.join("my-project", "scaffold.config.js"))).toBe(true)
|
||||
expect(existsSync(path.join("my-project", "templates", "default", "{{name}}.md"))).toBe(true)
|
||||
})
|
||||
})
|
||||
175
tests/logger.test.ts
Normal file
175
tests/logger.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"
|
||||
import { log, logInitStep, logInputFile } from "../src/logger"
|
||||
import { LogLevel, ScaffoldConfig } from "../src/types"
|
||||
|
||||
describe("logger", () => {
|
||||
let consoleSpy: {
|
||||
log: MockInstance
|
||||
warn: MockInstance
|
||||
error: MockInstance
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => void 0),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => void 0),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => void 0),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.log.mockRestore()
|
||||
consoleSpy.warn.mockRestore()
|
||||
consoleSpy.error.mockRestore()
|
||||
})
|
||||
|
||||
describe("log", () => {
|
||||
test("does not log when logLevel is none", () => {
|
||||
log({ logLevel: LogLevel.none }, LogLevel.info, "test")
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("logs info messages with console.log", () => {
|
||||
log({ logLevel: LogLevel.info }, LogLevel.info, "test message")
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("logs warning messages with console.warn", () => {
|
||||
log({ logLevel: LogLevel.warning }, LogLevel.warning, "warning message")
|
||||
expect(consoleSpy.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("logs error messages with console.error", () => {
|
||||
log({ logLevel: LogLevel.error }, LogLevel.error, "error message")
|
||||
expect(consoleSpy.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("filters out messages below configured level", () => {
|
||||
log({ logLevel: LogLevel.warning }, LogLevel.info, "should be filtered")
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("filters out debug messages when level is info", () => {
|
||||
log({ logLevel: LogLevel.info }, LogLevel.debug, "debug message")
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("shows debug messages when level is debug", () => {
|
||||
log({ logLevel: LogLevel.debug }, LogLevel.debug, "debug message")
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("shows all levels when configured as debug", () => {
|
||||
log({ logLevel: LogLevel.debug }, LogLevel.debug, "d")
|
||||
log({ logLevel: LogLevel.debug }, LogLevel.info, "i")
|
||||
log({ logLevel: LogLevel.debug }, LogLevel.warning, "w")
|
||||
log({ logLevel: LogLevel.debug }, LogLevel.error, "e")
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(2) // debug + info
|
||||
expect(consoleSpy.warn).toHaveBeenCalledTimes(1)
|
||||
expect(consoleSpy.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("handles Error objects", () => {
|
||||
log({ logLevel: LogLevel.error }, LogLevel.error, new Error("test error"))
|
||||
expect(consoleSpy.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("handles objects with util.inspect", () => {
|
||||
log({ logLevel: LogLevel.info }, LogLevel.info, { key: "value" })
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("handles multiple arguments", () => {
|
||||
log({ logLevel: LogLevel.info }, LogLevel.info, "a", "b", "c")
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
// First call, should have 3 arguments
|
||||
expect(consoleSpy.log.mock.calls[0].length).toBe(3)
|
||||
})
|
||||
|
||||
test("defaults to info when logLevel is undefined", () => {
|
||||
log({ logLevel: undefined as unknown as LogLevel }, LogLevel.info, "test")
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("error level shows when logLevel is info", () => {
|
||||
log({ logLevel: LogLevel.info }, LogLevel.error, "error")
|
||||
expect(consoleSpy.error).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("warning level shows when logLevel is info", () => {
|
||||
log({ logLevel: LogLevel.info }, LogLevel.warning, "warning")
|
||||
expect(consoleSpy.warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logInitStep", () => {
|
||||
test("logs config at debug level", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: LogLevel.debug,
|
||||
data: { name: "test" },
|
||||
}
|
||||
logInitStep(config)
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not log at info level (debug only)", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: LogLevel.info,
|
||||
data: { name: "test" },
|
||||
}
|
||||
logInitStep(config)
|
||||
// Full config is debug-only, nothing logged at info
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logInputFile", () => {
|
||||
test("logs file info at debug level", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: LogLevel.debug,
|
||||
data: { name: "test" },
|
||||
}
|
||||
logInputFile(config, {
|
||||
originalTemplate: "input",
|
||||
relativePath: ".",
|
||||
parsedTemplate: "input/**/*",
|
||||
inputFilePath: "input/file.txt",
|
||||
nonGlobTemplate: "input",
|
||||
basePath: "",
|
||||
isDirOrGlob: true,
|
||||
isGlob: false,
|
||||
})
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not log at info level", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: LogLevel.info,
|
||||
data: { name: "test" },
|
||||
}
|
||||
logInputFile(config, {
|
||||
originalTemplate: "input",
|
||||
relativePath: ".",
|
||||
parsedTemplate: "input/**/*",
|
||||
inputFilePath: "input/file.txt",
|
||||
nonGlobTemplate: "input",
|
||||
basePath: "",
|
||||
isDirOrGlob: true,
|
||||
isGlob: false,
|
||||
})
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,35 +14,43 @@ 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", { 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", { asPath: true })).toEqual(
|
||||
Buffer.from("/home/test/test.txt"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("should not do path escaping on non-path compiles", async () => {
|
||||
expect(
|
||||
handlebarsParse(
|
||||
@@ -53,6 +62,88 @@ describe("parser", () => {
|
||||
),
|
||||
).toEqual(Buffer.from("/home/test/test {{escaped}}.txt"))
|
||||
})
|
||||
|
||||
test("should replace name token in content", () => {
|
||||
const result = handlebarsParse(blankConf, "Hello {{name}}")
|
||||
expect(result.toString()).toEqual("Hello test")
|
||||
})
|
||||
|
||||
test("should replace multiple tokens", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
data: { name: "app", version: "1.0" },
|
||||
}
|
||||
expect(handlebarsParse(config, "{{name}} v{{version}}").toString()).toEqual("app v1.0")
|
||||
})
|
||||
|
||||
test("should return Buffer", () => {
|
||||
expect(Buffer.isBuffer(handlebarsParse(blankConf, "test"))).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle Buffer input", () => {
|
||||
expect(handlebarsParse(blankConf, Buffer.from("Hello {{name}}")).toString()).toEqual("Hello test")
|
||||
})
|
||||
|
||||
test("should return original content on handlebars error", () => {
|
||||
const result = handlebarsParse(blankConf, "{{#if}}invalid{{/unless}}")
|
||||
expect(Buffer.isBuffer(result)).toBe(true)
|
||||
expect(result.toString()).toEqual("{{#if}}invalid{{/unless}}")
|
||||
})
|
||||
|
||||
test("should handle empty template", () => {
|
||||
expect(handlebarsParse(blankConf, "").toString()).toEqual("")
|
||||
})
|
||||
|
||||
test("should handle template with no tokens", () => {
|
||||
expect(handlebarsParse(blankConf, "no tokens here").toString()).toEqual("no tokens here")
|
||||
})
|
||||
|
||||
test("should not escape HTML chars (noEscape)", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
data: { name: "<div>test</div>" },
|
||||
}
|
||||
expect(handlebarsParse(config, "{{name}}").toString()).toEqual("<div>test</div>")
|
||||
})
|
||||
|
||||
test("should handle nested data", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
data: { name: "test", nested: { key: "value" } },
|
||||
}
|
||||
expect(handlebarsParse(config, "{{nested.key}}").toString()).toEqual("value")
|
||||
})
|
||||
|
||||
test("should handle handlebars conditionals", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
data: { name: "test", showExtra: true },
|
||||
}
|
||||
registerHelpers(config)
|
||||
expect(handlebarsParse(config, "{{#if showExtra}}extra{{/if}} content").toString()).toEqual("extra content")
|
||||
})
|
||||
|
||||
test("should handle handlebars conditionals when false", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
data: { name: "test", showExtra: false },
|
||||
}
|
||||
registerHelpers(config)
|
||||
expect(handlebarsParse(config, "{{#if showExtra}}extra{{/if}}content").toString()).toEqual("content")
|
||||
})
|
||||
|
||||
test("should handle handlebars each loops", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
data: { name: "test", items: ["a", "b", "c"] },
|
||||
}
|
||||
registerHelpers(config)
|
||||
expect(handlebarsParse(config, "{{#each items}}{{this}},{{/each}}").toString()).toEqual("a,b,c,")
|
||||
})
|
||||
|
||||
test("should render empty for undefined data token", () => {
|
||||
expect(handlebarsParse(blankConf, "{{undefinedVar}}").toString()).toEqual("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Helpers", () => {
|
||||
@@ -65,6 +156,7 @@ describe("parser", () => {
|
||||
expect(defaultHelpers.camelCase("TestString")).toEqual("testString")
|
||||
expect(defaultHelpers.camelCase("Test____String")).toEqual("testString")
|
||||
})
|
||||
|
||||
test("pascalCase", () => {
|
||||
expect(defaultHelpers.pascalCase("test string")).toEqual("TestString")
|
||||
expect(defaultHelpers.pascalCase("test_string")).toEqual("TestString")
|
||||
@@ -73,6 +165,7 @@ describe("parser", () => {
|
||||
expect(defaultHelpers.pascalCase("TestString")).toEqual("TestString")
|
||||
expect(defaultHelpers.pascalCase("Test____String")).toEqual("TestString")
|
||||
})
|
||||
|
||||
test("snakeCase", () => {
|
||||
expect(defaultHelpers.snakeCase("test string")).toEqual("test_string")
|
||||
expect(defaultHelpers.snakeCase("test_string")).toEqual("test_string")
|
||||
@@ -81,6 +174,7 @@ describe("parser", () => {
|
||||
expect(defaultHelpers.snakeCase("TestString")).toEqual("test_string")
|
||||
expect(defaultHelpers.snakeCase("Test____String")).toEqual("test_string")
|
||||
})
|
||||
|
||||
test("kebabCase", () => {
|
||||
expect(defaultHelpers.kebabCase("test string")).toEqual("test-string")
|
||||
expect(defaultHelpers.kebabCase("test_string")).toEqual("test-string")
|
||||
@@ -89,6 +183,7 @@ describe("parser", () => {
|
||||
expect(defaultHelpers.kebabCase("TestString")).toEqual("test-string")
|
||||
expect(defaultHelpers.kebabCase("Test____String")).toEqual("test-string")
|
||||
})
|
||||
|
||||
test("startCase", () => {
|
||||
expect(defaultHelpers.startCase("test string")).toEqual("Test String")
|
||||
expect(defaultHelpers.startCase("test_string")).toEqual("Test String")
|
||||
@@ -98,6 +193,92 @@ describe("parser", () => {
|
||||
expect(defaultHelpers.startCase("Test____String")).toEqual("Test String")
|
||||
})
|
||||
})
|
||||
|
||||
describe("string helpers edge cases", () => {
|
||||
test("camelCase single word", () => {
|
||||
expect(defaultHelpers.camelCase("hello")).toEqual("hello")
|
||||
})
|
||||
|
||||
test("camelCase empty string", () => {
|
||||
expect(defaultHelpers.camelCase("")).toEqual("")
|
||||
})
|
||||
|
||||
test("camelCase all uppercase", () => {
|
||||
expect(defaultHelpers.camelCase("HELLO WORLD")).toEqual("helloWorld")
|
||||
})
|
||||
|
||||
test("pascalCase single word", () => {
|
||||
expect(defaultHelpers.pascalCase("hello")).toEqual("Hello")
|
||||
})
|
||||
|
||||
test("pascalCase empty string", () => {
|
||||
expect(defaultHelpers.pascalCase("")).toEqual("")
|
||||
})
|
||||
|
||||
test("snakeCase single word", () => {
|
||||
expect(defaultHelpers.snakeCase("hello")).toEqual("hello")
|
||||
})
|
||||
|
||||
test("snakeCase empty string", () => {
|
||||
expect(defaultHelpers.snakeCase("")).toEqual("")
|
||||
})
|
||||
|
||||
test("kebabCase single word", () => {
|
||||
expect(defaultHelpers.kebabCase("hello")).toEqual("hello")
|
||||
})
|
||||
|
||||
test("kebabCase empty string", () => {
|
||||
expect(defaultHelpers.kebabCase("")).toEqual("")
|
||||
})
|
||||
|
||||
test("startCase single word", () => {
|
||||
expect(defaultHelpers.startCase("hello")).toEqual("Hello")
|
||||
})
|
||||
|
||||
test("startCase empty string", () => {
|
||||
expect(defaultHelpers.startCase("")).toEqual("")
|
||||
})
|
||||
|
||||
test("hyphenCase is same as kebabCase", () => {
|
||||
expect(defaultHelpers.hyphenCase("testString")).toEqual(defaultHelpers.kebabCase("testString"))
|
||||
expect(defaultHelpers.hyphenCase("test_string")).toEqual(defaultHelpers.kebabCase("test_string"))
|
||||
})
|
||||
|
||||
test("lowerCase lowercases everything", () => {
|
||||
expect(defaultHelpers.lowerCase("HELLO")).toEqual("hello")
|
||||
expect(defaultHelpers.lowerCase("Hello World")).toEqual("hello world")
|
||||
})
|
||||
|
||||
test("upperCase uppercases everything", () => {
|
||||
expect(defaultHelpers.upperCase("hello")).toEqual("HELLO")
|
||||
expect(defaultHelpers.upperCase("hello world")).toEqual("HELLO WORLD")
|
||||
})
|
||||
|
||||
test("camelCase handles numbers in string", () => {
|
||||
expect(defaultHelpers.camelCase("item1_name")).toEqual("item1Name")
|
||||
})
|
||||
|
||||
test("pascalCase handles multiple separators", () => {
|
||||
expect(defaultHelpers.pascalCase("a--b__c d")).toEqual("ABCD")
|
||||
})
|
||||
|
||||
test("snakeCase handles mixed separators", () => {
|
||||
expect(defaultHelpers.snakeCase("myApp-name_here")).toEqual("my_app_name_here")
|
||||
})
|
||||
|
||||
test("kebabCase handles mixed separators", () => {
|
||||
expect(defaultHelpers.kebabCase("myApp-name_here")).toEqual("my-app-name-here")
|
||||
})
|
||||
|
||||
test("single character inputs", () => {
|
||||
expect(defaultHelpers.camelCase("a")).toEqual("a")
|
||||
expect(defaultHelpers.pascalCase("a")).toEqual("A")
|
||||
expect(defaultHelpers.snakeCase("a")).toEqual("a")
|
||||
expect(defaultHelpers.kebabCase("a")).toEqual("a")
|
||||
expect(defaultHelpers.startCase("a")).toEqual("A")
|
||||
})
|
||||
})
|
||||
|
||||
describe("date helpers", () => {
|
||||
describe("now", () => {
|
||||
test("should work without extra params", () => {
|
||||
@@ -127,7 +308,122 @@ describe("parser", () => {
|
||||
dateFns.format(dateFns.add(now, { months: 1 }), fmt),
|
||||
)
|
||||
})
|
||||
|
||||
test("should work with years offset", () => {
|
||||
const dateStr = "2024-01-15T12:00:00.000Z"
|
||||
const date = dateFns.parseISO(dateStr)
|
||||
expect(dateHelper(dateStr, "yyyy", 1, "years")).toEqual(
|
||||
dateFns.format(dateFns.add(date, { years: 1 }), "yyyy"),
|
||||
)
|
||||
})
|
||||
|
||||
test("should work with weeks offset", () => {
|
||||
const dateStr = "2024-01-15T12:00:00.000Z"
|
||||
const date = dateFns.parseISO(dateStr)
|
||||
expect(dateHelper(dateStr, "yyyy-MM-dd", 2, "weeks")).toEqual(
|
||||
dateFns.format(dateFns.add(date, { weeks: 2 }), "yyyy-MM-dd"),
|
||||
)
|
||||
})
|
||||
|
||||
test("should work with minutes offset", () => {
|
||||
const dateStr = "2024-01-15T12:00:00.000Z"
|
||||
const date = dateFns.parseISO(dateStr)
|
||||
expect(dateHelper(dateStr, "HH:mm", 30, "minutes")).toEqual(
|
||||
dateFns.format(dateFns.add(date, { minutes: 30 }), "HH:mm"),
|
||||
)
|
||||
})
|
||||
|
||||
test("should work with seconds offset", () => {
|
||||
const dateStr = "2024-01-15T12:00:00.000Z"
|
||||
const date = dateFns.parseISO(dateStr)
|
||||
expect(dateHelper(dateStr, "HH:mm:ss", 45, "seconds")).toEqual(
|
||||
dateFns.format(dateFns.add(date, { seconds: 45 }), "HH:mm:ss"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("now edge cases", () => {
|
||||
test("should work with different format tokens", () => {
|
||||
const now = new Date()
|
||||
expect(nowHelper("yyyy")).toEqual(dateFns.format(now, "yyyy"))
|
||||
expect(nowHelper("MM")).toEqual(dateFns.format(now, "MM"))
|
||||
expect(nowHelper("dd")).toEqual(dateFns.format(now, "dd"))
|
||||
})
|
||||
|
||||
test("should work with positive offset", () => {
|
||||
const now = new Date()
|
||||
const result = nowHelper("yyyy-MM-dd", 1, "days")
|
||||
const expected = dateFns.format(dateFns.add(now, { days: 1 }), "yyyy-MM-dd")
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
test("should work with hours offset", () => {
|
||||
const now = new Date()
|
||||
const result = nowHelper("HH", 2, "hours")
|
||||
const expected = dateFns.format(dateFns.add(now, { hours: 2 }), "HH")
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerHelpers", () => {
|
||||
test("registers default helpers", () => {
|
||||
const config: ScaffoldConfig = { ...blankConf }
|
||||
registerHelpers(config)
|
||||
const result = handlebarsParse(
|
||||
{ ...config, data: { name: "hello_world" } },
|
||||
"{{camelCase name}}",
|
||||
)
|
||||
expect(result.toString()).toEqual("helloWorld")
|
||||
})
|
||||
|
||||
test("registers custom helpers", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
helpers: {
|
||||
reverse: (text: string) => text.split("").reverse().join(""),
|
||||
},
|
||||
}
|
||||
registerHelpers(config)
|
||||
const result = handlebarsParse(
|
||||
{ ...config, data: { name: "hello" } },
|
||||
"{{reverse name}}",
|
||||
)
|
||||
expect(result.toString()).toEqual("olleh")
|
||||
})
|
||||
|
||||
test("custom helpers override default helpers", () => {
|
||||
const config: ScaffoldConfig = {
|
||||
...blankConf,
|
||||
helpers: {
|
||||
camelCase: () => "OVERRIDDEN",
|
||||
},
|
||||
}
|
||||
registerHelpers(config)
|
||||
const result = handlebarsParse(
|
||||
{ ...config, data: { name: "test" } },
|
||||
"{{camelCase name}}",
|
||||
)
|
||||
expect(result.toString()).toEqual("OVERRIDDEN")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default helpers completeness", () => {
|
||||
test("all expected helpers are defined", () => {
|
||||
const expectedHelpers = [
|
||||
"camelCase", "snakeCase", "startCase", "kebabCase",
|
||||
"hyphenCase", "pascalCase", "lowerCase", "upperCase",
|
||||
"now", "date",
|
||||
]
|
||||
for (const helper of expectedHelpers) {
|
||||
expect(defaultHelpers).toHaveProperty(helper)
|
||||
expect(typeof defaultHelpers[helper as keyof typeof defaultHelpers]).toBe("function")
|
||||
}
|
||||
})
|
||||
|
||||
test("has exactly 10 helpers", () => {
|
||||
expect(Object.keys(defaultHelpers).length).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
529
tests/prompts.test.ts
Normal file
529
tests/prompts.test.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest"
|
||||
import { LogLevel, ScaffoldCmdConfig, ScaffoldConfig } from "../src/types"
|
||||
|
||||
vi.mock("@inquirer/input", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@inquirer/select", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@inquirer/confirm", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@inquirer/number", () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
import inputMock from "@inquirer/input"
|
||||
import selectMock from "@inquirer/select"
|
||||
import confirmMock from "@inquirer/confirm"
|
||||
import numberMock from "@inquirer/number"
|
||||
import {
|
||||
promptForName,
|
||||
promptForTemplateKey,
|
||||
promptForOutput,
|
||||
promptForTemplates,
|
||||
promptForMissingConfig,
|
||||
promptForInputs,
|
||||
resolveInputs,
|
||||
isInteractive,
|
||||
} from "../src/prompts"
|
||||
|
||||
function mockTTY(value: boolean) {
|
||||
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true })
|
||||
}
|
||||
|
||||
const blankConfig: ScaffoldCmdConfig = {
|
||||
logLevel: LogLevel.none,
|
||||
name: "",
|
||||
output: "",
|
||||
templates: [],
|
||||
data: {},
|
||||
overwrite: false,
|
||||
subdir: false,
|
||||
dryRun: false,
|
||||
quiet: false,
|
||||
version: false,
|
||||
}
|
||||
|
||||
describe("prompts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("promptForName", () => {
|
||||
test("calls input prompt and returns result", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValue("my-component")
|
||||
const result = await promptForName()
|
||||
expect(result).toEqual("my-component")
|
||||
expect(inputMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptForTemplateKey", () => {
|
||||
test("calls select prompt when multiple keys", async () => {
|
||||
vi.mocked(selectMock).mockResolvedValue("component")
|
||||
const result = await promptForTemplateKey({
|
||||
default: { name: "d", templates: [], output: "" },
|
||||
component: { name: "c", templates: [], output: "" },
|
||||
})
|
||||
expect(result).toEqual("component")
|
||||
expect(selectMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("returns single key without prompting", async () => {
|
||||
const result = await promptForTemplateKey({
|
||||
default: { name: "d", templates: [], output: "" },
|
||||
})
|
||||
expect(result).toEqual("default")
|
||||
expect(selectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("throws when config map is empty", async () => {
|
||||
await expect(promptForTemplateKey({})).rejects.toThrow("No templates found")
|
||||
})
|
||||
|
||||
test("presents all keys as choices", async () => {
|
||||
vi.mocked(selectMock).mockResolvedValue("b")
|
||||
await promptForTemplateKey({
|
||||
a: { name: "a", templates: [], output: "" },
|
||||
b: { name: "b", templates: [], output: "" },
|
||||
c: { name: "c", templates: [], output: "" },
|
||||
})
|
||||
const call = vi.mocked(selectMock).mock.calls[0][0] as {
|
||||
choices: { name: string; value: string }[]
|
||||
}
|
||||
expect(call.choices).toEqual([
|
||||
{ name: "a", value: "a" },
|
||||
{ name: "b", value: "b" },
|
||||
{ name: "c", value: "c" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptForOutput", () => {
|
||||
test("calls input prompt and returns result", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValue("./dist")
|
||||
const result = await promptForOutput()
|
||||
expect(result).toEqual("./dist")
|
||||
expect(inputMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptForTemplates", () => {
|
||||
test("parses comma-separated input into array", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValue("src/templates, lib/other")
|
||||
const result = await promptForTemplates()
|
||||
expect(result).toEqual(["src/templates", "lib/other"])
|
||||
})
|
||||
|
||||
test("handles single template", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValue("src/templates")
|
||||
const result = await promptForTemplates()
|
||||
expect(result).toEqual(["src/templates"])
|
||||
})
|
||||
|
||||
test("trims whitespace and filters empty entries", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValue(" a , , b , ")
|
||||
const result = await promptForTemplates()
|
||||
expect(result).toEqual(["a", "b"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isInteractive", () => {
|
||||
test("returns a boolean", () => {
|
||||
expect(typeof isInteractive()).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptForMissingConfig", () => {
|
||||
test("prompts for all missing values when interactive", async () => {
|
||||
mockTTY(true)
|
||||
vi.mocked(inputMock)
|
||||
.mockResolvedValueOnce("my-app") // name
|
||||
.mockResolvedValueOnce("./output") // output
|
||||
.mockResolvedValueOnce("src/tpl") // templates
|
||||
|
||||
const config = { ...blankConfig }
|
||||
const result = await promptForMissingConfig(config)
|
||||
expect(result.name).toEqual("my-app")
|
||||
expect(result.output).toEqual("./output")
|
||||
expect(result.templates).toEqual(["src/tpl"])
|
||||
expect(inputMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
test("does not prompt for values already provided", async () => {
|
||||
mockTTY(true)
|
||||
|
||||
const config = {
|
||||
...blankConfig,
|
||||
name: "already-set",
|
||||
output: "./out",
|
||||
templates: ["tpl"],
|
||||
}
|
||||
const result = await promptForMissingConfig(config)
|
||||
expect(result.name).toEqual("already-set")
|
||||
expect(result.output).toEqual("./out")
|
||||
expect(result.templates).toEqual(["tpl"])
|
||||
expect(inputMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("prompts for template key when multiple templates and no key", async () => {
|
||||
mockTTY(true)
|
||||
vi.mocked(inputMock)
|
||||
.mockResolvedValueOnce("name") // name
|
||||
.mockResolvedValueOnce("./output") // output
|
||||
.mockResolvedValueOnce("src/tpl") // templates
|
||||
vi.mocked(selectMock).mockResolvedValue("component")
|
||||
|
||||
const configMap = {
|
||||
default: { name: "d", templates: [], output: "" },
|
||||
component: { name: "c", templates: [], output: "" },
|
||||
}
|
||||
const config = { ...blankConfig }
|
||||
const result = await promptForMissingConfig(config, configMap)
|
||||
expect(result.key).toEqual("component")
|
||||
})
|
||||
|
||||
test("does not prompt for template key when already set", async () => {
|
||||
mockTTY(true)
|
||||
|
||||
const configMap = {
|
||||
default: { name: "d", templates: [], output: "" },
|
||||
component: { name: "c", templates: [], output: "" },
|
||||
}
|
||||
const config = {
|
||||
...blankConfig,
|
||||
name: "test",
|
||||
output: "./out",
|
||||
templates: ["tpl"],
|
||||
key: "default",
|
||||
}
|
||||
const result = await promptForMissingConfig(config, configMap)
|
||||
expect(result.key).toEqual("default")
|
||||
expect(selectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not prompt for template key when only one template", async () => {
|
||||
mockTTY(true)
|
||||
|
||||
const configMap = {
|
||||
default: { name: "d", templates: [], output: "" },
|
||||
}
|
||||
const config = { ...blankConfig, name: "test", output: "./out", templates: ["tpl"] }
|
||||
const result = await promptForMissingConfig(config, configMap)
|
||||
expect(result.key).toBeUndefined()
|
||||
expect(selectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not prompt in non-interactive mode", async () => {
|
||||
mockTTY(false)
|
||||
|
||||
const config = { ...blankConfig }
|
||||
const result = await promptForMissingConfig(config)
|
||||
expect(result.name).toEqual("")
|
||||
expect(result.output).toEqual("")
|
||||
expect(result.templates).toEqual([])
|
||||
expect(inputMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not prompt for config key when no config map provided", async () => {
|
||||
mockTTY(true)
|
||||
vi.mocked(inputMock)
|
||||
.mockResolvedValueOnce("name")
|
||||
.mockResolvedValueOnce("./out")
|
||||
.mockResolvedValueOnce("tpl")
|
||||
|
||||
const config = { ...blankConfig }
|
||||
const result = await promptForMissingConfig(config)
|
||||
expect(result.key).toBeUndefined()
|
||||
expect(selectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("only prompts for missing values, not provided ones", async () => {
|
||||
mockTTY(true)
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("src/tpl") // only templates missing
|
||||
|
||||
const config = { ...blankConfig, name: "app", output: "./out" }
|
||||
const result = await promptForMissingConfig(config)
|
||||
expect(result.name).toEqual("app")
|
||||
expect(result.output).toEqual("./out")
|
||||
expect(result.templates).toEqual(["src/tpl"])
|
||||
expect(inputMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptForInputs", () => {
|
||||
test("prompts for required inputs not in existing data", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("John")
|
||||
const result = await promptForInputs(
|
||||
{ author: { message: "Author name", required: true } },
|
||||
{},
|
||||
)
|
||||
expect(result.author).toEqual("John")
|
||||
expect(inputMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("skips inputs already provided in data", async () => {
|
||||
const result = await promptForInputs(
|
||||
{ author: { message: "Author name", required: true } },
|
||||
{ author: "Jane" },
|
||||
)
|
||||
expect(result.author).toEqual("Jane")
|
||||
expect(inputMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("applies default value for optional inputs not in data", async () => {
|
||||
const result = await promptForInputs({ license: { default: "MIT" } }, {})
|
||||
expect(result.license).toEqual("MIT")
|
||||
expect(inputMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not apply default when value already exists", async () => {
|
||||
const result = await promptForInputs(
|
||||
{ license: { default: "MIT" } },
|
||||
{ license: "Apache-2.0" },
|
||||
)
|
||||
expect(result.license).toEqual("Apache-2.0")
|
||||
})
|
||||
|
||||
test("uses input key as message fallback", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("val")
|
||||
await promptForInputs({ myField: { required: true } }, {})
|
||||
const call = vi.mocked(inputMock).mock.calls[0][0] as { message: string }
|
||||
expect(call.message).toContain("myField")
|
||||
})
|
||||
|
||||
test("prompts multiple required inputs in order", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("John").mockResolvedValueOnce("2.0")
|
||||
const result = await promptForInputs(
|
||||
{
|
||||
author: { message: "Author", required: true },
|
||||
version: { message: "Version", required: true },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.author).toEqual("John")
|
||||
expect(result.version).toEqual("2.0")
|
||||
expect(inputMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test("mixes prompts, defaults, and existing data", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("John")
|
||||
const result = await promptForInputs(
|
||||
{
|
||||
author: { message: "Author", required: true },
|
||||
license: { default: "MIT" },
|
||||
description: { message: "Desc", required: true },
|
||||
},
|
||||
{ description: "My project" },
|
||||
)
|
||||
expect(result.author).toEqual("John")
|
||||
expect(result.license).toEqual("MIT")
|
||||
expect(result.description).toEqual("My project")
|
||||
expect(inputMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("preserves existing data keys not in inputs", async () => {
|
||||
const result = await promptForInputs({ license: { default: "MIT" } }, { extra: "value" })
|
||||
expect(result.extra).toEqual("value")
|
||||
expect(result.license).toEqual("MIT")
|
||||
})
|
||||
|
||||
test("required input with default pre-fills prompt", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("custom")
|
||||
await promptForInputs({ author: { required: true, default: "Anonymous" } }, {})
|
||||
const call = vi.mocked(inputMock).mock.calls[0][0] as { default?: string }
|
||||
expect(call.default).toEqual("Anonymous")
|
||||
})
|
||||
|
||||
test("select input prompts with options", async () => {
|
||||
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
|
||||
const result = await promptForInputs(
|
||||
{
|
||||
license: {
|
||||
type: "select",
|
||||
message: "License",
|
||||
options: ["MIT", "Apache-2.0", "GPL-3.0"],
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.license).toEqual("MIT")
|
||||
expect(selectMock).toHaveBeenCalledOnce()
|
||||
const call = vi.mocked(selectMock).mock.calls[0][0] as {
|
||||
choices: { name: string; value: string }[]
|
||||
}
|
||||
expect(call.choices).toEqual([
|
||||
{ name: "MIT", value: "MIT" },
|
||||
{ name: "Apache-2.0", value: "Apache-2.0" },
|
||||
{ name: "GPL-3.0", value: "GPL-3.0" },
|
||||
])
|
||||
})
|
||||
|
||||
test("select input with object options", async () => {
|
||||
vi.mocked(selectMock).mockResolvedValueOnce("mit")
|
||||
const result = await promptForInputs(
|
||||
{
|
||||
license: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ name: "MIT License", value: "mit" },
|
||||
{ name: "Apache 2.0", value: "apache" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.license).toEqual("mit")
|
||||
})
|
||||
|
||||
test("select input throws when no options", async () => {
|
||||
await expect(promptForInputs({ license: { type: "select" } }, {})).rejects.toThrow(
|
||||
"no options defined",
|
||||
)
|
||||
})
|
||||
|
||||
test("select input skipped when value already provided", async () => {
|
||||
const result = await promptForInputs(
|
||||
{ license: { type: "select", options: ["MIT", "Apache"] } },
|
||||
{ license: "MIT" },
|
||||
)
|
||||
expect(result.license).toEqual("MIT")
|
||||
expect(selectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("confirm input prompts and returns boolean", async () => {
|
||||
vi.mocked(confirmMock).mockResolvedValueOnce(true)
|
||||
const result = await promptForInputs(
|
||||
{ private: { type: "confirm", message: "Private?" } },
|
||||
{},
|
||||
)
|
||||
expect(result.private).toBe(true)
|
||||
expect(confirmMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("confirm input with default false", async () => {
|
||||
vi.mocked(confirmMock).mockResolvedValueOnce(false)
|
||||
await promptForInputs({ private: { type: "confirm", default: false } }, {})
|
||||
const call = vi.mocked(confirmMock).mock.calls[0][0] as { default?: boolean }
|
||||
expect(call.default).toBe(false)
|
||||
})
|
||||
|
||||
test("confirm input skipped when value already provided", async () => {
|
||||
const result = await promptForInputs({ private: { type: "confirm" } }, { private: true })
|
||||
expect(result.private).toBe(true)
|
||||
expect(confirmMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("number input prompts and returns number", async () => {
|
||||
vi.mocked(numberMock).mockResolvedValueOnce(8080)
|
||||
const result = await promptForInputs(
|
||||
{ port: { type: "number", message: "Port", required: true } },
|
||||
{},
|
||||
)
|
||||
expect(result.port).toBe(8080)
|
||||
expect(numberMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("number input with default", async () => {
|
||||
vi.mocked(numberMock).mockResolvedValueOnce(3000)
|
||||
await promptForInputs({ port: { type: "number", default: 3000, required: true } }, {})
|
||||
const call = vi.mocked(numberMock).mock.calls[0][0] as { default?: number }
|
||||
expect(call.default).toBe(3000)
|
||||
})
|
||||
|
||||
test("number input skipped when value already provided", async () => {
|
||||
const result = await promptForInputs(
|
||||
{ port: { type: "number", required: true } },
|
||||
{ port: 9090 },
|
||||
)
|
||||
expect(result.port).toBe(9090)
|
||||
expect(numberMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("mixed input types in one config", async () => {
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("John")
|
||||
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
|
||||
vi.mocked(confirmMock).mockResolvedValueOnce(true)
|
||||
vi.mocked(numberMock).mockResolvedValueOnce(3000)
|
||||
|
||||
const result = await promptForInputs(
|
||||
{
|
||||
author: { type: "text", message: "Author", required: true },
|
||||
license: { type: "select", message: "License", options: ["MIT", "Apache"] },
|
||||
private: { type: "confirm", message: "Private?" },
|
||||
port: { type: "number", message: "Port", required: true },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.author).toEqual("John")
|
||||
expect(result.license).toEqual("MIT")
|
||||
expect(result.private).toBe(true)
|
||||
expect(result.port).toBe(3000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveInputs", () => {
|
||||
test("returns config unchanged when no inputs defined", async () => {
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "out",
|
||||
templates: [],
|
||||
data: { foo: "bar" },
|
||||
}
|
||||
const result = await resolveInputs(config)
|
||||
expect(result.data).toEqual({ foo: "bar" })
|
||||
})
|
||||
|
||||
test("applies defaults in non-interactive mode", async () => {
|
||||
mockTTY(false)
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "out",
|
||||
templates: [],
|
||||
data: {},
|
||||
inputs: {
|
||||
license: { default: "MIT" },
|
||||
},
|
||||
}
|
||||
const result = await resolveInputs(config)
|
||||
expect(result.data?.license).toEqual("MIT")
|
||||
expect(inputMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not overwrite existing data with defaults in non-interactive mode", async () => {
|
||||
mockTTY(false)
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "out",
|
||||
templates: [],
|
||||
data: { license: "Apache-2.0" },
|
||||
inputs: {
|
||||
license: { default: "MIT" },
|
||||
},
|
||||
}
|
||||
const result = await resolveInputs(config)
|
||||
expect(result.data?.license).toEqual("Apache-2.0")
|
||||
})
|
||||
|
||||
test("prompts for required inputs in interactive mode", async () => {
|
||||
mockTTY(true)
|
||||
vi.mocked(inputMock).mockResolvedValueOnce("John")
|
||||
const config: ScaffoldConfig = {
|
||||
name: "test",
|
||||
output: "out",
|
||||
templates: [],
|
||||
data: {},
|
||||
inputs: {
|
||||
author: { message: "Author", required: true },
|
||||
},
|
||||
}
|
||||
const result = await resolveInputs(config)
|
||||
expect(result.data?.author).toEqual("John")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest"
|
||||
import mockFs from "mock-fs"
|
||||
import FileSystem from "mock-fs/lib/filesystem"
|
||||
import Scaffold from "../src/scaffold"
|
||||
@@ -66,7 +77,8 @@ const fileStructDates = {
|
||||
input: {
|
||||
"now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}",
|
||||
"offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}",
|
||||
"custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
|
||||
"custom.txt":
|
||||
"Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
|
||||
},
|
||||
output: {},
|
||||
}
|
||||
@@ -79,14 +91,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)
|
||||
// })
|
||||
})
|
||||
@@ -101,6 +113,7 @@ 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 +143,7 @@ describe("Scaffold", () => {
|
||||
|
||||
describe(
|
||||
"binary files",
|
||||
|
||||
withMock(fileStructWithBinary, () => {
|
||||
test("should copy as-is", async () => {
|
||||
await Scaffold({
|
||||
@@ -197,9 +211,9 @@ describe("Scaffold", () => {
|
||||
describe(
|
||||
"errors",
|
||||
withMock(fileStructNormal, () => {
|
||||
let consoleMock1: jest.SpyInstance
|
||||
let consoleMock1: MockInstance
|
||||
beforeAll(() => {
|
||||
consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0)
|
||||
consoleMock1 = vi.spyOn(console, "error").mockImplementation(() => void 0)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -235,9 +249,9 @@ describe("Scaffold", () => {
|
||||
describe(
|
||||
"dry run",
|
||||
withMock(fileStructNormal, () => {
|
||||
let consoleMock1: jest.SpyInstance
|
||||
let consoleMock1: MockInstance
|
||||
beforeAll(() => {
|
||||
consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0)
|
||||
consoleMock1 = vi.spyOn(console, "error").mockImplementation(() => void 0)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -276,7 +290,8 @@ describe("Scaffold", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
describe("output structure", () => {
|
||||
describe(
|
||||
"output structure",
|
||||
withMock(fileStructNested, () => {
|
||||
test("should maintain input structure on output", async () => {
|
||||
await Scaffold({
|
||||
@@ -296,33 +311,38 @@ describe("Scaffold", () => {
|
||||
|
||||
const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt"))
|
||||
const oneDeepFile = readFileSync(join(process.cwd(), "output", "AppName/app_name-2.txt"))
|
||||
const twoDeepFile = readFileSync(join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"))
|
||||
const twoDeepFile = readFileSync(
|
||||
join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"),
|
||||
)
|
||||
expect(rootFile.toString()).toEqual("This should be in root")
|
||||
expect(oneDeepFile.toString()).toEqual("Hello, my value is 1")
|
||||
expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!")
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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 +414,7 @@ describe("Scaffold", () => {
|
||||
describe(
|
||||
"custom helpers",
|
||||
withMock(fileStructHelpers, () => {
|
||||
const _helpers: Record<string, (text: string) => string> = {
|
||||
const _helpers: Record<string, (_text: string) => string> = {
|
||||
add1: (text) => text + " 1",
|
||||
}
|
||||
test("should work", async () => {
|
||||
@@ -521,4 +541,705 @@ describe("Scaffold", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe(
|
||||
"name is available in data",
|
||||
withMock(
|
||||
{
|
||||
input: { "file.txt": "Name: {{name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("name is automatically injected into data", async () => {
|
||||
await Scaffold({
|
||||
name: "my_project",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
|
||||
expect(content).toEqual("Name: my_project")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"data overrides name in data",
|
||||
withMock(
|
||||
{
|
||||
input: { "file.txt": "Name: {{name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("explicit data.name takes precedence", async () => {
|
||||
await Scaffold({
|
||||
name: "original_name",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
data: { name: "custom_name" },
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
|
||||
expect(content).toEqual("Name: custom_name")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"multiple templates",
|
||||
withMock(
|
||||
{
|
||||
template1: { "file1.txt": "From template 1: {{name}}" },
|
||||
template2: { "file2.txt": "From template 2: {{name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("processes multiple template directories", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["template1", "template2"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const file1 = readFileSync(join(process.cwd(), "output", "file1.txt")).toString()
|
||||
const file2 = readFileSync(join(process.cwd(), "output", "file2.txt")).toString()
|
||||
expect(file1).toEqual("From template 1: app")
|
||||
expect(file2).toEqual("From template 2: app")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"template with custom data",
|
||||
withMock(
|
||||
{
|
||||
input: { "{{name}}.txt": "Author: {{author}}, Version: {{version}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("uses custom data in content and filename", async () => {
|
||||
await Scaffold({
|
||||
name: "my_app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
data: { author: "John", version: "2.0" },
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "my_app.txt")).toString()
|
||||
expect(content).toEqual("Author: John, Version: 2.0")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"template with helpers in filenames",
|
||||
withMock(
|
||||
{
|
||||
input: { "{{pascalCase name}}.tsx": "component {{pascalCase name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("applies helpers to filenames", async () => {
|
||||
await Scaffold({
|
||||
name: "my_component",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "MyComponent.tsx")).toString()
|
||||
expect(content).toEqual("component MyComponent")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"template with helpers in directory names",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"{{kebabCase name}}": {
|
||||
"index.ts": "export from {{name}}",
|
||||
},
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("applies helpers to directory names", async () => {
|
||||
await Scaffold({
|
||||
name: "MyComponent",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const content = readFileSync(
|
||||
join(process.cwd(), "output", "my-component", "index.ts"),
|
||||
).toString()
|
||||
expect(content).toEqual("export from MyComponent")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"deeply nested template structure",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"root.txt": "root",
|
||||
level1: {
|
||||
"l1.txt": "level 1",
|
||||
level2: {
|
||||
"l2.txt": "level 2",
|
||||
level3: {
|
||||
"l3.txt": "level 3 {{name}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("preserves deep nesting", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
expect(readFileSync(join(process.cwd(), "output", "root.txt")).toString()).toEqual("root")
|
||||
expect(
|
||||
readFileSync(join(process.cwd(), "output", "level1", "l1.txt")).toString(),
|
||||
).toEqual("level 1")
|
||||
expect(
|
||||
readFileSync(join(process.cwd(), "output", "level1", "level2", "l2.txt")).toString(),
|
||||
).toEqual("level 2")
|
||||
expect(
|
||||
readFileSync(
|
||||
join(process.cwd(), "output", "level1", "level2", "level3", "l3.txt"),
|
||||
).toString(),
|
||||
).toEqual("level 3 app")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"overwrite as function",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"keep.txt": "new keep",
|
||||
"replace.txt": "new replace",
|
||||
},
|
||||
output: {
|
||||
"keep.txt": "old keep",
|
||||
"replace.txt": "old replace",
|
||||
},
|
||||
},
|
||||
() => {
|
||||
test("per-file overwrite control", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
overwrite: (_fullPath, _basedir, basename) => basename === "replace.txt",
|
||||
})
|
||||
expect(readFileSync(join(process.cwd(), "output", "keep.txt")).toString()).toEqual(
|
||||
"old keep",
|
||||
)
|
||||
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual(
|
||||
"new replace",
|
||||
)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"multiple custom helpers",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"file.txt": "{{reverse name}} - {{repeat name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("multiple custom helpers work together", async () => {
|
||||
await Scaffold({
|
||||
name: "abc",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
helpers: {
|
||||
reverse: (text: string) => text.split("").reverse().join(""),
|
||||
repeat: (text: string) => text + text,
|
||||
},
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
|
||||
expect(content).toEqual("cba - abcabc")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"subdirHelper with different helpers",
|
||||
withMock(
|
||||
{
|
||||
input: { "file.txt": "content" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("subdirHelper camelCase", async () => {
|
||||
await Scaffold({
|
||||
name: "my_component",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
subdir: true,
|
||||
subdirHelper: "camelCase",
|
||||
})
|
||||
const content = readFileSync(
|
||||
join(process.cwd(), "output", "myComponent", "file.txt"),
|
||||
).toString()
|
||||
expect(content).toEqual("content")
|
||||
})
|
||||
|
||||
test("subdirHelper kebabCase", async () => {
|
||||
await Scaffold({
|
||||
name: "MyComponent",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
subdir: true,
|
||||
subdirHelper: "kebabCase",
|
||||
})
|
||||
const content = readFileSync(
|
||||
join(process.cwd(), "output", "my-component", "file.txt"),
|
||||
).toString()
|
||||
expect(content).toEqual("content")
|
||||
})
|
||||
|
||||
test("subdirHelper snakeCase", async () => {
|
||||
await Scaffold({
|
||||
name: "MyComponent",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
subdir: true,
|
||||
subdirHelper: "snakeCase",
|
||||
})
|
||||
const content = readFileSync(
|
||||
join(process.cwd(), "output", "my_component", "file.txt"),
|
||||
).toString()
|
||||
expect(content).toEqual("content")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"empty template directory",
|
||||
withMock(
|
||||
{
|
||||
input: {},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("handles empty template dir gracefully", async () => {
|
||||
await expect(
|
||||
Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"template with special characters in data",
|
||||
withMock(
|
||||
{
|
||||
input: { "file.txt": "Value: {{value}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("handles special characters in data values", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
data: { value: 'hello & <world> "test"' },
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
|
||||
expect(content).toEqual('Value: hello & <world> "test"')
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"beforeWrite with async callback",
|
||||
withMock(
|
||||
{
|
||||
input: { "file.txt": "Hello {{name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("supports async beforeWrite", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
beforeWrite: async (content) => {
|
||||
return content.toString().replace("Hello", "Hi")
|
||||
},
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
|
||||
expect(content).toEqual("Hi app")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"beforeWrite receives all arguments",
|
||||
withMock(
|
||||
{
|
||||
input: { "{{name}}.txt": "Template: {{name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("beforeWrite gets content, rawContent, and outputPath", async () => {
|
||||
const beforeWriteSpy = vi.fn().mockReturnValue(undefined)
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
beforeWrite: beforeWriteSpy,
|
||||
})
|
||||
expect(beforeWriteSpy).toHaveBeenCalledTimes(1)
|
||||
const [content, rawContent, outputPath] = beforeWriteSpy.mock.calls[0]
|
||||
expect(content.toString()).toEqual("Template: app")
|
||||
expect(rawContent.toString()).toEqual("Template: {{name}}")
|
||||
expect(outputPath).toContain("app.txt")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"multiple binary files",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"img1.bin": crypto.randomBytes(5000),
|
||||
"img2.bin": crypto.randomBytes(8000),
|
||||
"text.txt": "regular text {{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("handles mix of binary and text files", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const text = readFileSync(join(process.cwd(), "output", "text.txt")).toString()
|
||||
expect(text).toEqual("regular text app")
|
||||
const bin1 = readFileSync(join(process.cwd(), "output", "img1.bin"))
|
||||
const bin2 = readFileSync(join(process.cwd(), "output", "img2.bin"))
|
||||
expect(bin1.length).toBeGreaterThan(0)
|
||||
expect(bin2.length).toBeGreaterThan(0)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"output with function returning dynamic path",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"component.tsx": "component {{name}}",
|
||||
"style.css": "style for {{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("output function can route files to different directories", async () => {
|
||||
await Scaffold({
|
||||
name: "Button",
|
||||
output: (_fullPath, _basedir, basename) => {
|
||||
if (basename.endsWith(".css")) return join("output", "styles")
|
||||
return join("output", "components")
|
||||
},
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const component = readFileSync(
|
||||
join(process.cwd(), "output", "components", "component.tsx"),
|
||||
).toString()
|
||||
const style = readFileSync(
|
||||
join(process.cwd(), "output", "styles", "style.css"),
|
||||
).toString()
|
||||
expect(component).toEqual("component Button")
|
||||
expect(style).toEqual("style for Button")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"handlebars block helpers",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
"file.txt": "{{#if showHeader}}Header\n{{/if}}Body for {{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("supports handlebars block helpers in templates", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
data: { showHeader: true },
|
||||
})
|
||||
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
|
||||
expect(content).toEqual("Header\nBody for app")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"glob pattern as template",
|
||||
withMock(
|
||||
{
|
||||
src: {
|
||||
"file1.txt": "text 1 {{name}}",
|
||||
"file2.txt": "text 2 {{name}}",
|
||||
"file3.js": "js {{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("glob pattern selects matching files only", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["src/*.txt"],
|
||||
logLevel: "none",
|
||||
})
|
||||
// glob templates maintain structure relative to the non-glob part
|
||||
const outputFiles = readdirSync(join(process.cwd(), "output", "src"))
|
||||
expect(outputFiles).toContain("file1.txt")
|
||||
expect(outputFiles).toContain("file2.txt")
|
||||
expect(outputFiles).not.toContain("file3.js")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"dotfiles in template",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
".gitignore": "node_modules",
|
||||
".env.example": "KEY={{name}}",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("includes dotfiles in output", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual(
|
||||
"node_modules",
|
||||
)
|
||||
expect(readFileSync(join(process.cwd(), "output", ".env.example")).toString()).toEqual(
|
||||
"KEY=app",
|
||||
)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"large number of files",
|
||||
withMock(
|
||||
{
|
||||
input: Object.fromEntries(
|
||||
Array.from({ length: 50 }, (_, i) => [`file${i}.txt`, `Content ${i} for {{name}}`]),
|
||||
),
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("handles many files", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const files = readdirSync(join(process.cwd(), "output"))
|
||||
expect(files.length).toBe(50)
|
||||
expect(readFileSync(join(process.cwd(), "output", "file0.txt")).toString()).toEqual(
|
||||
"Content 0 for app",
|
||||
)
|
||||
expect(readFileSync(join(process.cwd(), "output", "file49.txt")).toString()).toEqual(
|
||||
"Content 49 for app",
|
||||
)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
".scaffoldignore",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
".scaffoldignore": "*.log\nREADME.md\n",
|
||||
"file.txt": "included",
|
||||
"debug.log": "excluded log",
|
||||
"README.md": "excluded readme",
|
||||
"other.js": "included js",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("excludes files matching .scaffoldignore patterns", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const files = readdirSync(join(process.cwd(), "output"))
|
||||
expect(files).toContain("file.txt")
|
||||
expect(files).toContain("other.js")
|
||||
expect(files).not.toContain("debug.log")
|
||||
expect(files).not.toContain("README.md")
|
||||
expect(files).not.toContain(".scaffoldignore")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
".scaffoldignore not copied to output",
|
||||
withMock(
|
||||
{
|
||||
input: {
|
||||
".scaffoldignore": "*.log",
|
||||
"file.txt": "content",
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test(".scaffoldignore is never in output", async () => {
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
})
|
||||
const files = readdirSync(join(process.cwd(), "output"))
|
||||
expect(files).not.toContain(".scaffoldignore")
|
||||
expect(files).toContain("file.txt")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
describe(
|
||||
"afterScaffold hook",
|
||||
withMock(
|
||||
{
|
||||
input: { "file.txt": "Hello {{name}}" },
|
||||
output: {},
|
||||
},
|
||||
() => {
|
||||
test("calls function hook with config and files", async () => {
|
||||
const hookFn = vi.fn()
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
afterScaffold: hookFn,
|
||||
})
|
||||
expect(hookFn).toHaveBeenCalledOnce()
|
||||
const ctx = hookFn.mock.calls[0][0]
|
||||
expect(ctx.config.name).toEqual("app")
|
||||
expect(ctx.files.length).toBe(1)
|
||||
expect(ctx.files[0]).toContain("file.txt")
|
||||
})
|
||||
|
||||
test("calls async function hook", async () => {
|
||||
let called = false
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
afterScaffold: async () => {
|
||||
called = true
|
||||
},
|
||||
})
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
|
||||
test("does not call hook when no files written (dry run)", async () => {
|
||||
const hookFn = vi.fn()
|
||||
await Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
dryRun: true,
|
||||
afterScaffold: hookFn,
|
||||
})
|
||||
expect(hookFn).toHaveBeenCalledOnce()
|
||||
expect(hookFn.mock.calls[0][0].files.length).toBe(0)
|
||||
})
|
||||
|
||||
test("does not call hook when not provided", async () => {
|
||||
await expect(
|
||||
Scaffold({
|
||||
name: "app",
|
||||
output: "output",
|
||||
templates: ["input"],
|
||||
logLevel: "none",
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
3
tests/test-config.d.ts
vendored
Normal file
3
tests/test-config.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const config: import("../dist").ScaffoldConfigFile;
|
||||
export = config;
|
||||
|
||||
24
tests/test-config.js
Normal file
24
tests/test-config.js
Normal 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: "---",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 (_: unknown) => unknown)("anything")).toBe("hello")
|
||||
})
|
||||
|
||||
test("should return function as-is", () => {
|
||||
const fn = (x: string) => x.toUpperCase()
|
||||
const wrapped = wrapNoopResolver(fn)
|
||||
expect(wrapped).toBe(fn)
|
||||
})
|
||||
|
||||
test("should wrap object value", () => {
|
||||
const obj = { key: "value" }
|
||||
const wrapped = wrapNoopResolver(obj)
|
||||
expect(typeof wrapped).toBe("function")
|
||||
expect((wrapped as (_: unknown) => unknown)("anything")).toBe(obj)
|
||||
})
|
||||
|
||||
test("should wrap boolean value", () => {
|
||||
const wrapped = wrapNoopResolver(true)
|
||||
expect(typeof wrapped).toBe("function")
|
||||
expect((wrapped as (_: unknown) => unknown)(null)).toBe(true)
|
||||
})
|
||||
|
||||
test("should wrap number value", () => {
|
||||
const wrapped = wrapNoopResolver(42)
|
||||
expect(typeof wrapped).toBe("function")
|
||||
expect((wrapped as (_: unknown) => unknown)(null)).toBe(42)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("colorize", () => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
231
tests/validate.test.ts
Normal file
231
tests/validate.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest"
|
||||
import mockFs from "mock-fs"
|
||||
import { Console } from "console"
|
||||
import { validateConfig, validateTemplatePaths, assertConfigValid } from "../src/validate"
|
||||
|
||||
const validConfig = {
|
||||
name: "test",
|
||||
templates: ["templates"],
|
||||
output: "output",
|
||||
}
|
||||
|
||||
describe("validate", () => {
|
||||
describe("validateConfig", () => {
|
||||
test("returns no errors for valid config", () => {
|
||||
expect(validateConfig(validConfig)).toEqual([])
|
||||
})
|
||||
|
||||
test("returns no errors with all optional fields", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
subdir: true,
|
||||
subdirHelper: "camelCase",
|
||||
data: { key: "value" },
|
||||
logLevel: "debug",
|
||||
dryRun: true,
|
||||
overwrite: true,
|
||||
inputs: {
|
||||
author: { type: "text", message: "Author", required: true },
|
||||
},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("errors on missing name", () => {
|
||||
const errors = validateConfig({ ...validConfig, name: "" })
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("name")
|
||||
})
|
||||
|
||||
test("errors on missing templates", () => {
|
||||
const errors = validateConfig({ ...validConfig, templates: [] })
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("templates")
|
||||
})
|
||||
|
||||
test("errors on invalid logLevel", () => {
|
||||
const errors = validateConfig({ ...validConfig, logLevel: "verbose" })
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("logLevel")
|
||||
})
|
||||
|
||||
test("errors on subdirHelper without subdir", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
subdirHelper: "camelCase",
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("subdirHelper")
|
||||
})
|
||||
|
||||
test("no error on subdirHelper with subdir", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
subdir: true,
|
||||
subdirHelper: "camelCase",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("errors on select input without options", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
license: { type: "select" },
|
||||
},
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors.some((e) => e.includes("select") && e.includes("options"))).toBe(true)
|
||||
})
|
||||
|
||||
test("no error on select input with options", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
license: { type: "select", options: ["MIT", "Apache"] },
|
||||
},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("errors on confirm input with non-boolean default", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
flag: { type: "confirm", default: "yes" },
|
||||
},
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors.some((e) => e.includes("confirm") && e.includes("boolean"))).toBe(true)
|
||||
})
|
||||
|
||||
test("errors on number input with non-number default", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
port: { type: "number", default: "3000" },
|
||||
},
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors.some((e) => e.includes("number"))).toBe(true)
|
||||
})
|
||||
|
||||
test("valid input types pass", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
a: { type: "text", required: true },
|
||||
b: { type: "select", options: ["x", "y"] },
|
||||
c: { type: "confirm", default: false },
|
||||
d: { type: "number", default: 42 },
|
||||
},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts function output", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
output: () => "dynamic-output",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts function overwrite", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
overwrite: () => true,
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts afterScaffold string", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
afterScaffold: "npm install",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts afterScaffold function", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
afterScaffold: () => {},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateTemplatePaths", () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
mockFs({
|
||||
templates: { "file.txt": "content" },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
test("returns no errors for existing paths", async () => {
|
||||
const errors = await validateTemplatePaths(["templates"])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("returns error for missing paths", async () => {
|
||||
const errors = await validateTemplatePaths(["nonexistent"])
|
||||
expect(errors.length).toBe(1)
|
||||
expect(errors[0]).toContain("nonexistent")
|
||||
})
|
||||
|
||||
test("skips glob patterns", async () => {
|
||||
const errors = await validateTemplatePaths(["templates/**/*"])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("skips negation patterns", async () => {
|
||||
const errors = await validateTemplatePaths(["!excluded"])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("assertConfigValid", () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
mockFs({
|
||||
templates: { "file.txt": "content" },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
test("does not throw for valid config", async () => {
|
||||
await expect(assertConfigValid(validConfig)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("throws formatted error for invalid config", async () => {
|
||||
await expect(assertConfigValid({ name: "", templates: [], output: "" })).rejects.toThrow(
|
||||
"Invalid scaffold config",
|
||||
)
|
||||
})
|
||||
|
||||
test("includes all errors in message", async () => {
|
||||
try {
|
||||
await assertConfigValid({ name: "", templates: [], output: "out" })
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
expect(msg).toContain("name")
|
||||
expect(msg).toContain("templates")
|
||||
}
|
||||
})
|
||||
|
||||
test("checks template path existence", async () => {
|
||||
await expect(
|
||||
assertConfigValid({ name: "test", templates: ["missing"], output: "out" }),
|
||||
).rejects.toThrow("does not exist")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,28 +2,21 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "./src",
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"lib": ["ESNext"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": false,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/cmd.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"tests/*"
|
||||
],
|
||||
"include": ["src/index.ts", "src/cmd.ts"],
|
||||
"exclude": ["tests/*"]
|
||||
}
|
||||
|
||||
44
vite.config.ts
Normal file
44
vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig } from "vite"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
index: path.resolve(__dirname, "src/index.ts"),
|
||||
cmd: path.resolve(__dirname, "src/cmd.ts"),
|
||||
},
|
||||
formats: ["cjs"],
|
||||
},
|
||||
outDir: "dist",
|
||||
target: "node20",
|
||||
rollupOptions: {
|
||||
// Externalize all node builtins and all dependencies
|
||||
external: [
|
||||
/^node:/, // Node builtins
|
||||
/^[^./]/, // All bare imports (dependencies)
|
||||
],
|
||||
output: {
|
||||
exports: "named",
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
clearMocks: true,
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
reportsDirectory: "coverage",
|
||||
exclude: ["node_modules/", "scaffold.config.js", "dist/", "docs/"],
|
||||
},
|
||||
exclude: ["node_modules", "dist", "docs"],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user