@nci-gis/js-tmpl 0.0.1-beta.2 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # js-tmpl
2
2
 
3
3
  > A lightweight, deterministic file templating engine built on Handlebars.
4
+ >
4
5
  > An explicit file templating engine for developers who care about **control, predictability, and composability**.
5
6
 
6
7
  [![npm version](https://img.shields.io/npm/v/@nci-gis/js-tmpl.svg)](https://www.npmjs.com/package/@nci-gis/js-tmpl)
@@ -44,6 +45,7 @@ Most templating tools fail in one of two ways:
44
45
  js-tmpl sits intentionally in between.
45
46
 
46
47
  See [Motivation](docs/Motivation.md) - The full story.
48
+
47
49
  See [Design Principles](docs/PRINCIPLES.md) - Core philosophy guiding all decisions.
48
50
 
49
51
  ## Features
@@ -79,7 +81,7 @@ Everything else must be **explicitly specified**:
79
81
  - ✅ **Values file** - Required via `--values` flag or `valuesFile` config
80
82
  - ✅ **Template directory** - Must be in config or defaults to `templates/`
81
83
  - ✅ **Output directory** - Must be in config or defaults to `dist/`
82
- - ✅ **Partials directory** - Must be in config or defaults to `templates.partials/`
84
+ - ✅ **Partials directory** - Must be in config; not loaded if omitted
83
85
 
84
86
  ### Override Auto-Discovery
85
87
 
@@ -137,14 +139,9 @@ templates/
137
139
  **Template content** (`templates/${project.name}/config.json.hbs`):
138
140
 
139
141
  ```handlebars
140
- {
141
- "name": "{{project.name}}",
142
- "version": "{{project.version}}",
143
- "server": {
144
- "port": {{config.port}},
145
- "host": "{{config.host}}"
146
- }
147
- }
142
+ { "name": "{{project.name}}", "version": "{{project.version}}", "server": {
143
+ "port":
144
+ {{config.port}}, "host": "{{config.host}}" } }
148
145
  ```
149
146
 
150
147
  ### 3. Render templates
@@ -163,7 +160,7 @@ import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
163
160
  const config = resolveConfig({
164
161
  valuesFile: './values.yaml',
165
162
  templateDir: './templates',
166
- outDir: './dist'
163
+ outDir: './dist',
167
164
  });
168
165
 
169
166
  await renderDirectory(config);
@@ -190,14 +187,11 @@ CLI arguments
190
187
 
191
188
  ### View Model
192
189
 
193
- Templates receive a view object:
190
+ Templates receive a view object containing your values data plus an `env` object with allowlisted environment variables. The `env` key is reserved — if your values file contains a top-level `env` key, a warning is logged and it is overwritten.
194
191
 
195
- ```javascript
196
- {
197
- ...valuesFromFile, // Your YAML/JSON data
198
- env: process.env // Environment variables
199
- }
200
- ```
192
+ Use `envKeys` and `envPrefix` in your config file or via CLI (`--env-keys`, `--env-prefix`) to control which environment variables are exposed. Without either, `env` is an empty object `{}`.
193
+
194
+ See [docs/API.md](docs/API.md#view-object) for full details, examples, and recommended conventions.
201
195
 
202
196
  Access in templates:
203
197
 
@@ -220,21 +214,26 @@ templates/
220
214
 
221
215
  ### Partial System
222
216
 
223
- **Root partials** (`_name.hbs`):
217
+ Each render pass uses an isolated Handlebars instance. Directory structure maps to partial names:
224
218
 
225
219
  ```text
226
220
  templates.partials/
227
- └── _header.hbs → {{> header}}
221
+ ├── header.hbs → {{> header}}
222
+ ├── components/
223
+ │ ├── button.hbs → {{> components.button}}
224
+ │ └── forms/
225
+ │ └── login.hbs → {{> components.forms.login}}
228
226
  ```
229
227
 
230
- **Namespaced partials** (`@group/name.hbs`):
228
+ **`@` directories** flatten their contents (filename only, no namespace):
231
229
 
232
230
  ```text
233
- templates.partials/
234
- └── @common/
235
- └── metadata.hbs → {{> common.metadata}}
231
+ ├── @helpers/
232
+ │ └── date.hbs → {{> date}}
236
233
  ```
237
234
 
235
+ Duplicate partial names throw an error. Names must be alphanumeric + underscore only. See [API docs](docs/API.md#partial-system) for details.
236
+
238
237
  ## Mental Model
239
238
 
240
239
  > ⚠️ Design note
@@ -257,14 +256,16 @@ js-tmpl render [options]
257
256
 
258
257
  ### Options
259
258
 
260
- | Option | Description | Default |
261
- | ------------------------ | ----------------------- | -------------------- |
262
- | `-c, --values FILE` | Values file (YAML/JSON) | **Required** |
263
- | `-t, --template-dir DIR` | Template directory | `templates` |
264
- | `-o, --out DIR` | Output directory | `dist` |
265
- | `-p, --partials-dir DIR` | Partials directory | `templates.partials` |
266
- | `-x, --ext EXT` | Template extension | `.hbs` |
267
- | `--config-file FILE` | Explicit config file | Auto-discovered |
259
+ | Option | Description | Default |
260
+ | ------------------------ | --------------------------------------- | --------------- |
261
+ | `-c, --values FILE` | Values file (YAML/JSON) | **Required** |
262
+ | `-t, --template-dir DIR` | Template directory | `templates` |
263
+ | `-o, --out DIR` | Output directory | `dist` |
264
+ | `-p, --partials-dir DIR` | Partials directory | None (skipped) |
265
+ | `-x, --ext EXT` | Template extension | `.hbs` |
266
+ | `--config-file FILE` | Explicit config file | Auto-discovered |
267
+ | `--env-keys KEYS` | Comma-separated env var names to expose | None |
268
+ | `--env-prefix PREFIX` | Auto-include env vars with this prefix | None |
268
269
 
269
270
  ### Examples of Usage
270
271
 
@@ -278,79 +279,13 @@ js-tmpl render \
278
279
  --template-dir ./my-templates \
279
280
  --out ./output
280
281
 
281
- # Multi-environment
282
- NODE_ENV=production js-tmpl render --values prod-values.yaml
282
+ # Multi-environment (allowlist NODE_ENV to use it in templates)
283
+ NODE_ENV=production js-tmpl render --values prod-values.yaml --env-keys NODE_ENV
283
284
  ```
284
285
 
285
286
  ## Programmatic API
286
287
 
287
- See [docs/API.md](docs/API.md) for comprehensive API documentation.
288
-
289
- ### Import
290
-
291
- ```javascript
292
- import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
293
- ```
294
-
295
- ### resolveConfig(options)
296
-
297
- Resolves configuration with proper precedence.
298
-
299
- **Parameters:**
300
-
301
- - `options.valuesFile` (string, required) - Path to values file
302
- - `options.templateDir` (string) - Template directory path
303
- - `options.partialsDir` (string) - Partials directory path
304
- - `options.outDir` (string) - Output directory path
305
- - `options.extname` (string) - Template file extension
306
- - `options.configFile` (string) - Explicit config file path
307
-
308
- **Returns:** Resolved configuration object
309
-
310
- ### renderDirectory(config)
311
-
312
- Executes the rendering process.
313
-
314
- **Parameters:**
315
-
316
- - `config` (object) - Configuration from `resolveConfig`
317
-
318
- **Returns:** Promise that resolves when rendering completes
319
-
320
- ### Example
321
-
322
- ```javascript
323
- import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
324
-
325
- const config = resolveConfig({
326
- valuesFile: './values.yaml',
327
- templateDir: './templates',
328
- partialsDir: './partials',
329
- outDir: './dist'
330
- });
331
-
332
- await renderDirectory(config);
333
- console.log('✅ Rendering complete');
334
- ```
335
-
336
- ## Project Configuration
337
-
338
- Create `js-tmpl.config.yaml` in your project root:
339
-
340
- ```yaml
341
- templateDir: templates
342
- partialsDir: templates.partials
343
- outDir: dist
344
- extname: .hbs
345
- ```
346
-
347
- Auto-discovered config files (in order):
348
-
349
- 1. `js-tmpl.config.yaml`
350
- 2. `js-tmpl.config.yml`
351
- 3. `js-tmpl.config.json`
352
- 4. `config/js-tmpl.yaml`
353
- 5. `config/js-tmpl.json`
288
+ See [docs/API.md](docs/API.md) for the complete API reference — parameters, return types, config file format, and advanced usage.
354
289
 
355
290
  ## Examples
356
291
 
@@ -363,26 +298,14 @@ See [examples/yaml-templates/](examples/yaml-templates/) for a complete working
363
298
 
364
299
  ## Testing
365
300
 
366
- This project has comprehensive test coverage:
367
-
368
- - 156 tests
369
- - 99.8% line coverage
370
- - 99.7% branch coverage
301
+ This project has comprehensive automated test coverage across unit and integration suites.
302
+ Current coverage remains above 99% line coverage with high branch coverage as well.
371
303
 
372
304
  See [tests/README.md](tests/README.md) for testing documentation.
373
305
 
374
306
  ## Development Principles
375
307
 
376
- js-tmpl follows strict design principles:
377
-
378
- 1. **Engine First, CLI Second** - Programmatic API is primary
379
- 2. **Explicit Over Implicit** - No magic or hidden conventions
380
- 3. **Deterministic Over Clever** - Predictable behavior
381
- 4. **Separation of Concerns** - Each layer has one responsibility
382
- 5. **Composable Over Monolithic** - Small, focused functions
383
- 6. **Simple Over Feature-Rich** - Minimal API surface
384
-
385
- See [docs/PRINCIPLES.md](docs/PRINCIPLES.md) for details.
308
+ js-tmpl follows six core design principles — engine-first, explicit, deterministic, separated, composable, and simple. See [docs/PRINCIPLES.md](docs/PRINCIPLES.md) for the full philosophy.
386
309
 
387
310
  ## Roadmap
388
311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nci-gis/js-tmpl",
3
- "version": "0.0.1-beta.2",
3
+ "version": "0.0.1",
4
4
  "description": "The pure JavaScript templating engine that uses handlebars.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,16 +12,22 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
+ "prepare": "husky",
15
16
  "test": "node --test $(find tests -name '*.test.js')",
16
17
  "test:watch": "node --test --watch $(find tests -name '*.test.js')",
17
18
  "test:coverage": "node --experimental-test-coverage --test $(find tests -name '*.test.js')",
19
+ "format": "prettier --write src/ tests/",
20
+ "format:check": "prettier --check src/ tests/",
18
21
  "lint": "eslint src/ tests/",
19
22
  "lint:fix": "eslint src/ tests/ --fix",
20
23
  "build": "echo 'No build step required for pure JS library'",
21
24
  "start": "node src/cli/main.js",
22
25
  "help": "node src/cli/main.js --help",
23
26
  "dev": "node src/cli/main.js",
24
- "tool": "node src/cli/main.js"
27
+ "tool": "node src/cli/main.js",
28
+ "docs:check-links": "remark --frail --quiet *.md docs/*.md tests/README.md examples/**/*.md",
29
+ "docs:check-exports": "node scripts/check-doc-exports.js",
30
+ "docs:check": "pnpm docs:check-links && pnpm docs:check-exports"
25
31
  },
26
32
  "keywords": [
27
33
  "js",
@@ -57,7 +63,28 @@
57
63
  },
58
64
  "devDependencies": {
59
65
  "@eslint/js": "^9.39.2",
66
+ "@types/js-yaml": "^4.0.9",
67
+ "@types/node": "^25.5.2",
60
68
  "eslint": "^9.39.2",
61
- "eslint-plugin-simple-import-sort": "^12.1.1"
69
+ "eslint-plugin-simple-import-sort": "^12.1.1",
70
+ "husky": "^9.1.7",
71
+ "lint-staged": "^16.4.0",
72
+ "prettier": "^3.8.1",
73
+ "remark-cli": "^12.0.1",
74
+ "remark-validate-links": "^13.1.0",
75
+ "typescript": "^6.0.2"
76
+ },
77
+ "lint-staged": {
78
+ "src/**/*.js": [
79
+ "prettier --write",
80
+ "eslint --fix"
81
+ ],
82
+ "tests/**/*.js": [
83
+ "prettier --write",
84
+ "eslint --fix"
85
+ ],
86
+ "**/*.md": [
87
+ "prettier --write"
88
+ ]
62
89
  }
63
90
  }
package/src/cli/args.js CHANGED
@@ -1,49 +1,66 @@
1
1
  /**
2
2
  * Parse CLI arguments.
3
- *
3
+ *
4
4
  * @param {string[]} args
5
- * @returns {Record<string, any>}
5
+ * @returns {import('../types.js').CliArgs}
6
6
  */
7
7
  export function parseArgs(args) {
8
- const opts = { command: "render" };
8
+ /** @type {import('../types.js').CliArgs} */
9
+ const opts = { command: 'render' };
9
10
 
10
11
  let i = 0;
11
12
  while (i < args.length) {
12
13
  const a = args[i];
13
14
 
14
15
  switch (a) {
15
- case "render":
16
- opts.command = "render";
16
+ case '-h':
17
+ case '--help':
18
+ opts.command = 'help';
17
19
  break;
18
20
 
19
- case "-t":
20
- case "--template-dir":
21
+ case 'render':
22
+ opts.command = 'render';
23
+ break;
24
+
25
+ case '-t':
26
+ case '--template-dir':
21
27
  opts.templateDir = args[++i];
22
28
  break;
23
29
 
24
- case "-c":
25
- case "--values":
30
+ case '-c':
31
+ case '--values':
26
32
  opts.valuesFile = args[++i];
27
33
  break;
28
34
 
29
- case "-o":
30
- case "--out":
35
+ case '-o':
36
+ case '--out':
31
37
  opts.outDir = args[++i];
32
38
  break;
33
39
 
34
- case "-p":
35
- case "--partials-dir":
40
+ case '-p':
41
+ case '--partials-dir':
36
42
  opts.partialsDir = args[++i];
37
43
  break;
38
44
 
39
- case "--config-file":
45
+ case '--config-file':
40
46
  opts.configFile = args[++i];
41
47
  break;
42
48
 
43
- case "-x":
44
- case "--ext":
49
+ case '-x':
50
+ case '--ext':
45
51
  opts.extname = args[++i];
46
52
  break;
53
+
54
+ case '--env-keys':
55
+ opts.envKeys = args[++i]
56
+ .split(',')
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ break;
60
+
61
+ case '--env-prefix':
62
+ opts.envPrefix = args[++i];
63
+ break;
47
64
  }
48
65
  // next argument:
49
66
  i++;
package/src/cli/main.js CHANGED
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { resolveConfig } from "../config/resolver.js";
4
- import { renderDirectory } from "../engine/renderDirectory.js";
5
- import { parseArgs } from "./args.js";
3
+ import { resolveConfig } from '../config/resolver.js';
4
+ import { renderDirectory } from '../engine/renderDirectory.js';
5
+ import { parseArgs } from './args.js';
6
+ import { USAGE } from './usage.js';
6
7
 
8
+ /** @returns {Promise<void>} */
7
9
  export async function main() {
8
10
  const cli = parseArgs(process.argv);
9
11
 
10
- if (cli.command !== "render") {
11
- console.error("Unknown command:", cli.command);
12
+ if (cli.command === 'help') {
13
+ console.log(USAGE);
14
+ return;
15
+ }
16
+
17
+ if (cli.command !== 'render') {
18
+ console.error('Unknown command:', cli.command);
12
19
  process.exit(1);
13
20
  }
14
21
 
@@ -16,13 +23,19 @@ export async function main() {
16
23
 
17
24
  await renderDirectory(cfg);
18
25
 
19
- console.log("✔ js-tmpl completed.");
26
+ console.log('✔ js-tmpl completed.');
20
27
  }
21
28
 
22
29
  // Execute main function if this file is run directly
23
- try {
24
- await main();
25
- } catch (error) {
26
- console.error("Error:", error);
27
- process.exit(1);
30
+ const isDirectRun =
31
+ process.argv[1] &&
32
+ import.meta.url.endsWith(process.argv[1].replaceAll('\\', '/'));
33
+
34
+ if (isDirectRun) {
35
+ try {
36
+ await main();
37
+ } catch (error) {
38
+ console.error('Error:', error);
39
+ process.exit(1);
40
+ }
28
41
  }
@@ -0,0 +1,16 @@
1
+ /** CLI usage text. */
2
+ export const USAGE = `Usage: js-tmpl [command] [options]
3
+
4
+ Commands:
5
+ render Render templates (default)
6
+
7
+ Options:
8
+ -t, --template-dir <dir> Template directory (default: templates)
9
+ -c, --values <file> Values file (YAML or JSON) [required]
10
+ -o, --out <dir> Output directory (default: dist)
11
+ -p, --partials-dir <dir> Partials directory
12
+ -x, --ext <ext> Template extension (default: .hbs)
13
+ --config-file <file> Project config file
14
+ --env-keys <keys> Comma-separated env var names to expose (default: none)
15
+ --env-prefix <prefix> Auto-include env vars with this prefix (e.g. JS_TMPL_)
16
+ -h, --help Show this help message`;
@@ -1,8 +1,10 @@
1
1
  export const DEFAULTS = {
2
- templateDir: "templates",
3
- partialsDir: "templates.partials",
4
- valuesDir: "",
5
- valuesFile: "",
6
- outDir: "dist",
7
- extname: ".hbs",
2
+ templateDir: 'templates',
3
+ partialsDir: '',
4
+ valuesDir: '',
5
+ valuesFile: '',
6
+ outDir: 'dist',
7
+ extname: '.hbs',
8
+ envKeys: [],
9
+ envPrefix: '',
8
10
  };
@@ -1,30 +1,30 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
 
4
- import YAML from "js-yaml";
4
+ import YAML from 'js-yaml';
5
5
 
6
6
  /**
7
7
  * Load YAML or JSON values.
8
8
  *
9
9
  * @param {string} filePath Path to YAML or JSON file.
10
- * @returns {object} Parsed values.
10
+ * @returns {Record<string, unknown>} Parsed values.
11
11
  */
12
12
  export function loadYamlOrJson(filePath) {
13
13
  // Check if file exists before attempting to read
14
14
  if (!fs.existsSync(filePath)) {
15
15
  throw new Error(
16
16
  `Values file not found: ${filePath}\n` +
17
- "Check that the file exists and the path is correct."
17
+ 'Check that the file exists and the path is correct.',
18
18
  );
19
19
  }
20
20
 
21
- const raw = fs.readFileSync(filePath, "utf8");
21
+ const raw = fs.readFileSync(filePath, 'utf8');
22
22
 
23
23
  if (/\.ya?ml$/i.test(filePath)) {
24
- return YAML.load(raw) || {};
24
+ return /** @type {Record<string, unknown>} */ (YAML.load(raw) || {});
25
25
  }
26
26
  if (/\.json$/i.test(filePath)) {
27
- return JSON.parse(raw);
27
+ return /** @type {Record<string, unknown>} */ (JSON.parse(raw));
28
28
  }
29
29
 
30
30
  throw new Error(`Unsupported values file: ${filePath}`);
@@ -35,31 +35,45 @@ export function loadYamlOrJson(filePath) {
35
35
  *
36
36
  * @param {string} cwd Current working directory.
37
37
  * @param {string} [explicitFile] Explicit config file path.
38
- * @returns {object|null} Parsed config object or null if not found.
38
+ * @returns {Record<string, unknown> | null} Parsed config object or null if not found.
39
39
  */
40
40
  export function loadProjectConfig(cwd, explicitFile) {
41
+ if (explicitFile) {
42
+ const abs = path.isAbsolute(explicitFile)
43
+ ? explicitFile
44
+ : path.join(cwd, explicitFile);
45
+ if (!fs.existsSync(abs)) {
46
+ throw new Error(
47
+ `Config file not found: ${abs}\n` +
48
+ 'The --config-file path was explicitly provided but does not exist.',
49
+ );
50
+ }
51
+ }
52
+
41
53
  const candidates = explicitFile
42
54
  ? [explicitFile]
43
55
  : [
44
- "js-tmpl.config.yaml",
45
- "js-tmpl.config.yml",
46
- "js-tmpl.config.json",
47
- path.join("config", "js-tmpl.yaml"),
48
- path.join("config", "js-tmpl.json"),
56
+ 'js-tmpl.config.yaml',
57
+ 'js-tmpl.config.yml',
58
+ 'js-tmpl.config.json',
59
+ path.join('config', 'js-tmpl.yaml'),
60
+ path.join('config', 'js-tmpl.json'),
49
61
  ];
50
62
 
51
63
  for (const rel of candidates) {
52
64
  const abs = path.isAbsolute(rel) ? rel : path.join(cwd, rel);
53
- if (!fs.existsSync(abs)) {continue;}
65
+ if (!fs.existsSync(abs)) {
66
+ continue;
67
+ }
54
68
 
55
- const raw = fs.readFileSync(abs, "utf8");
69
+ const raw = fs.readFileSync(abs, 'utf8');
56
70
 
57
71
  if (/\.ya?ml$/i.test(abs)) {
58
- return YAML.load(raw) || {};
72
+ return /** @type {Record<string, unknown>} */ (YAML.load(raw) || {});
59
73
  }
60
74
 
61
75
  if (/\.json$/i.test(abs)) {
62
- return JSON.parse(raw);
76
+ return /** @type {Record<string, unknown>} */ (JSON.parse(raw));
63
77
  }
64
78
  }
65
79
 
@@ -1,9 +1,9 @@
1
- import path from "node:path";
2
- import process from "node:process";
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
3
 
4
- import { DEFAULTS } from "./defaults.js";
5
- import { loadProjectConfig,loadYamlOrJson } from "./loader.js";
6
- import { buildView } from "./view.js";
4
+ import { DEFAULTS } from './defaults.js';
5
+ import { loadProjectConfig, loadYamlOrJson } from './loader.js';
6
+ import { buildView, pickEnv } from './view.js';
7
7
 
8
8
  /**
9
9
  * Resolve valuesFile path based on valuesDir
@@ -34,9 +34,9 @@ function resolveValuesFilePath(valuesFile, valuesDir, cwd) {
34
34
  * Resolve final config using:
35
35
  * defaults < projectConfig < cliArgs
36
36
  *
37
- * @param {object} cli - CLI arguments
38
- * @param {string} cwd - Current working directory
39
- * @returns {object} - Resolved configuration
37
+ * @param {import('../types.js').CliArgs} cli - CLI arguments
38
+ * @param {string} [cwd] - Current working directory
39
+ * @returns {import('../types.js').TemplateConfig} - Resolved configuration
40
40
  */
41
41
  export function resolveConfig(cli, cwd = process.cwd()) {
42
42
  const projectConfig = loadProjectConfig(cwd, cli.configFile);
@@ -47,15 +47,16 @@ export function resolveConfig(cli, cwd = process.cwd()) {
47
47
  ...cli,
48
48
  };
49
49
 
50
+ /** @param {string} p */
50
51
  const abs = (p) => (path.isAbsolute(p) ? p : path.join(cwd, p));
51
52
 
52
53
  // Validate valuesFile is provided
53
54
  if (!mergedConfig.valuesFile) {
54
55
  throw new Error(
55
56
  'Missing required configuration: valuesFile\n' +
56
- 'Provide via:\n' +
57
- ' - CLI: --values path/to/values.yaml\n' +
58
- ' - Config: valuesFile: "path/to/values.yaml" in js-tmpl.config.yaml'
57
+ 'Provide via:\n' +
58
+ ' - CLI: --values path/to/values.yaml\n' +
59
+ ' - Config: valuesFile: "path/to/values.yaml" in js-tmpl.config.yaml',
59
60
  );
60
61
  }
61
62
 
@@ -63,16 +64,24 @@ export function resolveConfig(cli, cwd = process.cwd()) {
63
64
  const valuesFilePath = resolveValuesFilePath(
64
65
  mergedConfig.valuesFile,
65
66
  mergedConfig.valuesDir,
66
- cwd
67
+ cwd,
67
68
  );
68
69
 
69
70
  const values = loadYamlOrJson(valuesFilePath);
70
71
 
72
+ const hasEnvConfig = mergedConfig.envKeys?.length || mergedConfig.envPrefix;
73
+ const env = hasEnvConfig
74
+ ? pickEnv({
75
+ keys: mergedConfig.envKeys || [],
76
+ prefix: mergedConfig.envPrefix || '',
77
+ })
78
+ : {};
79
+
71
80
  return {
72
81
  templateDir: abs(mergedConfig.templateDir),
73
- partialsDir: abs(mergedConfig.partialsDir),
82
+ partialsDir: mergedConfig.partialsDir ? abs(mergedConfig.partialsDir) : '',
74
83
  outDir: abs(mergedConfig.outDir),
75
84
  extname: mergedConfig.extname,
76
- view: buildView(values),
85
+ view: buildView(values, env),
77
86
  };
78
87
  }
@@ -1,6 +1,53 @@
1
- export function buildView(values, env = process.env) {
1
+ /**
2
+ * Build the view object by merging values with environment data.
3
+ *
4
+ * **Reserved key:** `env` is always set to the provided environment object.
5
+ * If `values` contains a top-level `env` key, a warning is logged and
6
+ * the key is overwritten.
7
+ *
8
+ * @param {Record<string, unknown>} values
9
+ * @param {Record<string, string>} [env]
10
+ * @returns {Record<string, unknown>}
11
+ */
12
+ export function buildView(values, env = {}) {
13
+ if ('env' in values) {
14
+ console.warn(
15
+ 'Warning: "env" is a reserved key in js-tmpl and will be overwritten.\n' +
16
+ 'Rename the "env" key in your values file to avoid this.',
17
+ );
18
+ }
2
19
  return {
3
20
  ...values,
4
21
  env,
5
22
  };
6
23
  }
24
+
25
+ /**
26
+ * Pick environment variables by explicit keys and/or prefix.
27
+ *
28
+ * @param {object} options
29
+ * @param {string[]} options.keys - Explicit key names to include.
30
+ * @param {string} options.prefix - Include all vars starting with this prefix.
31
+ * @param {Record<string, string | undefined>} [source] - Env source (default: process.env).
32
+ * @returns {Record<string, string>}
33
+ */
34
+ export function pickEnv({ keys = [], prefix = '' }, source = process.env) {
35
+ /** @type {Record<string, string>} */
36
+ const result = {};
37
+
38
+ for (const k of keys) {
39
+ if (k in source) {
40
+ result[k] = /** @type {string} */ (source[k]);
41
+ }
42
+ }
43
+
44
+ if (prefix) {
45
+ for (const k of Object.keys(source)) {
46
+ if (k.startsWith(prefix)) {
47
+ result[k] = /** @type {string} */ (source[k]);
48
+ }
49
+ }
50
+ }
51
+
52
+ return result;
53
+ }
@@ -1,12 +1,16 @@
1
- import fs from "node:fs/promises";
1
+ import fs from 'node:fs/promises';
2
2
 
3
- import Handlebars from "handlebars";
3
+ import Handlebars from 'handlebars';
4
4
 
5
5
  /**
6
6
  * Render template file with Handlebars & view object.
7
+ * @param {string} filePath
8
+ * @param {Record<string, unknown>} view
9
+ * @param {typeof Handlebars} [hbs] - Scoped Handlebars instance; falls back to global
10
+ * @returns {Promise<string>}
7
11
  */
8
- export async function renderContent(filePath, view) {
9
- const raw = await fs.readFile(filePath, "utf8");
10
- const compile = Handlebars.compile(raw);
12
+ export async function renderContent(filePath, view, hbs) {
13
+ const raw = await fs.readFile(filePath, 'utf8');
14
+ const compile = (hbs || Handlebars).compile(raw);
11
15
  return compile(view);
12
16
  }
@@ -1,41 +1,117 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
3
 
4
- import Handlebars from "handlebars";
4
+ const VALID_SEGMENT = /^\w+$/;
5
5
 
6
6
  /**
7
- * Register partials from a directory.
8
- * - root partials use prefix "_"
9
- * - group partials under "@group"
7
+ * Validate a partial name segment (directory or file basename).
8
+ * @param {string[]} segments
9
+ * @param {string} filePath
10
10
  */
11
- export async function registerPartials(partialsDir, ext = ".hbs") {
12
- const entries = await fs.readdir(partialsDir, { withFileTypes: true });
11
+ function validateSegments(segments, filePath) {
12
+ if (!VALID_SEGMENT.test(segments.join(''))) {
13
+ throw new Error(
14
+ `Invalid partial name segment '${segments.join('>')}' in ${filePath} — only alphanumeric and underscore allowed`,
15
+ );
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Derive a partial entry from a file path.
21
+ *
22
+ * @param {string} partialsDir - Root partials directory
23
+ * @param {string} ext - Template extension (e.g. ".hbs")
24
+ * @param {string} filePath - Relative path from partialsDir
25
+ * @param {boolean} isFlat - If true, register by filename only; otherwise namespace by path
26
+ * @returns {{ name: string, source: string }}
27
+ */
28
+ function processPartialFile(partialsDir, ext, filePath, isFlat) {
29
+ const abs = path.join(partialsDir, filePath);
30
+
31
+ // Flat by filename: "name.hbs" → "name"
32
+ if (isFlat) {
33
+ const name = path.basename(filePath, ext);
34
+ validateSegments([name], abs);
35
+ return { name, source: abs };
36
+ }
37
+
38
+ // Namespace by path, e.g.:
39
+ // - "dir/name.hbs" → "dir.name"
40
+ // - "dir/sub/name.hbs" → "dir.sub.name"
41
+ // - "name.hbs" → "name" (no nesting).
42
+ const segments = filePath.slice(0, -ext.length).split(path.sep);
43
+ validateSegments(segments, abs);
44
+ return { name: segments.join('.'), source: abs };
45
+ }
46
+
47
+ /**
48
+ * Scan partialsDir recursively and collect all partial entries.
49
+ * @param {string} partialsDir
50
+ * @param {string} ext
51
+ * @returns {Promise<Array<{ name: string, source: string }>>}
52
+ */
53
+ async function scanPartialFiles(partialsDir, ext) {
54
+ const allFiles = await fs.readdir(partialsDir, { recursive: true });
55
+
56
+ return allFiles
57
+ .filter((f) => f.endsWith(ext))
58
+ .map((f) => {
59
+ const isFlat = f.startsWith('@');
60
+ return processPartialFile(partialsDir, ext, f, isFlat);
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Check for duplicate partial names and throw if found.
66
+ * @param {Array<{ name: string, source: string }>} entries
67
+ * @param {string} partialsDir
68
+ */
69
+ function checkDuplicates(entries, partialsDir) {
70
+ /** @type {Map<string, string>} */
71
+ const seen = new Map();
13
72
 
14
73
  for (const entry of entries) {
15
- const abs = path.join(partialsDir, entry.name);
16
-
17
- if (entry.isDirectory()) {
18
- // handle namespacing
19
- if (entry.name.startsWith("@")) {
20
- const group = entry.name.slice(1);
21
- const groupFiles = await fs.readdir(abs);
22
-
23
- for (const f of groupFiles) {
24
- if (!f.endsWith(ext)) {continue;}
25
- const key = path.basename(f, ext);
26
- const name = `${group}.${key}`;
27
- const content = await fs.readFile(path.join(abs, f), "utf8");
28
- Handlebars.registerPartial(name, content);
29
- }
30
- }
31
- continue;
74
+ const existing = seen.get(entry.name);
75
+ if (existing) {
76
+ const rel1 = path.relative(partialsDir, existing);
77
+ const rel2 = path.relative(partialsDir, entry.source);
78
+ throw new Error(
79
+ `Duplicate partial name '${entry.name}' — registered by both:\n` +
80
+ ` - ${rel1}\n` +
81
+ ` - ${rel2}\n` +
82
+ `Use namespaced directories to avoid collisions.`,
83
+ );
32
84
  }
85
+ seen.set(entry.name, entry.source);
86
+ }
87
+ }
33
88
 
34
- // root partial
35
- if (entry.name.startsWith("_") && entry.name.endsWith(ext)) {
36
- const content = await fs.readFile(abs, "utf8");
37
- const key = entry.name.slice(1, -ext.length);
38
- Handlebars.registerPartial(key, content);
39
- }
89
+ /**
90
+ * Register partials from a directory onto a Handlebars instance.
91
+ *
92
+ * Naming conventions:
93
+ * - `name.hbs` in root → "name"
94
+ * - `dir/name.hbs` → "dir.name" (namespaced by directory path)
95
+ * - `@dir/` at root → flatten entire subtree (filename only)
96
+ *
97
+ * Throws on duplicate partial names or invalid name segments.
98
+ * Skips silently if partialsDir is falsy. Throws if the directory does not exist.
99
+ *
100
+ * @param {string} partialsDir - Path to partials directory
101
+ * @param {string} ext - Template file extension (e.g. ".hbs")
102
+ * @param {import('handlebars')} hbs - Handlebars instance to register partials on
103
+ */
104
+ export async function registerPartials(partialsDir, ext, hbs) {
105
+ if (!partialsDir) {
106
+ return;
107
+ }
108
+
109
+ const allPartials = await scanPartialFiles(partialsDir, ext);
110
+
111
+ checkDuplicates(allPartials, partialsDir);
112
+
113
+ for (const p of allPartials) {
114
+ const content = await fs.readFile(p.source, 'utf8');
115
+ hbs.registerPartial(p.name, content);
40
116
  }
41
117
  }
@@ -1,9 +1,12 @@
1
- import path from "node:path";
1
+ import path from 'node:path';
2
2
 
3
- import { getNested } from "../utils/object.js";
3
+ import { getNested } from '../utils/object.js';
4
4
 
5
5
  /**
6
6
  * Render `${var}` placeholders in each segment of the relPath.
7
+ * @param {string} relPath
8
+ * @param {Record<string, unknown>} view
9
+ * @returns {string}
7
10
  */
8
11
  export function renderPath(relPath, view) {
9
12
  const segments = relPath.split(path.sep);
@@ -12,8 +15,8 @@ export function renderPath(relPath, view) {
12
15
  //NOSONAR -- ignore S5842: Regular expression is safe here
13
16
  seg.replace(/\$\{([^}]+)\}/g, (_, expr) => {
14
17
  const v = getNested(view, expr.trim());
15
- return String(v ?? "");
16
- })
18
+ return String(v ?? '');
19
+ }),
17
20
  );
18
21
 
19
22
  return path.join(...rendered);
@@ -1,18 +1,24 @@
1
- import path from "node:path";
1
+ import path from 'node:path';
2
2
 
3
- import { ensureDir, writeFileSafe } from "../utils/fs.js";
4
- import { renderContent } from "./contentRenderer.js";
5
- import { registerPartials } from "./partials.js";
6
- import { renderPath } from "./pathRenderer.js";
7
- import { walkTemplateTree } from "./treeWalker.js";
3
+ import Handlebars from 'handlebars';
4
+
5
+ import { ensureDir, writeFileSafe } from '../utils/fs.js';
6
+ import { renderContent } from './contentRenderer.js';
7
+ import { registerPartials } from './partials.js';
8
+ import { renderPath } from './pathRenderer.js';
9
+ import { walkTemplateTree } from './treeWalker.js';
8
10
 
9
11
  /**
10
12
  * Main rendering orchestrator.
13
+ * @param {import('../types.js').TemplateConfig} cfg
14
+ * @param {typeof import('handlebars')} [hbs] - Optional Handlebars instance (creates an isolated one if omitted)
15
+ * @returns {Promise<void>}
11
16
  */
12
- export async function renderDirectory(cfg) {
17
+ export async function renderDirectory(cfg, hbs) {
13
18
  const { templateDir, partialsDir, outDir, view, extname } = cfg;
14
19
 
15
- await registerPartials(partialsDir, extname);
20
+ hbs = hbs || Handlebars.create();
21
+ await registerPartials(partialsDir, extname, hbs);
16
22
 
17
23
  const files = await walkTemplateTree(templateDir, extname);
18
24
 
@@ -20,10 +26,10 @@ export async function renderDirectory(cfg) {
20
26
  const relRendered = renderPath(file.relPath, view);
21
27
  const target = path.join(
22
28
  outDir,
23
- relRendered.replace(new RegExp(`${extname}$`), "")
29
+ relRendered.replace(new RegExp(`${extname}$`), ''),
24
30
  );
25
31
 
26
- const content = await renderContent(file.absPath, view);
32
+ const content = await renderContent(file.absPath, view, hbs);
27
33
 
28
34
  await ensureDir(path.dirname(target));
29
35
  await writeFileSafe(target, content);
@@ -1,22 +1,28 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
3
 
4
4
  /**
5
5
  * BFS async folder walker.
6
+ * @param {string} rootDir
7
+ * @param {string} [ext]
8
+ * @param {Array<string | RegExp>} [ignore]
9
+ * @returns {Promise<import('../types.js').TemplateFile[]>}
6
10
  */
7
- export async function walkTemplateTree(rootDir, ext = ".hbs", ignore = []) {
11
+ export async function walkTemplateTree(rootDir, ext = '.hbs', ignore = []) {
8
12
  const results = [];
9
- const queue = [""];
13
+ const queue = [''];
10
14
 
11
15
  while (queue.length) {
12
- const rel = queue.shift();
16
+ const rel = /** @type {string} */ (queue.shift());
13
17
  const abs = path.join(rootDir, rel);
14
18
  const stat = await fs.stat(abs);
15
19
 
16
20
  if (stat.isDirectory()) {
17
- const items = await fs.readdir(abs);
21
+ const items = (await fs.readdir(abs)).sort();
18
22
  for (const name of items) {
19
- if (ignore.some((i) => matchIgnore(name, i))) {continue;}
23
+ if (ignore.some((i) => matchIgnore(name, i))) {
24
+ continue;
25
+ }
20
26
  queue.push(rel ? path.join(rel, name) : name);
21
27
  }
22
28
  } else if (path.extname(abs) === ext) {
@@ -27,6 +33,11 @@ export async function walkTemplateTree(rootDir, ext = ".hbs", ignore = []) {
27
33
  return results;
28
34
  }
29
35
 
36
+ /**
37
+ * @param {string} name
38
+ * @param {string | RegExp} rule
39
+ * @returns {boolean}
40
+ */
30
41
  function matchIgnore(name, rule) {
31
42
  return rule instanceof RegExp ? rule.test(name) : rule === name;
32
43
  }
package/src/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export * from "./config/resolver.js";
2
- export * from "./engine/renderDirectory.js";
1
+ export * from './config/resolver.js';
2
+ export * from './engine/renderDirectory.js';
package/src/types.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @typedef {object} CliArgs
3
+ * @property {string} [command]
4
+ * @property {string} [templateDir]
5
+ * @property {string} [valuesFile]
6
+ * @property {string} [outDir]
7
+ * @property {string} [partialsDir]
8
+ * @property {string} [configFile]
9
+ * @property {string} [extname]
10
+ * @property {string} [valuesDir]
11
+ * @property {string[]} [envKeys]
12
+ * @property {string} [envPrefix]
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} TemplateConfig
17
+ * @property {string} templateDir
18
+ * @property {string} partialsDir
19
+ * @property {string} outDir
20
+ * @property {string} extname
21
+ * @property {Record<string, unknown>} view
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} TemplateFile
26
+ * @property {string} absPath
27
+ * @property {string} relPath
28
+ */
29
+
30
+ export {}; //NOSONAR
package/src/utils/fs.js CHANGED
@@ -1,5 +1,5 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
3
 
4
4
  /** Safe mkdir -p
5
5
  * @param {string} dir Directory path to create
@@ -13,7 +13,7 @@ export async function ensureDir(dir) {
13
13
  * @param {string} content Content to write
14
14
  */
15
15
  export async function writeFileSafe(file, content) {
16
- await fs.writeFile(file, content, "utf8");
16
+ await fs.writeFile(file, content, 'utf8');
17
17
  }
18
18
 
19
19
  /** Resolve path relative to cwd
@@ -35,7 +35,8 @@ export function resolvePath(p, cwd = process.cwd()) {
35
35
  * @returns {string} Absolute path.
36
36
  */
37
37
  export function safeResolvePath(...segments) {
38
- const isAbsolute = segments ? segments[0] && path.isAbsolute(segments[0]) : false;
38
+ const isAbsolute =
39
+ segments.length > 0 && segments[0] && path.isAbsolute(segments[0]);
39
40
  if (isAbsolute) {
40
41
  return path.resolve(...segments);
41
42
  }
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Retrieve nested value: getNested(obj, "a.b.c")
3
+ * @param {Record<string, unknown>} obj
4
+ * @param {string} key
5
+ * @returns {unknown}
3
6
  */
4
7
  export function getNested(obj, key) {
5
- console.log("getNested", obj, key);
6
-
7
8
  return key
8
- .split(".")
9
- .reduce((acc, k) => (acc?.[k] ?? undefined), obj);
9
+ .split('.')
10
+ .reduce(
11
+ (/** @type {unknown} */ acc, /** @type {string} */ k) =>
12
+ /** @type {Record<string, unknown>} */ (acc)?.[k] ?? undefined,
13
+ obj,
14
+ );
10
15
  }