@nci-gis/js-tmpl 0.0.1 → 0.1.0
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 +51 -22
- package/package.json +9 -3
- package/src/cli/args.js +4 -0
- package/src/cli/usage.js +2 -1
- 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/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 +2 -2
- package/src/engine/treeWalker.js +36 -15
- package/src/utils/namespacing.js +92 -0
package/README.md
CHANGED
|
@@ -78,10 +78,11 @@ js-tmpl will search for a project config file in **exactly these locations**, in
|
|
|
78
78
|
|
|
79
79
|
Everything else must be **explicitly specified**:
|
|
80
80
|
|
|
81
|
-
- ✅ **Values file**
|
|
82
|
-
- ✅ **
|
|
83
|
-
- ✅ **
|
|
84
|
-
- ✅ **
|
|
81
|
+
- ✅ **Values file** — Optional via `--values` flag or `valuesFile` config (VP-8)
|
|
82
|
+
- ✅ **Values directory** — Optional via `--values-dir` flag or `valuesDir` config (VP-6)
|
|
83
|
+
- ✅ **Template directory** — Must be in config or defaults to `templates/`
|
|
84
|
+
- ✅ **Output directory** — Must be in config or defaults to `dist/`
|
|
85
|
+
- ✅ **Partials directory** — Must be in config; not loaded if omitted
|
|
85
86
|
|
|
86
87
|
### Override Auto-Discovery
|
|
87
88
|
|
|
@@ -212,6 +213,22 @@ templates/
|
|
|
212
213
|
→ dist/production/config-my-app.yaml
|
|
213
214
|
```
|
|
214
215
|
|
|
216
|
+
Use `$if{var}` / `$ifn{var}` as whole directory segments to conditionally
|
|
217
|
+
include or skip files based on view data:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
templates/
|
|
221
|
+
├── common.yaml.hbs
|
|
222
|
+
├── $if{prod}/
|
|
223
|
+
│ └── alerts.yaml.hbs → written only when view.prod is truthy
|
|
224
|
+
└── $ifn{prod}/
|
|
225
|
+
└── debug-panel.yaml.hbs → written only when view.prod is falsy
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Guards are directory-only, whole-segment, and throw loudly on missing
|
|
229
|
+
variables. See [API docs](docs/API.md#path-guards--conditional-files) for
|
|
230
|
+
the full semantics and rejected variants.
|
|
231
|
+
|
|
215
232
|
### Partial System
|
|
216
233
|
|
|
217
234
|
Each render pass uses an isolated Handlebars instance. Directory structure maps to partial names:
|
|
@@ -242,7 +259,7 @@ Duplicate partial names throw an error. Names must be alphanumeric + underscore
|
|
|
242
259
|
Think of js-tmpl as a function:
|
|
243
260
|
|
|
244
261
|
```text
|
|
245
|
-
(
|
|
262
|
+
f(config, values/view, input templates) → files (output)
|
|
246
263
|
```
|
|
247
264
|
|
|
248
265
|
There is no hidden state, no lifecycle, and no side effects.
|
|
@@ -256,16 +273,22 @@ js-tmpl render [options]
|
|
|
256
273
|
|
|
257
274
|
### Options
|
|
258
275
|
|
|
259
|
-
| Option | Description
|
|
260
|
-
| ------------------------ |
|
|
261
|
-
| `-c, --values FILE` | Values file (
|
|
262
|
-
|
|
|
263
|
-
| `-
|
|
264
|
-
| `-
|
|
265
|
-
| `-
|
|
266
|
-
|
|
|
267
|
-
| `--
|
|
268
|
-
| `--env-
|
|
276
|
+
| Option | Description | Default |
|
|
277
|
+
| ------------------------ | ---------------------------------------- | --------------- |
|
|
278
|
+
| `-c, --values FILE` | Values file (`.yaml` / `.yml` / `.json`) | Optional |
|
|
279
|
+
| `--values-dir DIR` | Value-partials root (namespaced by path) | Optional |
|
|
280
|
+
| `-t, --template-dir DIR` | Template directory | `templates` |
|
|
281
|
+
| `-o, --out DIR` | Output directory | `dist` |
|
|
282
|
+
| `-p, --partials-dir DIR` | Partials directory | None (skipped) |
|
|
283
|
+
| `-x, --ext EXT` | Template extension | `.hbs` |
|
|
284
|
+
| `--config-file FILE` | Explicit config file | Auto-discovered |
|
|
285
|
+
| `--env-keys KEYS` | Comma-separated env var names to expose | None |
|
|
286
|
+
| `--env-prefix PREFIX` | Auto-include env vars with this prefix | None |
|
|
287
|
+
|
|
288
|
+
Both `--values` and `--values-dir` are optional (VP-8, VP-6). If neither is
|
|
289
|
+
supplied, `view` is `{ env: {...} }` only. Missing `{{var}}` in a template
|
|
290
|
+
throws with the template's relative path and variable name (VP-9, strict
|
|
291
|
+
mode).
|
|
269
292
|
|
|
270
293
|
### Examples of Usage
|
|
271
294
|
|
|
@@ -289,12 +312,14 @@ See [docs/API.md](docs/API.md) for the complete API reference — parameters, re
|
|
|
289
312
|
|
|
290
313
|
## Examples
|
|
291
314
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
-
|
|
296
|
-
|
|
297
|
-
-
|
|
315
|
+
- [examples/yaml-templates/](examples/yaml-templates/) — complete walkthrough:
|
|
316
|
+
dynamic paths with `${env.NODE_ENV}`, Handlebars features (loops,
|
|
317
|
+
conditionals), root and namespaced partials, multi-format output.
|
|
318
|
+
- [examples/path-guards/](examples/path-guards/) — conditional files via
|
|
319
|
+
`$if{var}` / `$ifn{var}` whole-segment path guards.
|
|
320
|
+
- [examples/value-partials/](examples/value-partials/) — composing `view`
|
|
321
|
+
from multiple structured files via `--values-dir` (directory-as-namespace,
|
|
322
|
+
no merge, `@`-flatten escape).
|
|
298
323
|
|
|
299
324
|
## Testing
|
|
300
325
|
|
|
@@ -337,7 +362,7 @@ For security concerns, see [SECURITY.md](SECURITY.md).
|
|
|
337
362
|
|
|
338
363
|
## License
|
|
339
364
|
|
|
340
|
-
|
|
365
|
+
See [LICENSE](LICENSE).
|
|
341
366
|
|
|
342
367
|
## Learn More
|
|
343
368
|
|
|
@@ -354,3 +379,7 @@ MIT © pasxd245
|
|
|
354
379
|
- [Examples](examples/) - Working examples and templates
|
|
355
380
|
- [Issue Tracker](https://github.com/nci-gis/js-tmpl/issues) - Report bugs or request features
|
|
356
381
|
- [NPM Package](https://www.npmjs.com/package/@nci-gis/js-tmpl) - Package registry
|
|
382
|
+
|
|
383
|
+
## Transparency
|
|
384
|
+
|
|
385
|
+
AI-assisted development (e.g., Claude Code, Copilot) was used for scaffolding and iteration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nci-gis/js-tmpl",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The pure JavaScript templating engine that uses handlebars.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
"test": "node --test $(find tests -name '*.test.js')",
|
|
17
17
|
"test:watch": "node --test --watch $(find tests -name '*.test.js')",
|
|
18
18
|
"test:coverage": "node --experimental-test-coverage --test $(find tests -name '*.test.js')",
|
|
19
|
-
"format": "
|
|
20
|
-
"format:check": "
|
|
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'",
|
|
@@ -62,6 +66,8 @@
|
|
|
62
66
|
"js-yaml": "^4.1.1"
|
|
63
67
|
},
|
|
64
68
|
"devDependencies": {
|
|
69
|
+
"@commitlint/cli": "^20.5.0",
|
|
70
|
+
"@commitlint/config-conventional": "^20.5.0",
|
|
65
71
|
"@eslint/js": "^9.39.2",
|
|
66
72
|
"@types/js-yaml": "^4.0.9",
|
|
67
73
|
"@types/node": "^25.5.2",
|
package/src/cli/args.js
CHANGED
package/src/cli/usage.js
CHANGED
|
@@ -6,7 +6,8 @@ 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)
|
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
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertNoDuplicate,
|
|
6
|
+
assertValidSegments,
|
|
7
|
+
deriveNamespace,
|
|
8
|
+
} from '../utils/namespacing.js';
|
|
9
|
+
import { loadYamlOrJson } from './loader.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_EXTS = ['.yaml', '.yml', '.json'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Place a value into the namespaced tree. Assumes prefix-shadow collisions
|
|
15
|
+
* have already been ruled out by the caller, so intermediate segments can
|
|
16
|
+
* be created freely.
|
|
17
|
+
*
|
|
18
|
+
* @param {Record<string, unknown>} tree
|
|
19
|
+
* @param {string[]} chain
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
*/
|
|
22
|
+
function placeInTree(tree, chain, value) {
|
|
23
|
+
/** @type {Record<string, unknown>} */
|
|
24
|
+
let cur = tree;
|
|
25
|
+
for (let i = 0; i < chain.length - 1; i++) {
|
|
26
|
+
const seg = chain[i];
|
|
27
|
+
if (!(seg in cur)) {
|
|
28
|
+
cur[seg] = {};
|
|
29
|
+
}
|
|
30
|
+
cur = /** @type {Record<string, unknown>} */ (cur[seg]);
|
|
31
|
+
}
|
|
32
|
+
cur[/** @type {string} */ (chain.at(-1))] = value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cross-check for prefix-shadow collisions: two files whose dotted keys are
|
|
37
|
+
* such that one is a strict prefix of the other (e.g. `env` vs `env.prod`).
|
|
38
|
+
* A file's contents can't simultaneously be a leaf *and* a sub-tree.
|
|
39
|
+
*
|
|
40
|
+
* @param {Array<{ key: string, abs: string }>} entries
|
|
41
|
+
* @param {string} rootDir
|
|
42
|
+
*/
|
|
43
|
+
function assertNoPrefixShadow(entries, rootDir) {
|
|
44
|
+
const byKey = new Map(entries.map((e) => [e.key, e.abs]));
|
|
45
|
+
for (const { key, abs } of entries) {
|
|
46
|
+
for (const other of byKey.keys()) {
|
|
47
|
+
if (other === key) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (key.startsWith(`${other}.`)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Value partial shadow collision between '${other}' and '${key}':\n` +
|
|
53
|
+
` - ${path.relative(rootDir, /** @type {string} */ (byKey.get(other)))}\n` +
|
|
54
|
+
` - ${path.relative(rootDir, abs)}\n` +
|
|
55
|
+
`A file's contents cannot be both a leaf and a sub-tree.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scan `valuesDir` recursively and assemble a namespaced tree of values.
|
|
64
|
+
*
|
|
65
|
+
* - Files are classified by path via `deriveNamespace` — `@<name>/` anywhere
|
|
66
|
+
* in the chain triggers flatten (root-independent).
|
|
67
|
+
* - Supported formats per VP-10: `.yaml`, `.yml`, `.json` (first-match wins
|
|
68
|
+
* if a file ends with multiple listed extensions — not a realistic case).
|
|
69
|
+
* - VP-4 is enforced via duplicate-throws and shadow-collision detection.
|
|
70
|
+
* - Empty or absent `valuesDir` returns an empty tree.
|
|
71
|
+
*
|
|
72
|
+
* Synchronous to match `loadYamlOrJson` and `resolveConfig`'s sync-all-the-way
|
|
73
|
+
* model. File counts in a values tree are small, so blocking I/O is fine.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} valuesDir - Absolute path to the value-partials root.
|
|
76
|
+
* @param {string[]} [exts] - Accepted extensions (defaults to VP-10 set).
|
|
77
|
+
* @returns {Record<string, unknown>}
|
|
78
|
+
*/
|
|
79
|
+
export function scanValuePartials(valuesDir, exts = DEFAULT_EXTS) {
|
|
80
|
+
if (!valuesDir || !fs.existsSync(valuesDir)) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const all = /** @type {string[]} */ (
|
|
85
|
+
fs.readdirSync(valuesDir, { recursive: true })
|
|
86
|
+
);
|
|
87
|
+
const files = all.filter((f) => exts.some((e) => f.endsWith(e)));
|
|
88
|
+
|
|
89
|
+
/** @type {Map<string, string>} */
|
|
90
|
+
const seen = new Map();
|
|
91
|
+
/** @type {Array<{ key: string, chain: string[], abs: string }>} */
|
|
92
|
+
const entries = [];
|
|
93
|
+
|
|
94
|
+
for (const rel of files) {
|
|
95
|
+
const ext = /** @type {string} */ (exts.find((e) => rel.endsWith(e)));
|
|
96
|
+
const abs = path.join(valuesDir, rel);
|
|
97
|
+
const chain = deriveNamespace({ relPath: rel, ext });
|
|
98
|
+
|
|
99
|
+
assertValidSegments(chain, abs, 'value partial');
|
|
100
|
+
|
|
101
|
+
const key = chain.join('.');
|
|
102
|
+
assertNoDuplicate(seen, key, abs, valuesDir, 'value partial');
|
|
103
|
+
entries.push({ key, chain, abs });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
assertNoPrefixShadow(entries, valuesDir);
|
|
107
|
+
|
|
108
|
+
/** @type {Record<string, unknown>} */
|
|
109
|
+
const tree = {};
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
placeInTree(tree, e.chain, loadYamlOrJson(e.abs));
|
|
112
|
+
}
|
|
113
|
+
return tree;
|
|
114
|
+
}
|
package/src/config/view.js
CHANGED
|
@@ -1,25 +1,98 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* @typedef {object} BuildViewArgs
|
|
3
|
+
* @property {Record<string, unknown>} [rootValues] - Top-level values from `valuesFile`.
|
|
4
|
+
* @property {Record<string, unknown>} [partials] - Namespaced tree from `valuesDir`.
|
|
5
|
+
* @property {Record<string, string>} [env] - Allowlisted environment variables.
|
|
6
|
+
* @property {string} [valuesFile] - Source path, for error messages.
|
|
7
|
+
* @property {string} [valuesDir] - Source path, for error messages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const RESERVED_ENV = 'env';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the view object from root values + namespaced partials + env.
|
|
3
14
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
15
|
+
* Collision rules (from plan):
|
|
16
|
+
* - **C-2** — a top-level key in `rootValues` that also appears as a top-level
|
|
17
|
+
* namespace in `partials` is a hard error naming both sources.
|
|
18
|
+
* - **C-3** — a top-level namespace named `env` in `partials` is a hard error
|
|
19
|
+
* (reserved for environment variables).
|
|
7
20
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
21
|
+
* The `env` reserved key is always set from the provided `env` object. If
|
|
22
|
+
* `rootValues` contains a top-level `env`, a warning is logged and the key
|
|
23
|
+
* is overwritten (existing behavior preserved for back-compat).
|
|
24
|
+
*
|
|
25
|
+
* @param {BuildViewArgs} [args]
|
|
10
26
|
* @returns {Record<string, unknown>}
|
|
11
27
|
*/
|
|
12
|
-
export function buildView(
|
|
13
|
-
|
|
28
|
+
export function buildView(args = {}) {
|
|
29
|
+
const rootValues = args.rootValues ?? {};
|
|
30
|
+
const partials = args.partials ?? {};
|
|
31
|
+
const env = args.env ?? {};
|
|
32
|
+
const valuesFile = args.valuesFile ?? '<valuesFile>';
|
|
33
|
+
const valuesDir = args.valuesDir ?? '<valuesDir>';
|
|
34
|
+
|
|
35
|
+
assertReservedEnvNotInPartials(partials, valuesDir);
|
|
36
|
+
assertNoRootNamespaceCollision(rootValues, partials, valuesFile, valuesDir);
|
|
37
|
+
warnOnReservedEnvInValuesFile(rootValues);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...rootValues,
|
|
41
|
+
...partials,
|
|
42
|
+
[RESERVED_ENV]: env,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Record<string, unknown>} partials
|
|
48
|
+
* @param {string} valuesDir
|
|
49
|
+
*/
|
|
50
|
+
function assertReservedEnvNotInPartials(partials, valuesDir) {
|
|
51
|
+
if (RESERVED_ENV in partials) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Value partial conflicts with reserved 'env' namespace.\n` +
|
|
54
|
+
` Source: ${valuesDir} produced a top-level 'env' namespace.\n` +
|
|
55
|
+
` Rename the directory/file so it does not resolve to 'env'.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Record<string, unknown>} rootValues
|
|
62
|
+
* @param {Record<string, unknown>} partials
|
|
63
|
+
* @param {string} valuesFile
|
|
64
|
+
* @param {string} valuesDir
|
|
65
|
+
*/
|
|
66
|
+
function assertNoRootNamespaceCollision(
|
|
67
|
+
rootValues,
|
|
68
|
+
partials,
|
|
69
|
+
valuesFile,
|
|
70
|
+
valuesDir,
|
|
71
|
+
) {
|
|
72
|
+
for (const key of Object.keys(rootValues)) {
|
|
73
|
+
if (key === RESERVED_ENV) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (key in partials) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Duplicate view key '${key}' — registered by both:\n` +
|
|
79
|
+
` - ${valuesFile} top-level key\n` +
|
|
80
|
+
` - ${valuesDir}/${key}.(yaml|yml|json)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {Record<string, unknown>} values
|
|
88
|
+
*/
|
|
89
|
+
function warnOnReservedEnvInValuesFile(values) {
|
|
90
|
+
if (RESERVED_ENV in values) {
|
|
14
91
|
console.warn(
|
|
15
92
|
'Warning: "env" is a reserved key in js-tmpl and will be overwritten.\n' +
|
|
16
93
|
'Rename the "env" key in your values file to avoid this.',
|
|
17
94
|
);
|
|
18
95
|
}
|
|
19
|
-
return {
|
|
20
|
-
...values,
|
|
21
|
-
env,
|
|
22
|
-
};
|
|
23
96
|
}
|
|
24
97
|
|
|
25
98
|
/**
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
import Handlebars from 'handlebars';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Render template file with Handlebars
|
|
7
|
-
*
|
|
7
|
+
* Render a template file with Handlebars against the view.
|
|
8
|
+
*
|
|
9
|
+
* Strict mode (VP-9): `{{var}}` on an undefined path throws rather than
|
|
10
|
+
* rendering empty. The error includes the template's relative path — this
|
|
11
|
+
* makes forgotten values a loud failure, not a silent blank.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} filePath - Absolute path to the template file.
|
|
8
14
|
* @param {Record<string, unknown>} view
|
|
9
|
-
* @param {typeof Handlebars} [hbs] - Scoped Handlebars instance; falls back to global
|
|
15
|
+
* @param {typeof Handlebars} [hbs] - Scoped Handlebars instance; falls back to global.
|
|
16
|
+
* @param {string} [relPath] - Relative path (from templateDir), used in error messages.
|
|
10
17
|
* @returns {Promise<string>}
|
|
11
18
|
*/
|
|
12
|
-
export async function renderContent(filePath, view, hbs) {
|
|
19
|
+
export async function renderContent(filePath, view, hbs, relPath) {
|
|
13
20
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
14
|
-
const compile = (hbs || Handlebars).compile(raw);
|
|
15
|
-
|
|
21
|
+
const compile = (hbs || Handlebars).compile(raw, { strict: true });
|
|
22
|
+
try {
|
|
23
|
+
return compile(view);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const label = relPath || path.basename(filePath);
|
|
26
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
27
|
+
throw new Error(`Template '${label}': ${msg}`);
|
|
28
|
+
}
|
|
16
29
|
}
|
package/src/engine/partials.js
CHANGED
|
@@ -1,51 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
assertNoDuplicate,
|
|
5
|
+
assertValidSegments,
|
|
6
|
+
deriveNamespace,
|
|
7
|
+
} from '../utils/namespacing.js';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
|
-
*
|
|
8
|
-
* @param {string[]} segments
|
|
9
|
-
* @param {string} filePath
|
|
10
|
-
*/
|
|
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.
|
|
10
|
+
* Derive a partial entry from a relative file path.
|
|
21
11
|
*
|
|
22
12
|
* @param {string} partialsDir - Root partials directory
|
|
23
13
|
* @param {string} ext - Template extension (e.g. ".hbs")
|
|
24
14
|
* @param {string} filePath - Relative path from partialsDir
|
|
25
|
-
* @param {boolean} isFlat - If true, register by filename only; otherwise namespace by path
|
|
26
15
|
* @returns {{ name: string, source: string }}
|
|
27
16
|
*/
|
|
28
|
-
function processPartialFile(partialsDir, ext, filePath
|
|
29
|
-
const abs =
|
|
30
|
-
|
|
31
|
-
|
|
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);
|
|
17
|
+
function processPartialFile(partialsDir, ext, filePath) {
|
|
18
|
+
const abs = `${partialsDir}/${filePath}`;
|
|
19
|
+
const segments = deriveNamespace({ relPath: filePath, ext });
|
|
20
|
+
assertValidSegments(segments, abs, 'partial name');
|
|
44
21
|
return { name: segments.join('.'), source: abs };
|
|
45
22
|
}
|
|
46
23
|
|
|
47
24
|
/**
|
|
48
25
|
* Scan partialsDir recursively and collect all partial entries.
|
|
26
|
+
*
|
|
49
27
|
* @param {string} partialsDir
|
|
50
28
|
* @param {string} ext
|
|
51
29
|
* @returns {Promise<Array<{ name: string, source: string }>>}
|
|
@@ -55,34 +33,26 @@ async function scanPartialFiles(partialsDir, ext) {
|
|
|
55
33
|
|
|
56
34
|
return allFiles
|
|
57
35
|
.filter((f) => f.endsWith(ext))
|
|
58
|
-
.map((f) =>
|
|
59
|
-
const isFlat = f.startsWith('@');
|
|
60
|
-
return processPartialFile(partialsDir, ext, f, isFlat);
|
|
61
|
-
});
|
|
36
|
+
.map((f) => processPartialFile(partialsDir, ext, f));
|
|
62
37
|
}
|
|
63
38
|
|
|
64
39
|
/**
|
|
65
|
-
*
|
|
40
|
+
* Throw on duplicate partial names.
|
|
41
|
+
*
|
|
66
42
|
* @param {Array<{ name: string, source: string }>} entries
|
|
67
43
|
* @param {string} partialsDir
|
|
68
44
|
*/
|
|
69
45
|
function checkDuplicates(entries, partialsDir) {
|
|
70
46
|
/** @type {Map<string, string>} */
|
|
71
47
|
const seen = new Map();
|
|
72
|
-
|
|
73
48
|
for (const entry of entries) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
` - ${rel2}\n` +
|
|
82
|
-
`Use namespaced directories to avoid collisions.`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
seen.set(entry.name, entry.source);
|
|
49
|
+
assertNoDuplicate(
|
|
50
|
+
seen,
|
|
51
|
+
entry.name,
|
|
52
|
+
entry.source,
|
|
53
|
+
partialsDir,
|
|
54
|
+
'partial name',
|
|
55
|
+
);
|
|
86
56
|
}
|
|
87
57
|
}
|
|
88
58
|
|
|
@@ -90,9 +60,11 @@ function checkDuplicates(entries, partialsDir) {
|
|
|
90
60
|
* Register partials from a directory onto a Handlebars instance.
|
|
91
61
|
*
|
|
92
62
|
* Naming conventions:
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
63
|
+
* - `name.hbs` in root → "name"
|
|
64
|
+
* - `dir/name.hbs` → "dir.name" (namespaced by directory path)
|
|
65
|
+
* - `@<name>/` anywhere in the path flattens the chain: the key is the
|
|
66
|
+
* file's basename. Root-independent — scanning `partials/` vs
|
|
67
|
+
* `partials/foo/` yields the same key for `partials/foo/@shared/x.hbs`.
|
|
96
68
|
*
|
|
97
69
|
* Throws on duplicate partial names or invalid name segments.
|
|
98
70
|
* Skips silently if partialsDir is falsy. Throws if the directory does not exist.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getNested } from '../utils/object.js';
|
|
2
|
+
import { classifySegment } from './pathSegment.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check whether a dotted key path is *present* in `view` as an own property.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from `getNested`, which can't tell "missing" from "present but null/undefined".
|
|
8
|
+
* Required by G-4 (missing var throws) vs G-3 (present-but-falsy fails).
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} view
|
|
11
|
+
* @param {string} key
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
function hasNested(view, key) {
|
|
15
|
+
const parts = key.split('.');
|
|
16
|
+
let cur = view;
|
|
17
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
18
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
cur = /** @type {Record<string, unknown>} */ (cur)[parts[i]];
|
|
22
|
+
}
|
|
23
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return Object.hasOwn(cur, parts[parts.length - 1]); // NOSONAR
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Evaluate a single path segment against `view`.
|
|
31
|
+
*
|
|
32
|
+
* Returns `'pass'` for non-formula segments (literal, interpolation) and for
|
|
33
|
+
* formulas whose condition is satisfied. Returns `'skip'` when a formula's
|
|
34
|
+
* condition fails. Throws on malformed segments (G-5) or missing vars (G-4).
|
|
35
|
+
*
|
|
36
|
+
* Semantics (from plan):
|
|
37
|
+
* - G-3 JS-truthy rule: `false`, `0`, `''`, `null`, `undefined` → falsy.
|
|
38
|
+
* - G-4 Missing var throws with var name + containing relPath.
|
|
39
|
+
* - `$ifn` inverts `$if`.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} segment
|
|
42
|
+
* @param {Record<string, unknown>} view
|
|
43
|
+
* @param {string} [relPath] - Used to enrich error messages; optional.
|
|
44
|
+
* @returns {'pass' | 'skip'}
|
|
45
|
+
*/
|
|
46
|
+
export function evalFormula(segment, view, relPath) {
|
|
47
|
+
const c = classifySegment(segment);
|
|
48
|
+
|
|
49
|
+
if (c.kind === 'literal' || c.kind === 'interpolation') {
|
|
50
|
+
return 'pass';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (c.kind === 'malformed') {
|
|
54
|
+
throw new Error(
|
|
55
|
+
relPath
|
|
56
|
+
? `${c.reason} (in '${relPath}')`
|
|
57
|
+
: /** @type {string} */ (c.reason),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const varPath = /** @type {string} */ (c.var);
|
|
62
|
+
|
|
63
|
+
if (!hasNested(view, varPath)) {
|
|
64
|
+
const where = relPath ? ` in '${relPath}'` : '';
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Path formula '${segment}'${where} references undefined view variable '${varPath}'`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const value = getNested(view, varPath);
|
|
71
|
+
const truthy = Boolean(value);
|
|
72
|
+
const condition = c.kind === 'if-formula' ? truthy : !truthy;
|
|
73
|
+
return condition ? 'pass' : 'skip';
|
|
74
|
+
}
|
|
@@ -1,23 +1,71 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
import { getNested } from '../utils/object.js';
|
|
4
|
+
import { classifySegment } from './pathSegment.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* Replace every `${var}` placeholder in a segment with the nested view value.
|
|
8
|
+
* Missing values render as empty strings (existing behavior, unchanged).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} seg
|
|
11
|
+
* @param {Record<string, unknown>} view
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function expandInterpolations(seg, view) {
|
|
15
|
+
return seg.replaceAll(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
16
|
+
const v = getNested(view, expr.trim());
|
|
17
|
+
return String(v ?? ''); // NOSONAR -- String conversion is intentional here
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render a single segment. Formulas are rejected in filename position (G-5);
|
|
23
|
+
* formulas in directory position are assumed pre-approved by the walker and
|
|
24
|
+
* collapse to an empty string (G-2). Malformed segments throw.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} seg
|
|
27
|
+
* @param {boolean} isFilename
|
|
28
|
+
* @param {Record<string, unknown>} view
|
|
29
|
+
* @param {string} relPath
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function renderSegment(seg, isFilename, view, relPath) {
|
|
33
|
+
const c = classifySegment(seg);
|
|
34
|
+
|
|
35
|
+
if (c.kind === 'literal') {
|
|
36
|
+
return seg;
|
|
37
|
+
}
|
|
38
|
+
if (c.kind === 'interpolation') {
|
|
39
|
+
return expandInterpolations(seg, view);
|
|
40
|
+
}
|
|
41
|
+
if (c.kind === 'malformed') {
|
|
42
|
+
throw new Error(`${c.reason} (in '${relPath}')`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// if-formula or ifn-formula
|
|
46
|
+
if (isFilename) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Path formula '${seg}' is not allowed in a filename (directories only) — in '${relPath}'`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render all segments of `relPath`. `${var}` is expanded; `$if{var}` /
|
|
56
|
+
* `$ifn{var}` directory segments collapse to empty (the walker already
|
|
57
|
+
* decided inclusion). Filename-position formulas and malformed segments
|
|
58
|
+
* throw.
|
|
59
|
+
*
|
|
7
60
|
* @param {string} relPath
|
|
8
61
|
* @param {Record<string, unknown>} view
|
|
9
62
|
* @returns {string}
|
|
10
63
|
*/
|
|
11
64
|
export function renderPath(relPath, view) {
|
|
12
65
|
const segments = relPath.split(path.sep);
|
|
13
|
-
|
|
14
|
-
const rendered = segments.map((seg) =>
|
|
15
|
-
|
|
16
|
-
seg.replace(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
17
|
-
const v = getNested(view, expr.trim());
|
|
18
|
-
return String(v ?? '');
|
|
19
|
-
}),
|
|
66
|
+
const lastIdx = segments.length - 1;
|
|
67
|
+
const rendered = segments.map((seg, idx) =>
|
|
68
|
+
renderSegment(seg, idx === lastIdx, view, relPath),
|
|
20
69
|
);
|
|
21
|
-
|
|
22
70
|
return path.join(...rendered);
|
|
23
71
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {'literal' | 'interpolation' | 'if-formula' | 'ifn-formula' | 'malformed'} SegmentKind
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} SegmentClassification
|
|
7
|
+
* @property {SegmentKind} kind
|
|
8
|
+
* @property {string} [var] - Trimmed variable expression (if-formula / ifn-formula only).
|
|
9
|
+
* @property {string} [reason] - Human-readable reason (malformed only).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const FORMULA_WHOLE = /^\$(if|ifn)\{([^}]+)\}$/;
|
|
13
|
+
const FORMULA_SUBSTR = /\$ifn?\{/;
|
|
14
|
+
const INTERPOLATION = /\$\{[^}]+\}/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Classify a single path segment.
|
|
18
|
+
*
|
|
19
|
+
* Pure and total — never throws. Callers dispatch on the returned `kind`.
|
|
20
|
+
*
|
|
21
|
+
* Kinds:
|
|
22
|
+
* - `if-formula` — whole segment matches `$if{var}`; `.var` holds the trimmed expression.
|
|
23
|
+
* - `ifn-formula` — whole segment matches `$ifn{var}`; `.var` holds the trimmed expression.
|
|
24
|
+
* - `malformed` — contains `$if{` or `$ifn{` but is not a whole-segment formula; `.reason` describes the violation.
|
|
25
|
+
* - `interpolation` — contains one or more `${var}` placeholders.
|
|
26
|
+
* - `literal` — none of the above.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} segment
|
|
29
|
+
* @returns {SegmentClassification}
|
|
30
|
+
*/
|
|
31
|
+
export function classifySegment(segment) {
|
|
32
|
+
const m = FORMULA_WHOLE.exec(segment);
|
|
33
|
+
if (m) {
|
|
34
|
+
return {
|
|
35
|
+
kind: m[1] === 'if' ? 'if-formula' : 'ifn-formula',
|
|
36
|
+
var: m[2].trim(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (FORMULA_SUBSTR.test(segment)) {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'malformed',
|
|
42
|
+
reason: `formulas must be whole segments in directory positions, one per segment: got '${segment}'`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (INTERPOLATION.test(segment)) {
|
|
46
|
+
return { kind: 'interpolation' };
|
|
47
|
+
}
|
|
48
|
+
return { kind: 'literal' };
|
|
49
|
+
}
|
|
@@ -20,7 +20,7 @@ export async function renderDirectory(cfg, hbs) {
|
|
|
20
20
|
hbs = hbs || Handlebars.create();
|
|
21
21
|
await registerPartials(partialsDir, extname, hbs);
|
|
22
22
|
|
|
23
|
-
const files = await walkTemplateTree(templateDir, extname);
|
|
23
|
+
const files = await walkTemplateTree(templateDir, { ext: extname, view });
|
|
24
24
|
|
|
25
25
|
for (const file of files) {
|
|
26
26
|
const relRendered = renderPath(file.relPath, view);
|
|
@@ -29,7 +29,7 @@ export async function renderDirectory(cfg, hbs) {
|
|
|
29
29
|
relRendered.replace(new RegExp(`${extname}$`), ''),
|
|
30
30
|
);
|
|
31
31
|
|
|
32
|
-
const content = await renderContent(file.absPath, view, hbs);
|
|
32
|
+
const content = await renderContent(file.absPath, view, hbs, file.relPath);
|
|
33
33
|
|
|
34
34
|
await ensureDir(path.dirname(target));
|
|
35
35
|
await writeFileSafe(target, content);
|
package/src/engine/treeWalker.js
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { evalFormula } from './pathFormula.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* True when a directory's basename is a path formula that evaluates to skip
|
|
8
|
+
* against `view`. Subtree pruning happens here — the walker short-circuits
|
|
9
|
+
* before any `readdir`.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} rel - Relative path from the walk root (empty = root itself)
|
|
12
|
+
* @param {Record<string, unknown> | undefined} view
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function shouldSkipSubtree(rel, view) {
|
|
16
|
+
if (!rel || view === undefined) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return evalFormula(path.basename(rel), view, rel) === 'skip';
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
/**
|
|
5
23
|
* BFS async folder walker.
|
|
24
|
+
*
|
|
25
|
+
* When `view` is provided, directory segments that match path-formula syntax
|
|
26
|
+
* (`$if{var}` / `$ifn{var}`) are evaluated against the view; failing formulas
|
|
27
|
+
* prune the subtree before any filesystem descent (early-exit — no
|
|
28
|
+
* `stat`/`readdir` on skipped paths).
|
|
29
|
+
*
|
|
6
30
|
* @param {string} rootDir
|
|
7
|
-
* @param {string} [
|
|
8
|
-
*
|
|
31
|
+
* @param {(string | { ext?: string, view?: Record<string, unknown> })} [optsOrExt]
|
|
32
|
+
* Options object, or a bare `ext` string for back-compat.
|
|
9
33
|
* @returns {Promise<import('../types.js').TemplateFile[]>}
|
|
10
34
|
*/
|
|
11
|
-
export async function walkTemplateTree(rootDir,
|
|
35
|
+
export async function walkTemplateTree(rootDir, optsOrExt) {
|
|
36
|
+
const opts =
|
|
37
|
+
typeof optsOrExt === 'string' ? { ext: optsOrExt } : optsOrExt || {};
|
|
38
|
+
const ext = opts.ext ?? '.hbs';
|
|
39
|
+
const view = opts.view;
|
|
40
|
+
|
|
41
|
+
/** @type {import('../types.js').TemplateFile[]} */
|
|
12
42
|
const results = [];
|
|
13
43
|
const queue = [''];
|
|
14
44
|
|
|
@@ -18,11 +48,11 @@ export async function walkTemplateTree(rootDir, ext = '.hbs', ignore = []) {
|
|
|
18
48
|
const stat = await fs.stat(abs);
|
|
19
49
|
|
|
20
50
|
if (stat.isDirectory()) {
|
|
51
|
+
if (shouldSkipSubtree(rel, view)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
21
54
|
const items = (await fs.readdir(abs)).sort();
|
|
22
55
|
for (const name of items) {
|
|
23
|
-
if (ignore.some((i) => matchIgnore(name, i))) {
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
56
|
queue.push(rel ? path.join(rel, name) : name);
|
|
27
57
|
}
|
|
28
58
|
} else if (path.extname(abs) === ext) {
|
|
@@ -32,12 +62,3 @@ export async function walkTemplateTree(rootDir, ext = '.hbs', ignore = []) {
|
|
|
32
62
|
|
|
33
63
|
return results;
|
|
34
64
|
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {string} name
|
|
38
|
-
* @param {string | RegExp} rule
|
|
39
|
-
* @returns {boolean}
|
|
40
|
-
*/
|
|
41
|
-
function matchIgnore(name, rule) {
|
|
42
|
-
return rule instanceof RegExp ? rule.test(name) : rule === name;
|
|
43
|
-
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Valid namespace segment: letters, digits, underscore (matches Handlebars
|
|
5
|
+
* bare-identifier convention and the partials system's historical rule).
|
|
6
|
+
*/
|
|
7
|
+
export const SEGMENT_RE = /^\w+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Match a "@name" flatten marker at any position in the relative path.
|
|
11
|
+
* Root-independent: scan-root choice does not change the outcome, because
|
|
12
|
+
* the marker is detected wherever it appears in the chain.
|
|
13
|
+
*/
|
|
14
|
+
const FLATTEN_SEGMENT_RE = /^@\w+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Throw a clear error when a namespace segment contains invalid characters.
|
|
18
|
+
*
|
|
19
|
+
* @param {string[]} segments - Chain as produced by `deriveNamespace`.
|
|
20
|
+
* @param {string} filePath - Absolute or repo-relative path, for the error message.
|
|
21
|
+
* @param {string} [label='namespace'] - Noun used in the error message (e.g. "partial name").
|
|
22
|
+
*/
|
|
23
|
+
export function assertValidSegments(segments, filePath, label = 'namespace') {
|
|
24
|
+
if (!SEGMENT_RE.test(segments.join(''))) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Invalid ${label} segment '${segments.join('>')}' in ${filePath} — only alphanumeric and underscore allowed`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Throw a clear error when two files resolve to the same namespace key.
|
|
33
|
+
*
|
|
34
|
+
* @param {Map<string, string>} seen - Key → absolute path of the first file that claimed it.
|
|
35
|
+
* @param {string} key - The colliding namespace (dot-joined chain or single name).
|
|
36
|
+
* @param {string} filePath - Absolute path of the second file.
|
|
37
|
+
* @param {string} rootDir - The scan root, used to produce relative paths in the error.
|
|
38
|
+
* @param {string} [label='namespace'] - Noun used in the error message (e.g. "partial name").
|
|
39
|
+
*/
|
|
40
|
+
export function assertNoDuplicate(
|
|
41
|
+
seen,
|
|
42
|
+
key,
|
|
43
|
+
filePath,
|
|
44
|
+
rootDir,
|
|
45
|
+
label = 'namespace',
|
|
46
|
+
) {
|
|
47
|
+
const existing = seen.get(key);
|
|
48
|
+
if (existing) {
|
|
49
|
+
const rel1 = path.relative(rootDir, existing);
|
|
50
|
+
const rel2 = path.relative(rootDir, filePath);
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Duplicate ${label} '${key}' — registered by both:\n` +
|
|
53
|
+
` - ${rel1}\n` +
|
|
54
|
+
` - ${rel2}\n` +
|
|
55
|
+
`Use namespaced directories to avoid collisions.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
seen.set(key, filePath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Derive a namespace chain from a relative file path.
|
|
63
|
+
*
|
|
64
|
+
* Rules:
|
|
65
|
+
* - Extension is stripped from the final segment.
|
|
66
|
+
* - If any segment in the chain matches `^@\w+$` (a flatten marker), the chain
|
|
67
|
+
* collapses to `[basename]` — the file contributes at top level of view /
|
|
68
|
+
* partial registry, regardless of where the `@name` appears. This is the
|
|
69
|
+
* **root-independent** flatten rule: scanning `values/` vs `values/env/`
|
|
70
|
+
* yields the same namespace for `values/env/@overrides/app.yaml`.
|
|
71
|
+
* - Otherwise, the chain is the directory path split by `path.sep`, with the
|
|
72
|
+
* final file's extension trimmed.
|
|
73
|
+
*
|
|
74
|
+
* The function does not validate segments — that's `assertValidSegments`'
|
|
75
|
+
* job, called by the scan helpers that consume this chain.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} args
|
|
78
|
+
* @param {string} args.relPath - Relative path from the scan root.
|
|
79
|
+
* @param {string} args.ext - Extension to strip from the basename (include the dot, e.g. `.yaml`).
|
|
80
|
+
* @returns {string[]} The derived namespace chain.
|
|
81
|
+
*/
|
|
82
|
+
export function deriveNamespace({ relPath, ext }) {
|
|
83
|
+
const trimmed = relPath.endsWith(ext)
|
|
84
|
+
? relPath.slice(0, -ext.length)
|
|
85
|
+
: relPath;
|
|
86
|
+
const segments = trimmed.split(path.sep);
|
|
87
|
+
|
|
88
|
+
if (segments.some((s) => FLATTEN_SEGMENT_RE.test(s))) {
|
|
89
|
+
return [/** @type {string} */ (segments.at(-1))];
|
|
90
|
+
}
|
|
91
|
+
return segments;
|
|
92
|
+
}
|