diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..feacb45 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +docs/docs/api/ +examples/ +.github/ +CHANGELOG.md diff --git a/README.md b/README.md index fa1d1b9..635629d 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,12 @@ -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.
@@ -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. diff --git a/docs/docs/usage/02-configuration_files.md b/docs/docs/usage/02-configuration_files.md index 72ebe4a..dae2781 100644 --- a/docs/docs/usage/02-configuration_files.md +++ b/docs/docs/usage/02-configuration_files.md @@ -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.*` diff --git a/docs/docs/usage/03-cli.md b/docs/docs/usage/03-cli.md index 2fea068..1995306 100644 --- a/docs/docs/usage/03-cli.md +++ b/docs/docs/usage/03-cli.md @@ -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 diff --git a/docs/docs/usage/04-node.md b/docs/docs/usage/04-node.md index 6e66c31..50a4d46 100644 --- a/docs/docs/usage/04-node.md +++ b/docs/docs/usage/04-node.md @@ -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 subdir?: boolean - data?: Record + data?: Record overwrite?: FileResponse - quiet?: boolean - verbose?: LogLevel + logLevel?: LogLevel dryRun?: boolean helpers?: Record subdirHelper?: DefaultHelpers | string + inputs?: Record beforeWrite?( content: Buffer, rawContent: Buffer, outputPath: string, ): string | Buffer | undefined | Promise } + +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) +}) ``` diff --git a/docs/docs/usage/05-examples.md b/docs/docs/usage/05-examples.md index 8538caa..90c23d5 100644 --- a/docs/docs/usage/05-examples.md +++ b/docs/docs/usage/05-examples.md @@ -31,7 +31,6 @@ title: Examples ### Output - Output file path: - - With `subdir = false` (default): ```text diff --git a/docs/docs/usage/index.md b/docs/docs/usage/index.md index c20d504..64927a5 100644 --- a/docs/docs/usage/index.md +++ b/docs/docs/usage/index.md @@ -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) diff --git a/src/cmd.ts b/src/cmd.ts index edb3180..8aa1bc1 100644 --- a/src/cmd.ts +++ b/src/cmd.ts @@ -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) diff --git a/src/prompts.ts b/src/prompts.ts index 6808e1e..d04d107 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -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 { @@ -59,6 +59,41 @@ export async function promptForTemplates(): Promise { 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, + existingData: Record = {}, +): Promise> { + 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 { + 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 +} diff --git a/src/scaffold.ts b/src/scaffold.ts index f006ab1..27d64c3 100644 --- a/src/scaffold.ts +++ b/src/scaffold.ts @@ -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 { config.output ??= process.cwd() + config = await resolveInputs(config) registerHelpers(config) try { config.data = { name: config.name, ...config.data } diff --git a/src/types.ts b/src/types.ts index 9dd5b9d..b64f9b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -166,10 +166,47 @@ export interface ScaffoldConfig { outputPath: string, ): string | Buffer | undefined | Promise + /** + * 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 + /** @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. * diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index 6255da8..fe8ef67 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -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") + }) + }) })