@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 +82 -23
- package/bin/js-tmpl.js +8 -0
- package/package.json +17 -9
- package/src/cli/args.js +130 -53
- package/src/cli/main.js +39 -18
- package/src/cli/usage.js +9 -2
- package/src/config/resolver.js +45 -48
- package/src/config/valuePartials.js +114 -0
- package/src/config/view.js +85 -12
- package/src/engine/contentRenderer.js +19 -6
- package/src/engine/helpers.js +93 -0
- package/src/engine/partials.js +26 -54
- package/src/engine/pathFormula.js +74 -0
- package/src/engine/pathRenderer.js +57 -9
- package/src/engine/pathSegment.js +49 -0
- package/src/engine/renderDirectory.js +61 -10
- package/src/engine/treeWalker.js +36 -15
- package/src/index.js +1 -0
- package/src/types.js +1 -0
- package/src/utils/namespacing.js +92 -0
- package/bin/js-tmpl +0 -3
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,
|
|
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**
|
|
82
|
-
- ✅ **
|
|
83
|
-
- ✅ **
|
|
84
|
-
- ✅ **
|
|
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
|
-
(
|
|
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
|
|
260
|
-
| ------------------------ |
|
|
261
|
-
| `-c, --values FILE` | Values file (
|
|
262
|
-
|
|
|
263
|
-
| `-
|
|
264
|
-
| `-
|
|
265
|
-
| `-
|
|
266
|
-
|
|
|
267
|
-
| `--
|
|
268
|
-
| `--env-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
-
|
|
296
|
-
|
|
297
|
-
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
17
|
-
"test:watch": "node --test --watch
|
|
18
|
-
"test:coverage": "node --experimental-test-coverage --test
|
|
19
|
-
"format": "
|
|
20
|
-
"format:check": "
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
8
|
-
/** @
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
case '--template-dir':
|
|
27
|
-
opts.templateDir = args[++i];
|
|
28
|
-
break;
|
|
43
|
+
const COMMANDS = new Set(['render']);
|
|
29
44
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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)`;
|
package/src/config/resolver.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* @
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* @
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
mergedConfig.valuesDir,
|
|
67
|
-
cwd,
|
|
68
|
-
);
|
|
56
|
+
if (valuesFileAbs && valuesDirAbs) {
|
|
57
|
+
assertValuesFileNotInside(valuesFileAbs, valuesDirAbs);
|
|
58
|
+
}
|
|
69
59
|
|
|
70
|
-
const
|
|
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(
|
|
76
|
+
view: buildView({
|
|
77
|
+
rootValues,
|
|
78
|
+
partials,
|
|
79
|
+
env,
|
|
80
|
+
valuesFile: valuesFileAbs || '<unset>',
|
|
81
|
+
valuesDir: valuesDirAbs || '<unset>',
|
|
82
|
+
}),
|
|
86
83
|
};
|
|
87
84
|
}
|