@nci-gis/js-tmpl 0.0.1-beta.2 → 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.
@@ -1,78 +1,84 @@
1
- import path from "node:path";
2
- import process from "node:process";
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
3
 
4
- import { DEFAULTS } from "./defaults.js";
5
- import { loadProjectConfig,loadYamlOrJson } from "./loader.js";
6
- import { buildView } from "./view.js";
4
+ import { DEFAULTS } from './defaults.js';
5
+ import { loadProjectConfig, loadYamlOrJson } from './loader.js';
6
+ import { scanValuePartials } from './valuePartials.js';
7
+ import { buildView, pickEnv } from './view.js';
7
8
 
8
9
  /**
9
- * Resolve valuesFile path based on valuesDir
10
- * @param {string} valuesFile - The values file name or path
11
- * @param {string} valuesDir - Values directory (may be empty string)
12
- * @param {string} cwd - Current working directory
13
- * @returns {string} Absolute path to values file
10
+ * C-1 — throw if the resolved `valuesFile` sits inside the resolved
11
+ * `valuesDir`. A file loaded both as root and as a value partial would
12
+ * produce ambiguous collisions.
13
+ *
14
+ * @param {string} valuesFileAbs
15
+ * @param {string} valuesDirAbs
14
16
  */
15
- function resolveValuesFilePath(valuesFile, valuesDir, cwd) {
16
- // If absolute, use as-is
17
- if (path.isAbsolute(valuesFile)) {
18
- return valuesFile;
19
- }
20
-
21
- // If valuesDir is set (truthy), use it as base
22
- if (valuesDir) {
23
- const absoluteValuesDir = path.isAbsolute(valuesDir)
24
- ? valuesDir
25
- : path.join(cwd, valuesDir);
26
- return path.join(absoluteValuesDir, valuesFile);
17
+ function assertValuesFileNotInside(valuesFileAbs, valuesDirAbs) {
18
+ const rel = path.relative(valuesDirAbs, valuesFileAbs);
19
+ const inside = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
20
+ if (inside) {
21
+ throw new Error(
22
+ `valuesFile '${valuesFileAbs}' is inside valuesDir '${valuesDirAbs}'.\n` +
23
+ `Move the file out, or drop valuesDir.`,
24
+ );
27
25
  }
28
-
29
- // Otherwise, resolve from cwd
30
- return path.join(cwd, valuesFile);
31
26
  }
32
27
 
33
28
  /**
34
- * Resolve final config using:
35
- * defaults < projectConfig < cliArgs
29
+ * Resolve final config using: defaults < projectConfig < cliArgs.
30
+ *
31
+ * Value sources (all optional per VP-5, VP-6, VP-8):
32
+ * - `valuesFile` loaded into top-level view keys.
33
+ * - `valuesDir` scanned via `scanValuePartials` into a namespaced tree.
34
+ * - Allowlisted env vars under `view.env.*`.
36
35
  *
37
- * @param {object} cli - CLI arguments
38
- * @param {string} cwd - Current working directory
39
- * @returns {object} - Resolved configuration
36
+ * Collision rules C-1, C-2, C-3 apply and surface as hard errors.
37
+ *
38
+ * @param {import('../types.js').CliArgs} cli
39
+ * @param {string} [cwd]
40
+ * @returns {import('../types.js').TemplateConfig}
40
41
  */
41
42
  export function resolveConfig(cli, cwd = process.cwd()) {
42
43
  const projectConfig = loadProjectConfig(cwd, cli.configFile);
44
+ const mergedConfig = { ...DEFAULTS, ...projectConfig, ...cli };
43
45
 
44
- const mergedConfig = {
45
- ...DEFAULTS,
46
- ...projectConfig,
47
- ...cli,
48
- };
49
-
46
+ /** @param {string} p */
50
47
  const abs = (p) => (path.isAbsolute(p) ? p : path.join(cwd, p));
51
48
 
52
- // Validate valuesFile is provided
53
- if (!mergedConfig.valuesFile) {
54
- throw new Error(
55
- 'Missing required configuration: valuesFile\n' +
56
- 'Provide via:\n' +
57
- ' - CLI: --values path/to/values.yaml\n' +
58
- ' - Config: valuesFile: "path/to/values.yaml" in js-tmpl.config.yaml'
59
- );
49
+ const valuesFileAbs = mergedConfig.valuesFile
50
+ ? abs(mergedConfig.valuesFile)
51
+ : '';
52
+ const valuesDirAbs = mergedConfig.valuesDir
53
+ ? abs(mergedConfig.valuesDir)
54
+ : '';
55
+
56
+ if (valuesFileAbs && valuesDirAbs) {
57
+ assertValuesFileNotInside(valuesFileAbs, valuesDirAbs);
60
58
  }
61
59
 
62
- // Resolve path - simple logic based on valuesDir presence
63
- const valuesFilePath = resolveValuesFilePath(
64
- mergedConfig.valuesFile,
65
- mergedConfig.valuesDir,
66
- cwd
67
- );
60
+ const rootValues = valuesFileAbs ? loadYamlOrJson(valuesFileAbs) : {};
61
+ const partials = valuesDirAbs ? scanValuePartials(valuesDirAbs) : {};
68
62
 
69
- const values = loadYamlOrJson(valuesFilePath);
63
+ const hasEnvConfig = mergedConfig.envKeys?.length || mergedConfig.envPrefix;
64
+ const env = hasEnvConfig
65
+ ? pickEnv({
66
+ keys: mergedConfig.envKeys || [],
67
+ prefix: mergedConfig.envPrefix || '',
68
+ })
69
+ : {};
70
70
 
71
71
  return {
72
72
  templateDir: abs(mergedConfig.templateDir),
73
- partialsDir: abs(mergedConfig.partialsDir),
73
+ partialsDir: mergedConfig.partialsDir ? abs(mergedConfig.partialsDir) : '',
74
74
  outDir: abs(mergedConfig.outDir),
75
75
  extname: mergedConfig.extname,
76
- view: buildView(values),
76
+ view: buildView({
77
+ rootValues,
78
+ partials,
79
+ env,
80
+ valuesFile: valuesFileAbs || '<unset>',
81
+ valuesDir: valuesDirAbs || '<unset>',
82
+ }),
77
83
  };
78
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
+ }
@@ -1,6 +1,126 @@
1
- export function buildView(values, env = process.env) {
1
+ /**
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.
14
+ *
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).
20
+ *
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]
26
+ * @returns {Record<string, unknown>}
27
+ */
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
+
2
39
  return {
3
- ...values,
4
- env,
40
+ ...rootValues,
41
+ ...partials,
42
+ [RESERVED_ENV]: env,
5
43
  };
6
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) {
91
+ console.warn(
92
+ 'Warning: "env" is a reserved key in js-tmpl and will be overwritten.\n' +
93
+ 'Rename the "env" key in your values file to avoid this.',
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Pick environment variables by explicit keys and/or prefix.
100
+ *
101
+ * @param {object} options
102
+ * @param {string[]} options.keys - Explicit key names to include.
103
+ * @param {string} options.prefix - Include all vars starting with this prefix.
104
+ * @param {Record<string, string | undefined>} [source] - Env source (default: process.env).
105
+ * @returns {Record<string, string>}
106
+ */
107
+ export function pickEnv({ keys = [], prefix = '' }, source = process.env) {
108
+ /** @type {Record<string, string>} */
109
+ const result = {};
110
+
111
+ for (const k of keys) {
112
+ if (k in source) {
113
+ result[k] = /** @type {string} */ (source[k]);
114
+ }
115
+ }
116
+
117
+ if (prefix) {
118
+ for (const k of Object.keys(source)) {
119
+ if (k.startsWith(prefix)) {
120
+ result[k] = /** @type {string} */ (source[k]);
121
+ }
122
+ }
123
+ }
124
+
125
+ return result;
126
+ }
@@ -1,12 +1,29 @@
1
- import fs from "node:fs/promises";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
2
3
 
3
- import Handlebars from "handlebars";
4
+ import Handlebars from 'handlebars';
4
5
 
5
6
  /**
6
- * Render template file with Handlebars & view object.
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.
14
+ * @param {Record<string, unknown>} view
15
+ * @param {typeof Handlebars} [hbs] - Scoped Handlebars instance; falls back to global.
16
+ * @param {string} [relPath] - Relative path (from templateDir), used in error messages.
17
+ * @returns {Promise<string>}
7
18
  */
8
- export async function renderContent(filePath, view) {
9
- const raw = await fs.readFile(filePath, "utf8");
10
- const compile = Handlebars.compile(raw);
11
- return compile(view);
19
+ export async function renderContent(filePath, view, hbs, relPath) {
20
+ const raw = await fs.readFile(filePath, 'utf8');
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
+ }
12
29
  }
@@ -1,41 +1,89 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
3
2
 
4
- import Handlebars from "handlebars";
3
+ import {
4
+ assertNoDuplicate,
5
+ assertValidSegments,
6
+ deriveNamespace,
7
+ } from '../utils/namespacing.js';
5
8
 
6
9
  /**
7
- * Register partials from a directory.
8
- * - root partials use prefix "_"
9
- * - group partials under "@group"
10
+ * Derive a partial entry from a relative file path.
11
+ *
12
+ * @param {string} partialsDir - Root partials directory
13
+ * @param {string} ext - Template extension (e.g. ".hbs")
14
+ * @param {string} filePath - Relative path from partialsDir
15
+ * @returns {{ name: string, source: string }}
10
16
  */
11
- export async function registerPartials(partialsDir, ext = ".hbs") {
12
- const entries = await fs.readdir(partialsDir, { withFileTypes: true });
17
+ function processPartialFile(partialsDir, ext, filePath) {
18
+ const abs = `${partialsDir}/${filePath}`;
19
+ const segments = deriveNamespace({ relPath: filePath, ext });
20
+ assertValidSegments(segments, abs, 'partial name');
21
+ return { name: segments.join('.'), source: abs };
22
+ }
13
23
 
24
+ /**
25
+ * Scan partialsDir recursively and collect all partial entries.
26
+ *
27
+ * @param {string} partialsDir
28
+ * @param {string} ext
29
+ * @returns {Promise<Array<{ name: string, source: string }>>}
30
+ */
31
+ async function scanPartialFiles(partialsDir, ext) {
32
+ const allFiles = await fs.readdir(partialsDir, { recursive: true });
33
+
34
+ return allFiles
35
+ .filter((f) => f.endsWith(ext))
36
+ .map((f) => processPartialFile(partialsDir, ext, f));
37
+ }
38
+
39
+ /**
40
+ * Throw on duplicate partial names.
41
+ *
42
+ * @param {Array<{ name: string, source: string }>} entries
43
+ * @param {string} partialsDir
44
+ */
45
+ function checkDuplicates(entries, partialsDir) {
46
+ /** @type {Map<string, string>} */
47
+ const seen = new Map();
14
48
  for (const entry of entries) {
15
- const abs = path.join(partialsDir, entry.name);
16
-
17
- if (entry.isDirectory()) {
18
- // handle namespacing
19
- if (entry.name.startsWith("@")) {
20
- const group = entry.name.slice(1);
21
- const groupFiles = await fs.readdir(abs);
22
-
23
- for (const f of groupFiles) {
24
- if (!f.endsWith(ext)) {continue;}
25
- const key = path.basename(f, ext);
26
- const name = `${group}.${key}`;
27
- const content = await fs.readFile(path.join(abs, f), "utf8");
28
- Handlebars.registerPartial(name, content);
29
- }
30
- }
31
- continue;
32
- }
33
-
34
- // root partial
35
- if (entry.name.startsWith("_") && entry.name.endsWith(ext)) {
36
- const content = await fs.readFile(abs, "utf8");
37
- const key = entry.name.slice(1, -ext.length);
38
- Handlebars.registerPartial(key, content);
39
- }
49
+ assertNoDuplicate(
50
+ seen,
51
+ entry.name,
52
+ entry.source,
53
+ partialsDir,
54
+ 'partial name',
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Register partials from a directory onto a Handlebars instance.
61
+ *
62
+ * Naming conventions:
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`.
68
+ *
69
+ * Throws on duplicate partial names or invalid name segments.
70
+ * Skips silently if partialsDir is falsy. Throws if the directory does not exist.
71
+ *
72
+ * @param {string} partialsDir - Path to partials directory
73
+ * @param {string} ext - Template file extension (e.g. ".hbs")
74
+ * @param {import('handlebars')} hbs - Handlebars instance to register partials on
75
+ */
76
+ export async function registerPartials(partialsDir, ext, hbs) {
77
+ if (!partialsDir) {
78
+ return;
79
+ }
80
+
81
+ const allPartials = await scanPartialFiles(partialsDir, ext);
82
+
83
+ checkDuplicates(allPartials, partialsDir);
84
+
85
+ for (const p of allPartials) {
86
+ const content = await fs.readFile(p.source, 'utf8');
87
+ hbs.registerPartial(p.name, content);
40
88
  }
41
89
  }
@@ -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
+ }