@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.
@@ -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,21 +72,15 @@ import { walkTemplateTree } from './treeWalker.js';
15
72
  * @returns {Promise<void>}
16
73
  */
17
74
  export async function renderDirectory(cfg, hbs) {
18
- const { templateDir, partialsDir, outDir, view, extname } = cfg;
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
- const files = await walkTemplateTree(templateDir, extname);
24
-
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
- );
80
+ const files = await walkTemplateTree(templateDir, { ext: extname, view });
31
81
 
32
- const content = await renderContent(file.absPath, view, hbs);
82
+ for (const { file, target } of planTargets(files, cfg)) {
83
+ const content = await renderContent(file.absPath, view, hbs, file.relPath);
33
84
 
34
85
  await ensureDir(path.dirname(target));
35
86
  await writeFileSafe(target, content);
@@ -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} [ext]
8
- * @param {Array<string | RegExp>} [ignore]
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, ext = '.hbs', ignore = []) {
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
- }
package/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './config/resolver.js';
2
+ export * from './engine/helpers.js';
2
3
  export * from './engine/renderDirectory.js';
package/src/types.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * @property {string} [valuesDir]
11
11
  * @property {string[]} [envKeys]
12
12
  * @property {string} [envPrefix]
13
+ * @property {boolean} [verbose] - CLI only: print stack traces on error
13
14
  */
14
15
 
15
16
  /**
@@ -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
+ }
package/bin/js-tmpl DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env bash
2
- # CLI entry point for js-tmpl
3
- node "$(dirname "$0")/../src/cli/main.js" "$@"