@nci-gis/js-tmpl 0.0.1 → 0.1.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
@@ -51,7 +51,8 @@ See [Design Principles](docs/PRINCIPLES.md) - Core philosophy guiding all decisi
51
51
  ## Features
52
52
 
53
53
  - 🎯 **Dynamic File Paths** - Use `${var}` placeholders in paths and filenames
54
- - 🧩 **Handlebars Templates** - Full Handlebars feature set (loops, conditionals, helpers)
54
+ - 🧩 **Handlebars Templates** - Full Handlebars feature set (loops, conditionals, partials)
55
+ - 🛠️ **Custom Helpers** - `registerHelpers` on a scoped instance, validated and atomic
55
56
  - 📦 **Partial System** - Reusable components with root and namespaced partials
56
57
  - ⚙️ **Flexible Configuration** - CLI args > project config > defaults
57
58
  - 🌲 **BFS Tree Walking** - Async, non-blocking template discovery
@@ -78,10 +79,11 @@ js-tmpl will search for a project config file in **exactly these locations**, in
78
79
 
79
80
  Everything else must be **explicitly specified**:
80
81
 
81
- - ✅ **Values file** - Required via `--values` flag or `valuesFile` config
82
- - ✅ **Template directory** - Must be in config or defaults to `templates/`
83
- - ✅ **Output directory** - Must be in config or defaults to `dist/`
84
- - ✅ **Partials directory** - Must be in config; not loaded if omitted
82
+ - ✅ **Values file** — Optional via `--values` flag or `valuesFile` config (VP-8)
83
+ - ✅ **Values directory** — Optional via `--values-dir` flag or `valuesDir` config (VP-6)
84
+ - ✅ **Template directory** — Must be in config or defaults to `templates/`
85
+ - ✅ **Output directory** — Must be in config or defaults to `dist/`
86
+ - ✅ **Partials directory** — Must be in config; not loaded if omitted
85
87
 
86
88
  ### Override Auto-Discovery
87
89
 
@@ -166,6 +168,27 @@ const config = resolveConfig({
166
168
  await renderDirectory(config);
167
169
  ```
168
170
 
171
+ **With custom helpers:**
172
+
173
+ ```javascript
174
+ import Handlebars from 'handlebars';
175
+ import {
176
+ registerHelpers,
177
+ renderDirectory,
178
+ resolveConfig,
179
+ } from '@nci-gis/js-tmpl';
180
+
181
+ const hbs = Handlebars.create();
182
+ registerHelpers(hbs, { upper: (s) => s.toUpperCase() });
183
+
184
+ await renderDirectory(resolveConfig({ valuesFile: './values.yaml' }), hbs);
185
+ ```
186
+
187
+ Helpers must be pure functions. Strict mode still applies to plain
188
+ `{{var}}` lookups around them; see
189
+ [Strict templates](docs/API.md#strict-templates) for what is not checked
190
+ inside helper arguments.
191
+
169
192
  ### 4. Get output
170
193
 
171
194
  ```text
@@ -212,6 +235,22 @@ templates/
212
235
  → dist/production/config-my-app.yaml
213
236
  ```
214
237
 
238
+ Use `$if{var}` / `$ifn{var}` as whole directory segments to conditionally
239
+ include or skip files based on view data:
240
+
241
+ ```text
242
+ templates/
243
+ ├── common.yaml.hbs
244
+ ├── $if{prod}/
245
+ │ └── alerts.yaml.hbs → written only when view.prod is truthy
246
+ └── $ifn{prod}/
247
+ └── debug-panel.yaml.hbs → written only when view.prod is falsy
248
+ ```
249
+
250
+ Guards are directory-only, whole-segment, and throw loudly on missing
251
+ variables. See [API docs](docs/API.md#path-guards--conditional-files) for
252
+ the full semantics and rejected variants.
253
+
215
254
  ### Partial System
216
255
 
217
256
  Each render pass uses an isolated Handlebars instance. Directory structure maps to partial names:
@@ -242,7 +281,7 @@ Duplicate partial names throw an error. Names must be alphanumeric + underscore
242
281
  Think of js-tmpl as a function:
243
282
 
244
283
  ```text
245
- (input templates, data, config) → output files
284
+ f(config, values/view, input templates) → files (output)
246
285
  ```
247
286
 
248
287
  There is no hidden state, no lifecycle, and no side effects.
@@ -256,16 +295,28 @@ js-tmpl render [options]
256
295
 
257
296
  ### Options
258
297
 
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 |
298
+ | Option | Description | Default |
299
+ | ------------------------ | ---------------------------------------- | --------------- |
300
+ | `-c, --values FILE` | Values file (`.yaml` / `.yml` / `.json`) | Optional |
301
+ | `--values-dir DIR` | Value-partials root (namespaced by path) | Optional |
302
+ | `-t, --template-dir DIR` | Template directory | `templates` |
303
+ | `-o, --out DIR` | Output directory | `dist` |
304
+ | `-p, --partials-dir DIR` | Partials directory | None (skipped) |
305
+ | `-x, --ext EXT` | Template extension | `.hbs` |
306
+ | `--config-file FILE` | Explicit config file | Auto-discovered |
307
+ | `--env-keys KEYS` | Comma-separated env var names to expose | None |
308
+ | `--env-prefix PREFIX` | Auto-include env vars with this prefix | None |
309
+ | `--verbose` | Print stack traces on error | Off |
310
+ | `-h, --help` | Show usage | |
311
+
312
+ The CLI is strict: an unknown option, an option without its value, a
313
+ repeated option, or an unexpected argument is an error. Exit codes: `0`
314
+ success, `1` render or configuration error, `2` usage error.
315
+
316
+ Both `--values` and `--values-dir` are optional (VP-8, VP-6). If neither is
317
+ supplied, `view` is `{ env: {...} }` only. Missing `{{var}}` in a template
318
+ throws with the template's relative path and variable name (VP-9, strict
319
+ mode).
269
320
 
270
321
  ### Examples of Usage
271
322
 
@@ -289,12 +340,16 @@ See [docs/API.md](docs/API.md) for the complete API reference — parameters, re
289
340
 
290
341
  ## Examples
291
342
 
292
- See [examples/yaml-templates/](examples/yaml-templates/) for a complete working example demonstrating:
293
-
294
- - Dynamic file paths with `${env.NODE_ENV}`
295
- - Handlebars features (loops, conditionals)
296
- - Root and namespaced partials
297
- - Multi-format output (YAML, Markdown)
343
+ - [examples/yaml-templates/](examples/yaml-templates/) — complete walkthrough:
344
+ dynamic paths with `${env.NODE_ENV}`, Handlebars features (loops,
345
+ conditionals), root and namespaced partials, multi-format output.
346
+ - [examples/path-guards/](examples/path-guards/) — conditional files via
347
+ `$if{var}` / `$ifn{var}` whole-segment path guards.
348
+ - [examples/value-partials/](examples/value-partials/) — composing `view`
349
+ from multiple structured files via `--values-dir` (directory-as-namespace,
350
+ no merge, `@`-flatten escape).
351
+ - [examples/helpers/](examples/helpers/) — registering pure custom helpers on
352
+ a scoped Handlebars instance with `registerHelpers`.
298
353
 
299
354
  ## Testing
300
355
 
@@ -337,7 +392,7 @@ For security concerns, see [SECURITY.md](SECURITY.md).
337
392
 
338
393
  ## License
339
394
 
340
- MIT © pasxd245
395
+ See [LICENSE](LICENSE).
341
396
 
342
397
  ## Learn More
343
398
 
@@ -354,3 +409,7 @@ MIT © pasxd245
354
409
  - [Examples](examples/) - Working examples and templates
355
410
  - [Issue Tracker](https://github.com/nci-gis/js-tmpl/issues) - Report bugs or request features
356
411
  - [NPM Package](https://www.npmjs.com/package/@nci-gis/js-tmpl) - Package registry
412
+
413
+ ## Transparency
414
+
415
+ AI-assisted development (e.g., Claude Code, Copilot) was used for scaffolding and iteration.
package/bin/js-tmpl.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Installed CLI entry. A Node file, not a shell wrapper: npm links it from
4
+ // node_modules/.bin on every OS, and imports resolve from this file's real
5
+ // location, not from the symlink.
6
+ import { run } from '../src/cli/main.js';
7
+
8
+ process.exitCode = await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@nci-gis/js-tmpl",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "The pure JavaScript templating engine that uses handlebars.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "js-tmpl": "bin/js-tmpl"
7
+ "js-tmpl": "bin/js-tmpl.js"
8
8
  },
9
9
  "exports": {
10
10
  ".": {
@@ -13,11 +13,15 @@
13
13
  },
14
14
  "scripts": {
15
15
  "prepare": "husky",
16
- "test": "node --test $(find tests -name '*.test.js')",
17
- "test:watch": "node --test --watch $(find tests -name '*.test.js')",
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/",
16
+ "test": "node --test",
17
+ "test:watch": "node --test --watch",
18
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"tests/**\" --test-coverage-exclude=\"examples/**\" --test-coverage-lines=99 --test-coverage-branches=99 --test-coverage-functions=99",
19
+ "format": "pnpm format:code && pnpm format:md",
20
+ "format:check": "pnpm format:code:check && pnpm format:md:check",
21
+ "format:code": "prettier --write src/ tests/",
22
+ "format:code:check": "prettier --check src/ tests/",
23
+ "format:md": "prettier --write \"*.md\" \"docs/**/*.md\" \"tests/**/*.md\" \"examples/**/*.md\"",
24
+ "format:md:check": "prettier --check \"*.md\" \"docs/**/*.md\" \"tests/**/*.md\" \"examples/**/*.md\"",
21
25
  "lint": "eslint src/ tests/",
22
26
  "lint:fix": "eslint src/ tests/ --fix",
23
27
  "build": "echo 'No build step required for pure JS library'",
@@ -27,7 +31,10 @@
27
31
  "tool": "node src/cli/main.js",
28
32
  "docs:check-links": "remark --frail --quiet *.md docs/*.md tests/README.md examples/**/*.md",
29
33
  "docs:check-exports": "node scripts/check-doc-exports.js",
30
- "docs:check": "pnpm docs:check-links && pnpm docs:check-exports"
34
+ "docs:check": "pnpm docs:check-links && pnpm docs:check-exports",
35
+ "examples:update": "node scripts/update-golden.js",
36
+ "pack:check": "node scripts/check-pack.js",
37
+ "verify": "pnpm lint && pnpm format:check && pnpm docs:check && pnpm pack:check && pnpm test:coverage"
31
38
  },
32
39
  "keywords": [
33
40
  "js",
@@ -57,11 +64,12 @@
57
64
  },
58
65
  "packageManager": "pnpm@10.22.0",
59
66
  "dependencies": {
60
- "config": "^4.1.1",
61
67
  "handlebars": "^4.7.8",
62
68
  "js-yaml": "^4.1.1"
63
69
  },
64
70
  "devDependencies": {
71
+ "@commitlint/cli": "^20.5.0",
72
+ "@commitlint/config-conventional": "^20.5.0",
65
73
  "@eslint/js": "^9.39.2",
66
74
  "@types/js-yaml": "^4.0.9",
67
75
  "@types/node": "^25.5.2",
package/src/cli/args.js CHANGED
@@ -1,69 +1,146 @@
1
+ import { parseArgs as parseNodeArgs } from 'node:util';
2
+
1
3
  /**
2
- * Parse CLI arguments.
3
- *
4
- * @param {string[]} args
5
- * @returns {import('../types.js').CliArgs}
4
+ * The command line was malformed: unknown option, missing value, stray
5
+ * argument. The CLI exits with code 2 for these, distinct from render or
6
+ * config failures (code 1).
6
7
  */
7
- export function parseArgs(args) {
8
- /** @type {import('../types.js').CliArgs} */
9
- const opts = { command: 'render' };
10
-
11
- let i = 0;
12
- while (i < args.length) {
13
- const a = args[i];
8
+ export class UsageError extends Error {
9
+ /** @param {string} message */
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'UsageError';
13
+ }
14
+ }
14
15
 
15
- switch (a) {
16
- case '-h':
17
- case '--help':
18
- opts.command = 'help';
19
- break;
16
+ /** Option name → CliArgs field. Short aliases live in OPTIONS. */
17
+ const FIELDS = {
18
+ 'template-dir': 'templateDir',
19
+ values: 'valuesFile',
20
+ 'values-dir': 'valuesDir',
21
+ out: 'outDir',
22
+ 'partials-dir': 'partialsDir',
23
+ 'config-file': 'configFile',
24
+ ext: 'extname',
25
+ 'env-prefix': 'envPrefix',
26
+ };
20
27
 
21
- case 'render':
22
- opts.command = 'render';
23
- break;
28
+ /** @type {import('node:util').ParseArgsConfig['options']} */
29
+ const OPTIONS = {
30
+ help: { type: 'boolean', short: 'h' },
31
+ verbose: { type: 'boolean' },
32
+ 'template-dir': { type: 'string', short: 't' },
33
+ values: { type: 'string', short: 'c' },
34
+ 'values-dir': { type: 'string' },
35
+ out: { type: 'string', short: 'o' },
36
+ 'partials-dir': { type: 'string', short: 'p' },
37
+ 'config-file': { type: 'string' },
38
+ ext: { type: 'string', short: 'x' },
39
+ 'env-keys': { type: 'string' },
40
+ 'env-prefix': { type: 'string' },
41
+ };
24
42
 
25
- case '-t':
26
- case '--template-dir':
27
- opts.templateDir = args[++i];
28
- break;
43
+ const COMMANDS = new Set(['render']);
29
44
 
30
- case '-c':
31
- case '--values':
32
- opts.valuesFile = args[++i];
33
- break;
45
+ /**
46
+ * Run node:util parseArgs, turning its errors into UsageError with the
47
+ * first sentence of Node's message (the rest is generic advice). An option
48
+ * followed by another option (`-o --values x`) is reported as missing its
49
+ * value rather than taking `--values` as the output dir.
50
+ *
51
+ * @param {string[]} args
52
+ */
53
+ function tokenize(args) {
54
+ try {
55
+ return parseNodeArgs({
56
+ args,
57
+ options: OPTIONS,
58
+ strict: true,
59
+ allowPositionals: true,
60
+ tokens: true,
61
+ });
62
+ } catch (error) {
63
+ const err = /** @type {Error & { code?: string }} */ (error);
64
+ if (err.code?.startsWith('ERR_PARSE_ARGS_')) {
65
+ const first = err.message.split(/\.(?:\s|$)/)[0];
66
+ const ambiguous = first.match(/^Option '([^']+)' argument is ambiguous/);
67
+ throw new UsageError(
68
+ ambiguous ? `Option '${ambiguous[1]}' is missing its value` : first,
69
+ );
70
+ }
71
+ throw err;
72
+ }
73
+ }
34
74
 
35
- case '-o':
36
- case '--out':
37
- opts.outDir = args[++i];
38
- break;
75
+ /**
76
+ * Throw if any option appears more than once (no silent last-wins).
77
+ *
78
+ * @param {Array<{ kind: string, name?: string, rawName?: string }>} tokens
79
+ */
80
+ function assertNoRepeats(tokens) {
81
+ /** @type {Map<string, string>} */
82
+ const seen = new Map();
83
+ for (const t of tokens) {
84
+ if (t.kind !== 'option' || !t.name) {
85
+ continue;
86
+ }
87
+ if (seen.has(t.name)) {
88
+ throw new UsageError(
89
+ `Option '--${t.name}' given more than once (${seen.get(t.name)}, ${t.rawName})`,
90
+ );
91
+ }
92
+ seen.set(t.name, t.rawName ?? `--${t.name}`);
93
+ }
94
+ }
39
95
 
40
- case '-p':
41
- case '--partials-dir':
42
- opts.partialsDir = args[++i];
43
- break;
96
+ /**
97
+ * Parse CLI arguments (without the node binary and script path).
98
+ *
99
+ * Strict: unknown options, options missing their value, repeated options,
100
+ * and positional arguments other than one `render` command throw
101
+ * `UsageError`. Only options actually given appear in the result, so unset
102
+ * options never override config-file or default values.
103
+ *
104
+ * @param {string[]} args - e.g. `process.argv.slice(2)`
105
+ * @returns {import('../types.js').CliArgs}
106
+ * @throws {UsageError}
107
+ */
108
+ export function parseArgs(args) {
109
+ const { values, positionals, tokens } = tokenize(args);
110
+ assertNoRepeats(/** @type {any[]} */ (tokens));
44
111
 
45
- case '--config-file':
46
- opts.configFile = args[++i];
47
- break;
112
+ const [command = 'render', ...extra] = positionals;
113
+ if (!COMMANDS.has(command)) {
114
+ throw new UsageError(`Unknown command '${command}'`);
115
+ }
116
+ if (extra.length > 0) {
117
+ throw new UsageError(`Unexpected argument '${extra[0]}'`);
118
+ }
48
119
 
49
- case '-x':
50
- case '--ext':
51
- opts.extname = args[++i];
52
- break;
120
+ /** @type {import('../types.js').CliArgs} */
121
+ const opts = { command: values.help ? 'help' : command };
122
+ if (values.verbose) {
123
+ opts.verbose = true;
124
+ }
53
125
 
54
- case '--env-keys':
55
- opts.envKeys = args[++i]
56
- .split(',')
57
- .map((s) => s.trim())
58
- .filter(Boolean);
59
- break;
126
+ for (const [option, field] of Object.entries(FIELDS)) {
127
+ const value = values[option];
128
+ if (typeof value === 'string') {
129
+ /** @type {Record<string, unknown>} */ (opts)[field] = value;
130
+ }
131
+ }
60
132
 
61
- case '--env-prefix':
62
- opts.envPrefix = args[++i];
63
- break;
133
+ if (typeof values['env-keys'] === 'string') {
134
+ const keys = values['env-keys']
135
+ .split(',')
136
+ .map((s) => s.trim())
137
+ .filter(Boolean);
138
+ if (keys.length === 0) {
139
+ throw new UsageError(
140
+ "Option '--env-keys' needs at least one variable name",
141
+ );
64
142
  }
65
- // next argument:
66
- i++;
143
+ opts.envKeys = keys;
67
144
  }
68
145
 
69
146
  return opts;
package/src/cli/main.js CHANGED
@@ -2,40 +2,61 @@
2
2
 
3
3
  import { resolveConfig } from '../config/resolver.js';
4
4
  import { renderDirectory } from '../engine/renderDirectory.js';
5
- import { parseArgs } from './args.js';
5
+ import { parseArgs, UsageError } from './args.js';
6
6
  import { USAGE } from './usage.js';
7
7
 
8
- /** @returns {Promise<void>} */
9
- export async function main() {
10
- const cli = parseArgs(process.argv);
8
+ /**
9
+ * Parse arguments and run the command. Throws on any failure; `run` turns
10
+ * that into output and an exit code.
11
+ *
12
+ * @param {string[]} argv - Arguments without the node binary and script path
13
+ * @returns {Promise<void>}
14
+ */
15
+ export async function main(argv) {
16
+ const cli = parseArgs(argv);
11
17
 
12
18
  if (cli.command === 'help') {
13
19
  console.log(USAGE);
14
20
  return;
15
21
  }
16
22
 
17
- if (cli.command !== 'render') {
18
- console.error('Unknown command:', cli.command);
19
- process.exit(1);
20
- }
21
-
22
23
  const cfg = resolveConfig(cli);
23
-
24
24
  await renderDirectory(cfg);
25
-
26
25
  console.log('✔ js-tmpl completed.');
27
26
  }
28
27
 
29
- // Execute main function if this file is run directly
28
+ /**
29
+ * CLI error boundary. Prints `js-tmpl: <message>` (plus the stack with
30
+ * `--verbose`) and returns the exit code: 0 ok, 1 render/config error,
31
+ * 2 usage error.
32
+ *
33
+ * @param {string[]} argv - Arguments without the node binary and script path
34
+ * @returns {Promise<number>}
35
+ */
36
+ export async function run(argv) {
37
+ try {
38
+ await main(argv);
39
+ return 0;
40
+ } catch (error) {
41
+ const err = /** @type {Error} */ (error);
42
+ console.error(`js-tmpl: ${err.message}`);
43
+ if (err instanceof UsageError) {
44
+ console.error("Run 'js-tmpl --help' for usage.");
45
+ return 2;
46
+ }
47
+ if (argv.includes('--verbose')) {
48
+ console.error(err.stack);
49
+ }
50
+ return 1;
51
+ }
52
+ }
53
+
54
+ // Direct execution (`node src/cli/main.js …`). The installed CLI enters
55
+ // through bin/js-tmpl.js instead, which calls `run` itself.
30
56
  const isDirectRun =
31
57
  process.argv[1] &&
32
58
  import.meta.url.endsWith(process.argv[1].replaceAll('\\', '/'));
33
59
 
34
60
  if (isDirectRun) {
35
- try {
36
- await main();
37
- } catch (error) {
38
- console.error('Error:', error);
39
- process.exit(1);
40
- }
61
+ process.exitCode = await run(process.argv.slice(2));
41
62
  }
package/src/cli/usage.js CHANGED
@@ -6,11 +6,18 @@ Commands:
6
6
 
7
7
  Options:
8
8
  -t, --template-dir <dir> Template directory (default: templates)
9
- -c, --values <file> Values file (YAML or JSON) [required]
9
+ -c, --values <file> Values file (.yaml / .yml / .json) — optional
10
+ --values-dir <dir> Value-partials root, namespaced into view by path
10
11
  -o, --out <dir> Output directory (default: dist)
11
12
  -p, --partials-dir <dir> Partials directory
12
13
  -x, --ext <ext> Template extension (default: .hbs)
13
14
  --config-file <file> Project config file
14
15
  --env-keys <keys> Comma-separated env var names to expose (default: none)
15
16
  --env-prefix <prefix> Auto-include env vars with this prefix (e.g. JS_TMPL_)
16
- -h, --help Show this help message`;
17
+ --verbose Print stack traces on error
18
+ -h, --help Show this help message
19
+
20
+ Exit codes:
21
+ 0 success
22
+ 1 render or configuration error
23
+ 2 usage error (unknown option, missing value, unexpected argument)`;
@@ -3,71 +3,62 @@ import process from 'node:process';
3
3
 
4
4
  import { DEFAULTS } from './defaults.js';
5
5
  import { loadProjectConfig, loadYamlOrJson } from './loader.js';
6
+ import { scanValuePartials } from './valuePartials.js';
6
7
  import { buildView, pickEnv } from './view.js';
7
8
 
8
9
  /**
9
- * Resolve valuesFile path based on valuesDir
10
- * @param {string} valuesFile - The values file name or path
11
- * @param {string} valuesDir - Values directory (may be empty string)
12
- * @param {string} cwd - Current working directory
13
- * @returns {string} Absolute path to values file
10
+ * C-1 — throw if the resolved `valuesFile` sits inside the resolved
11
+ * `valuesDir`. A file loaded both as root and as a value partial would
12
+ * produce ambiguous collisions.
13
+ *
14
+ * @param {string} valuesFileAbs
15
+ * @param {string} valuesDirAbs
14
16
  */
15
- function resolveValuesFilePath(valuesFile, valuesDir, cwd) {
16
- // If absolute, use as-is
17
- if (path.isAbsolute(valuesFile)) {
18
- return valuesFile;
19
- }
20
-
21
- // If valuesDir is set (truthy), use it as base
22
- if (valuesDir) {
23
- const absoluteValuesDir = path.isAbsolute(valuesDir)
24
- ? valuesDir
25
- : path.join(cwd, valuesDir);
26
- return path.join(absoluteValuesDir, valuesFile);
17
+ function assertValuesFileNotInside(valuesFileAbs, valuesDirAbs) {
18
+ const rel = path.relative(valuesDirAbs, valuesFileAbs);
19
+ const inside = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
20
+ if (inside) {
21
+ throw new Error(
22
+ `valuesFile '${valuesFileAbs}' is inside valuesDir '${valuesDirAbs}'.\n` +
23
+ `Move the file out, or drop valuesDir.`,
24
+ );
27
25
  }
28
-
29
- // Otherwise, resolve from cwd
30
- return path.join(cwd, valuesFile);
31
26
  }
32
27
 
33
28
  /**
34
- * Resolve final config using:
35
- * defaults < projectConfig < cliArgs
29
+ * Resolve final config using: defaults < projectConfig < cliArgs.
30
+ *
31
+ * Value sources (all optional per VP-5, VP-6, VP-8):
32
+ * - `valuesFile` loaded into top-level view keys.
33
+ * - `valuesDir` scanned via `scanValuePartials` into a namespaced tree.
34
+ * - Allowlisted env vars under `view.env.*`.
36
35
  *
37
- * @param {import('../types.js').CliArgs} cli - CLI arguments
38
- * @param {string} [cwd] - Current working directory
39
- * @returns {import('../types.js').TemplateConfig} - Resolved configuration
36
+ * Collision rules C-1, C-2, C-3 apply and surface as hard errors.
37
+ *
38
+ * @param {import('../types.js').CliArgs} cli
39
+ * @param {string} [cwd]
40
+ * @returns {import('../types.js').TemplateConfig}
40
41
  */
41
42
  export function resolveConfig(cli, cwd = process.cwd()) {
42
43
  const projectConfig = loadProjectConfig(cwd, cli.configFile);
43
-
44
- const mergedConfig = {
45
- ...DEFAULTS,
46
- ...projectConfig,
47
- ...cli,
48
- };
44
+ const mergedConfig = { ...DEFAULTS, ...projectConfig, ...cli };
49
45
 
50
46
  /** @param {string} p */
51
47
  const abs = (p) => (path.isAbsolute(p) ? p : path.join(cwd, p));
52
48
 
53
- // Validate valuesFile is provided
54
- if (!mergedConfig.valuesFile) {
55
- throw new Error(
56
- 'Missing required configuration: valuesFile\n' +
57
- 'Provide via:\n' +
58
- ' - CLI: --values path/to/values.yaml\n' +
59
- ' - Config: valuesFile: "path/to/values.yaml" in js-tmpl.config.yaml',
60
- );
61
- }
49
+ const valuesFileAbs = mergedConfig.valuesFile
50
+ ? abs(mergedConfig.valuesFile)
51
+ : '';
52
+ const valuesDirAbs = mergedConfig.valuesDir
53
+ ? abs(mergedConfig.valuesDir)
54
+ : '';
62
55
 
63
- // Resolve path - simple logic based on valuesDir presence
64
- const valuesFilePath = resolveValuesFilePath(
65
- mergedConfig.valuesFile,
66
- mergedConfig.valuesDir,
67
- cwd,
68
- );
56
+ if (valuesFileAbs && valuesDirAbs) {
57
+ assertValuesFileNotInside(valuesFileAbs, valuesDirAbs);
58
+ }
69
59
 
70
- const values = loadYamlOrJson(valuesFilePath);
60
+ const rootValues = valuesFileAbs ? loadYamlOrJson(valuesFileAbs) : {};
61
+ const partials = valuesDirAbs ? scanValuePartials(valuesDirAbs) : {};
71
62
 
72
63
  const hasEnvConfig = mergedConfig.envKeys?.length || mergedConfig.envPrefix;
73
64
  const env = hasEnvConfig
@@ -82,6 +73,12 @@ export function resolveConfig(cli, cwd = process.cwd()) {
82
73
  partialsDir: mergedConfig.partialsDir ? abs(mergedConfig.partialsDir) : '',
83
74
  outDir: abs(mergedConfig.outDir),
84
75
  extname: mergedConfig.extname,
85
- view: buildView(values, env),
76
+ view: buildView({
77
+ rootValues,
78
+ partials,
79
+ env,
80
+ valuesFile: valuesFileAbs || '<unset>',
81
+ valuesDir: valuesDirAbs || '<unset>',
82
+ }),
86
83
  };
87
84
  }