@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.
@@ -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
+ }
@@ -1,25 +1,98 @@
1
1
  /**
2
- * Build the view object by merging values with environment data.
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
- * **Reserved key:** `env` is always set to the provided environment object.
5
- * If `values` contains a top-level `env` key, a warning is logged and
6
- * the key is overwritten.
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
- * @param {Record<string, unknown>} values
9
- * @param {Record<string, string>} [env]
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(values, env = {}) {
13
- if ('env' in values) {
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 & view object.
7
- * @param {string} filePath
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
- return compile(view);
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
  }
@@ -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
+ }
@@ -1,51 +1,29 @@
1
1
  import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
2
 
4
- const VALID_SEGMENT = /^\w+$/;
3
+ import {
4
+ assertNoDuplicate,
5
+ assertValidSegments,
6
+ deriveNamespace,
7
+ } from '../utils/namespacing.js';
5
8
 
6
9
  /**
7
- * Validate a partial name segment (directory or file basename).
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, isFlat) {
29
- const abs = path.join(partialsDir, filePath);
30
-
31
- // Flat by filename: "name.hbs" → "name"
32
- if (isFlat) {
33
- const name = path.basename(filePath, ext);
34
- validateSegments([name], abs);
35
- return { name, source: abs };
36
- }
37
-
38
- // Namespace by path, e.g.:
39
- // - "dir/name.hbs" → "dir.name"
40
- // - "dir/sub/name.hbs" → "dir.sub.name"
41
- // - "name.hbs" → "name" (no nesting).
42
- const segments = filePath.slice(0, -ext.length).split(path.sep);
43
- validateSegments(segments, abs);
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
- * Check for duplicate partial names and throw if found.
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
- const existing = seen.get(entry.name);
75
- if (existing) {
76
- const rel1 = path.relative(partialsDir, existing);
77
- const rel2 = path.relative(partialsDir, entry.source);
78
- throw new Error(
79
- `Duplicate partial name '${entry.name}' — registered by both:\n` +
80
- ` - ${rel1}\n` +
81
- ` - ${rel2}\n` +
82
- `Use namespaced directories to avoid collisions.`,
83
- );
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
- * - `name.hbs` in root → "name"
94
- * - `dir/name.hbs` → "dir.name" (namespaced by directory path)
95
- * - `@dir/` at root → flatten entire subtree (filename only)
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
- * Render `${var}` placeholders in each segment of the relPath.
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
- //NOSONAR -- ignore S5842: Regular expression is safe here
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
+ }