@nci-gis/js-tmpl 0.1.0 → 0.1.2
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 +31 -1
- package/bin/js-tmpl.js +8 -0
- package/package.json +9 -7
- package/src/cli/args.js +130 -57
- package/src/cli/main.js +39 -18
- package/src/cli/usage.js +7 -1
- package/src/config/valuePartials.js +1 -1
- package/src/config/view.js +4 -4
- package/src/engine/helpers.js +93 -0
- package/src/engine/renderDirectory.js +59 -8
- package/src/engine/treeWalker.js +22 -3
- package/src/index.js +1 -0
- package/src/types.js +1 -0
- package/src/utils/namespacing.js +5 -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
|
|
@@ -167,6 +168,27 @@ const config = resolveConfig({
|
|
|
167
168
|
await renderDirectory(config);
|
|
168
169
|
```
|
|
169
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
|
+
|
|
170
192
|
### 4. Get output
|
|
171
193
|
|
|
172
194
|
```text
|
|
@@ -284,6 +306,12 @@ js-tmpl render [options]
|
|
|
284
306
|
| `--config-file FILE` | Explicit config file | Auto-discovered |
|
|
285
307
|
| `--env-keys KEYS` | Comma-separated env var names to expose | None |
|
|
286
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.
|
|
287
315
|
|
|
288
316
|
Both `--values` and `--values-dir` are optional (VP-8, VP-6). If neither is
|
|
289
317
|
supplied, `view` is `{ env: {...} }` only. Missing `{{var}}` in a template
|
|
@@ -320,6 +348,8 @@ See [docs/API.md](docs/API.md) for the complete API reference — parameters, re
|
|
|
320
348
|
- [examples/value-partials/](examples/value-partials/) — composing `view`
|
|
321
349
|
from multiple structured files via `--values-dir` (directory-as-namespace,
|
|
322
350
|
no merge, `@`-flatten escape).
|
|
351
|
+
- [examples/helpers/](examples/helpers/) — registering pure custom helpers on
|
|
352
|
+
a scoped Handlebars instance with `registerHelpers`.
|
|
323
353
|
|
|
324
354
|
## Testing
|
|
325
355
|
|
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.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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,9 +13,9 @@
|
|
|
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
|
|
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
19
|
"format": "pnpm format:code && pnpm format:md",
|
|
20
20
|
"format:check": "pnpm format:code:check && pnpm format:md:check",
|
|
21
21
|
"format:code": "prettier --write src/ tests/",
|
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
"tool": "node src/cli/main.js",
|
|
32
32
|
"docs:check-links": "remark --frail --quiet *.md docs/*.md tests/README.md examples/**/*.md",
|
|
33
33
|
"docs:check-exports": "node scripts/check-doc-exports.js",
|
|
34
|
-
"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"
|
|
35
38
|
},
|
|
36
39
|
"keywords": [
|
|
37
40
|
"js",
|
|
@@ -61,7 +64,6 @@
|
|
|
61
64
|
},
|
|
62
65
|
"packageManager": "pnpm@10.22.0",
|
|
63
66
|
"dependencies": {
|
|
64
|
-
"config": "^4.1.1",
|
|
65
67
|
"handlebars": "^4.7.8",
|
|
66
68
|
"js-yaml": "^4.1.1"
|
|
67
69
|
},
|
package/src/cli/args.js
CHANGED
|
@@ -1,73 +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
|
-
|
|
14
|
-
|
|
15
|
-
switch (a) {
|
|
16
|
-
case '-h':
|
|
17
|
-
case '--help':
|
|
18
|
-
opts.command = 'help';
|
|
19
|
-
break;
|
|
8
|
+
export class UsageError extends Error {
|
|
9
|
+
/** @param {string} message */
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'UsageError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
};
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
};
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
case '--values':
|
|
32
|
-
opts.valuesFile = args[++i];
|
|
33
|
-
break;
|
|
43
|
+
const COMMANDS = new Set(['render']);
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|
|
38
74
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
|
43
95
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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));
|
|
48
111
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
|
52
119
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
120
|
+
/** @type {import('../types.js').CliArgs} */
|
|
121
|
+
const opts = { command: values.help ? 'help' : command };
|
|
122
|
+
if (values.verbose) {
|
|
123
|
+
opts.verbose = true;
|
|
124
|
+
}
|
|
57
125
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
64
132
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
);
|
|
68
142
|
}
|
|
69
|
-
|
|
70
|
-
i++;
|
|
143
|
+
opts.envKeys = keys;
|
|
71
144
|
}
|
|
72
145
|
|
|
73
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
|
@@ -14,4 +14,10 @@ Options:
|
|
|
14
14
|
--config-file <file> Project config file
|
|
15
15
|
--env-keys <keys> Comma-separated env var names to expose (default: none)
|
|
16
16
|
--env-prefix <prefix> Auto-include env vars with this prefix (e.g. JS_TMPL_)
|
|
17
|
-
|
|
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)`;
|
|
@@ -24,7 +24,7 @@ function placeInTree(tree, chain, value) {
|
|
|
24
24
|
let cur = tree;
|
|
25
25
|
for (let i = 0; i < chain.length - 1; i++) {
|
|
26
26
|
const seg = chain[i];
|
|
27
|
-
if (!(seg
|
|
27
|
+
if (!Object.hasOwn(cur, seg)) {
|
|
28
28
|
cur[seg] = {};
|
|
29
29
|
}
|
|
30
30
|
cur = /** @type {Record<string, unknown>} */ (cur[seg]);
|
package/src/config/view.js
CHANGED
|
@@ -48,7 +48,7 @@ export function buildView(args = {}) {
|
|
|
48
48
|
* @param {string} valuesDir
|
|
49
49
|
*/
|
|
50
50
|
function assertReservedEnvNotInPartials(partials, valuesDir) {
|
|
51
|
-
if (RESERVED_ENV
|
|
51
|
+
if (Object.hasOwn(partials, RESERVED_ENV)) {
|
|
52
52
|
throw new Error(
|
|
53
53
|
`Value partial conflicts with reserved 'env' namespace.\n` +
|
|
54
54
|
` Source: ${valuesDir} produced a top-level 'env' namespace.\n` +
|
|
@@ -73,7 +73,7 @@ function assertNoRootNamespaceCollision(
|
|
|
73
73
|
if (key === RESERVED_ENV) {
|
|
74
74
|
continue;
|
|
75
75
|
}
|
|
76
|
-
if (key
|
|
76
|
+
if (Object.hasOwn(partials, key)) {
|
|
77
77
|
throw new Error(
|
|
78
78
|
`Duplicate view key '${key}' — registered by both:\n` +
|
|
79
79
|
` - ${valuesFile} top-level key\n` +
|
|
@@ -87,7 +87,7 @@ function assertNoRootNamespaceCollision(
|
|
|
87
87
|
* @param {Record<string, unknown>} values
|
|
88
88
|
*/
|
|
89
89
|
function warnOnReservedEnvInValuesFile(values) {
|
|
90
|
-
if (RESERVED_ENV
|
|
90
|
+
if (Object.hasOwn(values, RESERVED_ENV)) {
|
|
91
91
|
console.warn(
|
|
92
92
|
'Warning: "env" is a reserved key in js-tmpl and will be overwritten.\n' +
|
|
93
93
|
'Rename the "env" key in your values file to avoid this.',
|
|
@@ -109,7 +109,7 @@ export function pickEnv({ keys = [], prefix = '' }, source = process.env) {
|
|
|
109
109
|
const result = {};
|
|
110
110
|
|
|
111
111
|
for (const k of keys) {
|
|
112
|
-
if (k
|
|
112
|
+
if (Object.hasOwn(source, k)) {
|
|
113
113
|
result[k] = /** @type {string} */ (source[k]);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare-identifier rule for helper names: `{{name}}` must parse without
|
|
3
|
+
* bracket notation. Hyphens are allowed (`date-format` is idiomatic).
|
|
4
|
+
*/
|
|
5
|
+
const HELPER_NAME_RE = /^[a-zA-Z_$][\w$-]*$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Describe a value's type for error messages (`null` and arrays included).
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} value
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function describeType(value) {
|
|
14
|
+
if (value === null) {
|
|
15
|
+
return 'null';
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
return 'array';
|
|
19
|
+
}
|
|
20
|
+
return typeof value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Throw if a single helper entry is invalid or collides on `hbs`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @param {unknown} fn
|
|
28
|
+
* @param {typeof import('handlebars')} hbs
|
|
29
|
+
*/
|
|
30
|
+
function assertValidHelper(name, fn, hbs) {
|
|
31
|
+
if (!HELPER_NAME_RE.test(name)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Invalid helper name '${name}' — must start with a letter, underscore, or\n` +
|
|
34
|
+
'dollar sign, and contain only letters, digits, underscores, dollars, or hyphens.\n' +
|
|
35
|
+
'For exotic names, use hbs.registerHelper() directly.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (typeof fn !== 'function') {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Helper '${name}' must be a function, got ${describeType(fn)}.\n` +
|
|
41
|
+
'Each value in helpersMap must be a callable function.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (Object.hasOwn(hbs.helpers, name)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Helper '${name}' is already registered on this Handlebars instance.\n` +
|
|
47
|
+
'To intentionally override a built-in, use hbs.registerHelper() directly.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register custom helpers on a scoped Handlebars instance.
|
|
54
|
+
*
|
|
55
|
+
* Validates every entry before registering any (atomic): a map with one
|
|
56
|
+
* invalid entry registers nothing. Names must be bare identifiers
|
|
57
|
+
* (`/^[a-zA-Z_$][\w$-]*$/`), values must be functions, and a name already
|
|
58
|
+
* registered on `hbs` (built-ins included) throws. Skips silently if
|
|
59
|
+
* `helpersMap` is falsy or empty.
|
|
60
|
+
*
|
|
61
|
+
* Helpers must be pure: same arguments, same result. js-tmpl cannot enforce
|
|
62
|
+
* this; a helper reading the clock, randomness, env, or disk makes output
|
|
63
|
+
* non-deterministic.
|
|
64
|
+
*
|
|
65
|
+
* @param {typeof import('handlebars')} hbs - Handlebars instance to register on
|
|
66
|
+
* @param {Record<string, import('handlebars').HelperDelegate>} [helpersMap] - Helper name → function
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
export function registerHelpers(hbs, helpersMap) {
|
|
70
|
+
if (!hbs || typeof hbs.registerHelper !== 'function') {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'registerHelpers requires a Handlebars instance as its first argument.\n' +
|
|
73
|
+
'Create one with Handlebars.create() and pass it to renderDirectory too.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!helpersMap) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (typeof helpersMap !== 'object' || Array.isArray(helpersMap)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`helpersMap must be an object of name → function, got ${describeType(helpersMap)}.\n` +
|
|
82
|
+
'Example: registerHelpers(hbs, { upper: (s) => s.toUpperCase() })',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entries = Object.entries(helpersMap);
|
|
87
|
+
for (const [name, fn] of entries) {
|
|
88
|
+
assertValidHelper(name, fn, hbs);
|
|
89
|
+
}
|
|
90
|
+
for (const [name, fn] of entries) {
|
|
91
|
+
hbs.registerHelper(name, fn);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -8,6 +8,63 @@ import { registerPartials } from './partials.js';
|
|
|
8
8
|
import { renderPath } from './pathRenderer.js';
|
|
9
9
|
import { walkTemplateTree } from './treeWalker.js';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Throw if `target` is not strictly inside `outDir` — a `${var}` value such
|
|
13
|
+
* as `../x` must never write outside the output directory.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} target
|
|
16
|
+
* @param {string} outDir
|
|
17
|
+
* @param {string} relPath - Template path, for the error message
|
|
18
|
+
* @param {string} rendered - Rendered path, for the error message
|
|
19
|
+
*/
|
|
20
|
+
function assertInsideOutDir(target, outDir, relPath, rendered) {
|
|
21
|
+
const rel = path.relative(path.resolve(outDir), path.resolve(target));
|
|
22
|
+
const escapes =
|
|
23
|
+
rel === '' || rel.split(path.sep)[0] === '..' || path.isAbsolute(rel);
|
|
24
|
+
if (escapes) {
|
|
25
|
+
const vars = [...relPath.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1]);
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Template '${relPath}' renders to '${rendered}', which is outside outDir '${outDir}'.\n` +
|
|
28
|
+
(vars.length ? `Check the values of: ${vars.join(', ')}. ` : '') +
|
|
29
|
+
"Path values must not contain '..' segments.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Map every template to its output path before anything is rendered or
|
|
36
|
+
* written, so an escaping path or two templates sharing a target fail
|
|
37
|
+
* before the first file is touched.
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<{ relPath: string, absPath: string }>} files
|
|
40
|
+
* @param {import('../types.js').TemplateConfig} cfg
|
|
41
|
+
* @returns {Array<{ file: { relPath: string, absPath: string }, target: string }>}
|
|
42
|
+
*/
|
|
43
|
+
function planTargets(files, cfg) {
|
|
44
|
+
const { outDir, view, extname } = cfg;
|
|
45
|
+
/** @type {Map<string, string>} */
|
|
46
|
+
const owners = new Map();
|
|
47
|
+
|
|
48
|
+
return files.map((file) => {
|
|
49
|
+
const rendered = renderPath(file.relPath, view).replace(
|
|
50
|
+
new RegExp(`${extname}$`),
|
|
51
|
+
'',
|
|
52
|
+
);
|
|
53
|
+
const target = path.join(outDir, rendered);
|
|
54
|
+
assertInsideOutDir(target, outDir, file.relPath, rendered);
|
|
55
|
+
|
|
56
|
+
const owner = owners.get(target);
|
|
57
|
+
if (owner) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Templates '${owner}' and '${file.relPath}' both render to '${rendered}'.\n` +
|
|
60
|
+
'Each output file must come from exactly one template; check the path values.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
owners.set(target, file.relPath);
|
|
64
|
+
return { file, target };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
11
68
|
/**
|
|
12
69
|
* Main rendering orchestrator.
|
|
13
70
|
* @param {import('../types.js').TemplateConfig} cfg
|
|
@@ -15,20 +72,14 @@ import { walkTemplateTree } from './treeWalker.js';
|
|
|
15
72
|
* @returns {Promise<void>}
|
|
16
73
|
*/
|
|
17
74
|
export async function renderDirectory(cfg, hbs) {
|
|
18
|
-
const { templateDir, partialsDir,
|
|
75
|
+
const { templateDir, partialsDir, view, extname } = cfg;
|
|
19
76
|
|
|
20
77
|
hbs = hbs || Handlebars.create();
|
|
21
78
|
await registerPartials(partialsDir, extname, hbs);
|
|
22
79
|
|
|
23
80
|
const files = await walkTemplateTree(templateDir, { ext: extname, view });
|
|
24
81
|
|
|
25
|
-
for (const file of files) {
|
|
26
|
-
const relRendered = renderPath(file.relPath, view);
|
|
27
|
-
const target = path.join(
|
|
28
|
-
outDir,
|
|
29
|
-
relRendered.replace(new RegExp(`${extname}$`), ''),
|
|
30
|
-
);
|
|
31
|
-
|
|
82
|
+
for (const { file, target } of planTargets(files, cfg)) {
|
|
32
83
|
const content = await renderContent(file.absPath, view, hbs, file.relPath);
|
|
33
84
|
|
|
34
85
|
await ensureDir(path.dirname(target));
|
package/src/engine/treeWalker.js
CHANGED
|
@@ -27,6 +27,10 @@ function shouldSkipSubtree(rel, view) {
|
|
|
27
27
|
* prune the subtree before any filesystem descent (early-exit — no
|
|
28
28
|
* `stat`/`readdir` on skipped paths).
|
|
29
29
|
*
|
|
30
|
+
* Symbolic links are followed. A directory that resolves to one of its own
|
|
31
|
+
* ancestors throws instead of looping; the same directory linked from two
|
|
32
|
+
* places (no cycle) is walked twice.
|
|
33
|
+
*
|
|
30
34
|
* @param {string} rootDir
|
|
31
35
|
* @param {(string | { ext?: string, view?: Record<string, unknown> })} [optsOrExt]
|
|
32
36
|
* Options object, or a bare `ext` string for back-compat.
|
|
@@ -40,10 +44,13 @@ export async function walkTemplateTree(rootDir, optsOrExt) {
|
|
|
40
44
|
|
|
41
45
|
/** @type {import('../types.js').TemplateFile[]} */
|
|
42
46
|
const results = [];
|
|
43
|
-
|
|
47
|
+
/** @type {Array<{ rel: string, ancestors: Array<{ rel: string, real: string }> }>} */
|
|
48
|
+
const queue = [{ rel: '', ancestors: [] }];
|
|
44
49
|
|
|
45
50
|
while (queue.length) {
|
|
46
|
-
const rel = /** @type {
|
|
51
|
+
const { rel, ancestors } = /** @type {(typeof queue)[number]} */ (
|
|
52
|
+
queue.shift()
|
|
53
|
+
);
|
|
47
54
|
const abs = path.join(rootDir, rel);
|
|
48
55
|
const stat = await fs.stat(abs);
|
|
49
56
|
|
|
@@ -51,9 +58,21 @@ export async function walkTemplateTree(rootDir, optsOrExt) {
|
|
|
51
58
|
if (shouldSkipSubtree(rel, view)) {
|
|
52
59
|
continue;
|
|
53
60
|
}
|
|
61
|
+
const real = await fs.realpath(abs);
|
|
62
|
+
const loop = ancestors.find((a) => a.real === real);
|
|
63
|
+
if (loop) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Template directory '${rel}' links back to '${loop.rel || '.'}' (a symbolic link cycle in '${rootDir}').\n` +
|
|
66
|
+
'Remove the link, or point it outside its own parent directories.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const chain = [...ancestors, { rel, real }];
|
|
54
70
|
const items = (await fs.readdir(abs)).sort();
|
|
55
71
|
for (const name of items) {
|
|
56
|
-
queue.push(
|
|
72
|
+
queue.push({
|
|
73
|
+
rel: rel ? path.join(rel, name) : name,
|
|
74
|
+
ancestors: chain,
|
|
75
|
+
});
|
|
57
76
|
}
|
|
58
77
|
} else if (path.extname(abs) === ext) {
|
|
59
78
|
results.push({ absPath: abs, relPath: rel });
|
package/src/index.js
CHANGED
package/src/types.js
CHANGED
package/src/utils/namespacing.js
CHANGED
|
@@ -21,6 +21,11 @@ const FLATTEN_SEGMENT_RE = /^@\w+$/;
|
|
|
21
21
|
* @param {string} [label='namespace'] - Noun used in the error message (e.g. "partial name").
|
|
22
22
|
*/
|
|
23
23
|
export function assertValidSegments(segments, filePath, label = 'namespace') {
|
|
24
|
+
if (segments.includes('__proto__')) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Invalid ${label} segment '__proto__' in ${filePath} — reserved name`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
24
29
|
if (!SEGMENT_RE.test(segments.join(''))) {
|
|
25
30
|
throw new Error(
|
|
26
31
|
`Invalid ${label} segment '${segments.join('>')}' in ${filePath} — only alphanumeric and underscore allowed`,
|
package/bin/js-tmpl
DELETED