feat: predefined data inputs

This commit is contained in:
2026-03-23 12:27:55 +02:00
parent 519ef273ac
commit d64dd4f0e7
12 changed files with 438 additions and 43 deletions

4
.prettierignore Normal file
View File

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

View File

@@ -13,9 +13,12 @@
</h2>
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.
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.
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.
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">
@@ -92,6 +95,33 @@ See information about each option and flag using the `--help` flag, or read the
[CLI documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/cli). For information
about how configuration files work, [see below](#configuration-files).
### Interactive Mode
When running in a terminal, Simple Scaffold will interactively prompt for any missing required
values — name, output directory, template paths, and template key (if multiple are available).
Config files can also define **inputs** — custom fields that are prompted interactively and become
template data:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
},
}
```
Inputs can be pre-provided via `--data` or `-D` to skip the prompt:
```sh
npx simple-scaffold -c scaffold.config.js -k component -D author=John MyComponent
```
### Configuration Files
You can use a config file to more easily maintain all your scaffold definitions.

View File

@@ -23,7 +23,8 @@ module.exports = {
}
```
For the full configuration options, see [ScaffoldConfigFile](../api/type-aliases/ScaffoldConfigFile).
For the full configuration options, see
[ScaffoldConfigFile](../api/type-aliases/ScaffoldConfigFile).
If you want to supply functions inside the configurations, you must use a `.js`/`.cjs`/`.mjs` file
as JSON does not support non-primitives.
@@ -45,6 +46,31 @@ module.exports = (config) => {
}
```
### Template Inputs
You can define **inputs** in your config to prompt users for custom values when scaffolding. Each
input becomes a template data variable:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License type", default: "MIT" },
description: { message: "Component description" },
},
},
}
```
In your templates, use these as `{{ author }}`, `{{ license }}`, `{{ description }}`.
- **Required** inputs are prompted interactively if not provided via `--data` or `-D`
- **Optional** inputs with a `default` use that value silently if not provided
- In non-interactive environments, only defaults are applied
If you want to provide templates that need no name (such as common config files which are easily
portable between projects), you may provide the `name` property in the config object.
@@ -86,7 +112,6 @@ simple-scaffold -c scaffold.json MyComponentName
```
- When the a directory is given, the following files in the given directory will be tried in order:
- `scaffold.config.*`
- `scaffold.*`

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { log } from "./logger"
import { MassargCommand } from "massarg/command"
import { getUniqueTmpPath as generateUniqueTmpPath } from "./file"
import { colorize } from "./colors"
import { isInteractive, promptForMissingConfig } from "./prompts"
import { promptForMissingConfig, resolveInputs } from "./prompts"
export async function parseCliArgs(args = process.argv.slice(2)) {
const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false))
@@ -45,7 +45,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
log(config, LogLevel.debug, "Parsing config file...", config)
const parsed = await parseConfigFile(config)
await Scaffold(parsed)
const resolved = await resolveInputs(parsed)
await Scaffold(resolved)
} catch (e) {
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
log(config, LogLevel.error, message)

View File

@@ -1,7 +1,7 @@
import input from "@inquirer/input"
import select from "@inquirer/select"
import { colorize } from "./colors"
import { ScaffoldCmdConfig, ScaffoldConfigMap } from "./types"
import { ScaffoldCmdConfig, ScaffoldConfig, ScaffoldConfigMap, ScaffoldInput } from "./types"
/** Prompts the user for a scaffold name. */
export async function promptForName(): Promise<string> {
@@ -59,6 +59,41 @@ export async function promptForTemplates(): Promise<string[]> {
return value.split(",").map((t) => t.trim()).filter(Boolean)
}
/**
* Prompts the user for any required scaffold inputs that are not already provided in data.
* Also applies default values for optional inputs that have one.
* Returns the merged data object.
*/
export async function promptForInputs(
inputs: Record<string, ScaffoldInput>,
existingData: Record<string, unknown> = {},
): Promise<Record<string, unknown>> {
const data = { ...existingData }
for (const [key, def] of Object.entries(inputs)) {
// Skip if already provided via data/CLI
if (key in data && data[key] !== undefined && data[key] !== "") {
continue
}
if (def.required) {
data[key] = await input({
message: colorize.cyan(def.message ?? `${key}:`),
required: true,
default: def.default,
validate: (value) => {
if (!value.trim()) return `${key} is required`
return true
},
})
} else if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}
}
return data
}
/** Returns true if the process is running in an interactive terminal. */
export function isInteractive(): boolean {
return Boolean(process.stdin.isTTY)
@@ -97,3 +132,30 @@ export async function promptForMissingConfig(
return config
}
/**
* Prompts for any required inputs defined in the scaffold config and merges them into data.
* Only prompts in interactive mode; in non-interactive mode, only applies defaults.
*/
export async function resolveInputs(config: ScaffoldConfig): Promise<ScaffoldConfig> {
if (!config.inputs) {
return config
}
const interactive = isInteractive()
if (interactive) {
config.data = await promptForInputs(config.inputs, config.data)
} else {
// Non-interactive: only apply defaults
const data = { ...config.data }
for (const [key, def] of Object.entries(config.inputs)) {
if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}
}
config.data = data
}
return config
}

View File

@@ -14,6 +14,7 @@ import { LogLevel, MinimalConfig, Resolver, ScaffoldCmdConfig, ScaffoldConfig }
import { registerHelpers } from "./parser"
import { log, logInitStep } from "./logger"
import { parseConfigFile } from "./config"
import { resolveInputs } from "./prompts"
/**
* Create a scaffold using given `options`.
@@ -50,6 +51,7 @@ import { parseConfigFile } from "./config"
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
config.output ??= process.cwd()
config = await resolveInputs(config)
registerHelpers(config)
try {
config.data = { name: config.name, ...config.data }

View File

@@ -166,10 +166,47 @@ 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>
/** @internal */
tmpDir?: string
}
/**
* Defines a single interactive input for a scaffold template.
*
* @category Config
*/
export interface ScaffoldInput {
/** The prompt message shown to the user. Defaults to the input key name if omitted. */
message?: string
/** Whether this input must be provided. If true and missing, the user will be prompted interactively. */
required?: boolean
/** Default value used when the user doesn't provide one. */
default?: string
}
/**
* The names of the available helper functions that relate to text capitalization.
*

View File

@@ -1,5 +1,5 @@
import { describe, test, expect, vi, beforeEach } from "vitest"
import { LogLevel, ScaffoldCmdConfig } from "../src/types"
import { LogLevel, ScaffoldCmdConfig, ScaffoldConfig } from "../src/types"
vi.mock("@inquirer/input", () => ({
default: vi.fn(),
@@ -17,6 +17,8 @@ import {
promptForOutput,
promptForTemplates,
promptForMissingConfig,
promptForInputs,
resolveInputs,
isInteractive,
} from "../src/prompts"
@@ -235,4 +237,163 @@ describe("prompts", () => {
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForInputs", () => {
test("prompts for required inputs not in existing data", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
const result = await promptForInputs(
{ author: { message: "Author name", required: true } },
{},
)
expect(result.author).toEqual("John")
expect(inputMock).toHaveBeenCalledOnce()
})
test("skips inputs already provided in data", async () => {
const result = await promptForInputs(
{ author: { message: "Author name", required: true } },
{ author: "Jane" },
)
expect(result.author).toEqual("Jane")
expect(inputMock).not.toHaveBeenCalled()
})
test("applies default value for optional inputs not in data", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{},
)
expect(result.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
test("does not apply default when value already exists", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{ license: "Apache-2.0" },
)
expect(result.license).toEqual("Apache-2.0")
})
test("uses input key as message fallback", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("val")
await promptForInputs(
{ myField: { required: true } },
{},
)
const call = vi.mocked(inputMock).mock.calls[0][0] as { message: string }
expect(call.message).toContain("myField")
})
test("prompts multiple required inputs in order", async () => {
vi.mocked(inputMock)
.mockResolvedValueOnce("John")
.mockResolvedValueOnce("2.0")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
version: { message: "Version", required: true },
},
{},
)
expect(result.author).toEqual("John")
expect(result.version).toEqual("2.0")
expect(inputMock).toHaveBeenCalledTimes(2)
})
test("mixes prompts, defaults, and existing data", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
license: { default: "MIT" },
description: { message: "Desc", required: true },
},
{ description: "My project" },
)
expect(result.author).toEqual("John")
expect(result.license).toEqual("MIT")
expect(result.description).toEqual("My project")
expect(inputMock).toHaveBeenCalledOnce()
})
test("preserves existing data keys not in inputs", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{ extra: "value" },
)
expect(result.extra).toEqual("value")
expect(result.license).toEqual("MIT")
})
test("required input with default pre-fills prompt", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("custom")
await promptForInputs(
{ author: { required: true, default: "Anonymous" } },
{},
)
const call = vi.mocked(inputMock).mock.calls[0][0] as { default?: string }
expect(call.default).toEqual("Anonymous")
})
})
describe("resolveInputs", () => {
test("returns config unchanged when no inputs defined", async () => {
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: { foo: "bar" },
}
const result = await resolveInputs(config)
expect(result.data).toEqual({ foo: "bar" })
})
test("applies defaults in non-interactive mode", async () => {
mockTTY(false)
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: {},
inputs: {
license: { default: "MIT" },
},
}
const result = await resolveInputs(config)
expect(result.data?.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
test("does not overwrite existing data with defaults in non-interactive mode", async () => {
mockTTY(false)
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: { license: "Apache-2.0" },
inputs: {
license: { default: "MIT" },
},
}
const result = await resolveInputs(config)
expect(result.data?.license).toEqual("Apache-2.0")
})
test("prompts for required inputs in interactive mode", async () => {
mockTTY(true)
vi.mocked(inputMock).mockResolvedValueOnce("John")
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: {},
inputs: {
author: { message: "Author", required: true },
},
}
const result = await resolveInputs(config)
expect(result.data?.author).toEqual("John")
})
})
})