@hostwebhook/template-engine 1.0.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/dist/index.d.mts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +476 -0
- package/dist/index.mjs +433 -0
- package/package.json +39 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
type TemplateMode = 'json' | 'text' | 'html' | 'code' | 'auto';
|
|
2
|
+
interface TemplateOptions {
|
|
3
|
+
/** Template processing mode. Default: 'auto' (json if starts with {/[, else text). */
|
|
4
|
+
mode?: TemplateMode;
|
|
5
|
+
/** Enable JS expression evaluation within {{...}} (json/text/html modes). Default: false. */
|
|
6
|
+
allowJs?: boolean;
|
|
7
|
+
/** Workspace payloads for $() cross-node references. */
|
|
8
|
+
workspacePayloads?: Record<string, Record<string, unknown>>;
|
|
9
|
+
/** Timeout for JS execution in ms. Default: 1000 for template modes, 5000 for code mode. */
|
|
10
|
+
timeout?: number;
|
|
11
|
+
/** Code mode only: receive full iterable array as payload instead of per-item. */
|
|
12
|
+
batchMode?: boolean;
|
|
13
|
+
}
|
|
14
|
+
interface TemplateContext {
|
|
15
|
+
payload: unknown;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
meta: Record<string, string>;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
interface TemplateResult {
|
|
21
|
+
/** The interpolated output string. */
|
|
22
|
+
output: string;
|
|
23
|
+
/** Type-preserved value when a single {{expr}} resolves to a non-string (json mode). */
|
|
24
|
+
rawValue?: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface CodeResult {
|
|
27
|
+
/** The return value from the executed code. */
|
|
28
|
+
output: unknown;
|
|
29
|
+
/** 200 on success, 500 on error. */
|
|
30
|
+
statusCode: number;
|
|
31
|
+
/** Captured console.log/warn/error output. */
|
|
32
|
+
logs: string[];
|
|
33
|
+
/** Error message if statusCode is 500. */
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a template with the specified mode.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // JSON mode — type-preserving interpolation
|
|
42
|
+
* renderTemplate('{"name": "{{payload.name}}"}', ctx, { mode: 'json' });
|
|
43
|
+
*
|
|
44
|
+
* // Text mode — plain string replacement
|
|
45
|
+
* renderTemplate('Hello {{payload.name}}!', ctx, { mode: 'text' });
|
|
46
|
+
*
|
|
47
|
+
* // HTML mode — entity-encoded output
|
|
48
|
+
* renderTemplate('<p>{{payload.html}}</p>', ctx, { mode: 'html' });
|
|
49
|
+
*
|
|
50
|
+
* // Code mode — sandboxed JS execution
|
|
51
|
+
* renderTemplate('return payload.items.length;', ctx, { mode: 'code' });
|
|
52
|
+
*
|
|
53
|
+
* // Auto mode — detects json vs text (legacy behavior)
|
|
54
|
+
* renderTemplate(template, ctx); // default
|
|
55
|
+
*/
|
|
56
|
+
declare function renderTemplate(template: string, ctx: TemplateContext, opts?: TemplateOptions): TemplateResult | CodeResult;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a dot-notation path against a TemplateContext.
|
|
60
|
+
* Supports bracket notation for array indexing: `payload.items[0].name`
|
|
61
|
+
*/
|
|
62
|
+
declare function resolvePath(path: string, ctx: TemplateContext): unknown;
|
|
63
|
+
/** Convert a value to string. null/undefined → '', objects → JSON.stringify. */
|
|
64
|
+
declare function valueToString(val: unknown): string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Try null-coalescing: path ?? "fallback"
|
|
68
|
+
* Returns null if expression is not a null-coalesce.
|
|
69
|
+
*/
|
|
70
|
+
declare function tryNullCoalesce(expr: string, ctx: TemplateContext): string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Parse and evaluate a ternary expression.
|
|
73
|
+
* Returns null if the expression is not a ternary.
|
|
74
|
+
*/
|
|
75
|
+
declare function tryTernary(expr: string, ctx: TemplateContext): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Apply a pipe transform: | json, | upper, | lower, | trim
|
|
78
|
+
*/
|
|
79
|
+
declare function applyPipe(pipe: string, val: unknown): string;
|
|
80
|
+
/**
|
|
81
|
+
* Evaluate a single {{...}} expression and return a string.
|
|
82
|
+
*/
|
|
83
|
+
declare function evaluate(expr: string, ctx: TemplateContext, opts?: TemplateOptions): string;
|
|
84
|
+
|
|
85
|
+
export { type CodeResult, type TemplateContext, type TemplateMode, type TemplateOptions, type TemplateResult, applyPipe, evaluate, renderTemplate, resolvePath, tryNullCoalesce, tryTernary, valueToString };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
type TemplateMode = 'json' | 'text' | 'html' | 'code' | 'auto';
|
|
2
|
+
interface TemplateOptions {
|
|
3
|
+
/** Template processing mode. Default: 'auto' (json if starts with {/[, else text). */
|
|
4
|
+
mode?: TemplateMode;
|
|
5
|
+
/** Enable JS expression evaluation within {{...}} (json/text/html modes). Default: false. */
|
|
6
|
+
allowJs?: boolean;
|
|
7
|
+
/** Workspace payloads for $() cross-node references. */
|
|
8
|
+
workspacePayloads?: Record<string, Record<string, unknown>>;
|
|
9
|
+
/** Timeout for JS execution in ms. Default: 1000 for template modes, 5000 for code mode. */
|
|
10
|
+
timeout?: number;
|
|
11
|
+
/** Code mode only: receive full iterable array as payload instead of per-item. */
|
|
12
|
+
batchMode?: boolean;
|
|
13
|
+
}
|
|
14
|
+
interface TemplateContext {
|
|
15
|
+
payload: unknown;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
meta: Record<string, string>;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
interface TemplateResult {
|
|
21
|
+
/** The interpolated output string. */
|
|
22
|
+
output: string;
|
|
23
|
+
/** Type-preserved value when a single {{expr}} resolves to a non-string (json mode). */
|
|
24
|
+
rawValue?: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface CodeResult {
|
|
27
|
+
/** The return value from the executed code. */
|
|
28
|
+
output: unknown;
|
|
29
|
+
/** 200 on success, 500 on error. */
|
|
30
|
+
statusCode: number;
|
|
31
|
+
/** Captured console.log/warn/error output. */
|
|
32
|
+
logs: string[];
|
|
33
|
+
/** Error message if statusCode is 500. */
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a template with the specified mode.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* // JSON mode — type-preserving interpolation
|
|
42
|
+
* renderTemplate('{"name": "{{payload.name}}"}', ctx, { mode: 'json' });
|
|
43
|
+
*
|
|
44
|
+
* // Text mode — plain string replacement
|
|
45
|
+
* renderTemplate('Hello {{payload.name}}!', ctx, { mode: 'text' });
|
|
46
|
+
*
|
|
47
|
+
* // HTML mode — entity-encoded output
|
|
48
|
+
* renderTemplate('<p>{{payload.html}}</p>', ctx, { mode: 'html' });
|
|
49
|
+
*
|
|
50
|
+
* // Code mode — sandboxed JS execution
|
|
51
|
+
* renderTemplate('return payload.items.length;', ctx, { mode: 'code' });
|
|
52
|
+
*
|
|
53
|
+
* // Auto mode — detects json vs text (legacy behavior)
|
|
54
|
+
* renderTemplate(template, ctx); // default
|
|
55
|
+
*/
|
|
56
|
+
declare function renderTemplate(template: string, ctx: TemplateContext, opts?: TemplateOptions): TemplateResult | CodeResult;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a dot-notation path against a TemplateContext.
|
|
60
|
+
* Supports bracket notation for array indexing: `payload.items[0].name`
|
|
61
|
+
*/
|
|
62
|
+
declare function resolvePath(path: string, ctx: TemplateContext): unknown;
|
|
63
|
+
/** Convert a value to string. null/undefined → '', objects → JSON.stringify. */
|
|
64
|
+
declare function valueToString(val: unknown): string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Try null-coalescing: path ?? "fallback"
|
|
68
|
+
* Returns null if expression is not a null-coalesce.
|
|
69
|
+
*/
|
|
70
|
+
declare function tryNullCoalesce(expr: string, ctx: TemplateContext): string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Parse and evaluate a ternary expression.
|
|
73
|
+
* Returns null if the expression is not a ternary.
|
|
74
|
+
*/
|
|
75
|
+
declare function tryTernary(expr: string, ctx: TemplateContext): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Apply a pipe transform: | json, | upper, | lower, | trim
|
|
78
|
+
*/
|
|
79
|
+
declare function applyPipe(pipe: string, val: unknown): string;
|
|
80
|
+
/**
|
|
81
|
+
* Evaluate a single {{...}} expression and return a string.
|
|
82
|
+
*/
|
|
83
|
+
declare function evaluate(expr: string, ctx: TemplateContext, opts?: TemplateOptions): string;
|
|
84
|
+
|
|
85
|
+
export { type CodeResult, type TemplateContext, type TemplateMode, type TemplateOptions, type TemplateResult, applyPipe, evaluate, renderTemplate, resolvePath, tryNullCoalesce, tryTernary, valueToString };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
applyPipe: () => applyPipe,
|
|
34
|
+
evaluate: () => evaluate,
|
|
35
|
+
renderTemplate: () => renderTemplate,
|
|
36
|
+
resolvePath: () => resolvePath,
|
|
37
|
+
tryNullCoalesce: () => tryNullCoalesce,
|
|
38
|
+
tryTernary: () => tryTernary,
|
|
39
|
+
valueToString: () => valueToString
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(index_exports);
|
|
42
|
+
|
|
43
|
+
// src/resolve-path.ts
|
|
44
|
+
function resolvePath(path, ctx) {
|
|
45
|
+
const [prefix, ...rest] = path.split(".");
|
|
46
|
+
const key = rest.join(".");
|
|
47
|
+
let source;
|
|
48
|
+
switch (prefix) {
|
|
49
|
+
case "payload":
|
|
50
|
+
source = ctx.payload;
|
|
51
|
+
break;
|
|
52
|
+
case "headers":
|
|
53
|
+
source = ctx.headers;
|
|
54
|
+
break;
|
|
55
|
+
case "meta":
|
|
56
|
+
source = ctx.meta;
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
source = ctx[prefix];
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (!key) return source;
|
|
63
|
+
return key.split(".").reduce((curr, k) => {
|
|
64
|
+
if (curr === null || curr === void 0) return void 0;
|
|
65
|
+
const bracketMatch = k.match(/^([^[]*)\[(\d+)\]$/);
|
|
66
|
+
if (bracketMatch) {
|
|
67
|
+
const [, prop, indexStr] = bracketMatch;
|
|
68
|
+
let obj = curr;
|
|
69
|
+
if (prop && typeof obj === "object") {
|
|
70
|
+
obj = obj[prop];
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(obj)) {
|
|
73
|
+
return obj[Number(indexStr)];
|
|
74
|
+
}
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
if (typeof curr === "object") {
|
|
78
|
+
return curr[k];
|
|
79
|
+
}
|
|
80
|
+
return void 0;
|
|
81
|
+
}, source);
|
|
82
|
+
}
|
|
83
|
+
function valueToString(val) {
|
|
84
|
+
if (val === void 0 || val === null) return "";
|
|
85
|
+
if (typeof val === "object") return JSON.stringify(val);
|
|
86
|
+
return String(val);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/sandbox.ts
|
|
90
|
+
var vm = __toESM(require("vm"));
|
|
91
|
+
function buildNodeLookup(workspacePayloads) {
|
|
92
|
+
return (nodeName) => {
|
|
93
|
+
if (!workspacePayloads) throw new Error("$() requires workspace context");
|
|
94
|
+
const p = workspacePayloads[nodeName];
|
|
95
|
+
if (!p) throw new Error(`Node "${nodeName}" not found`);
|
|
96
|
+
let cloned;
|
|
97
|
+
try {
|
|
98
|
+
cloned = structuredClone(p);
|
|
99
|
+
} catch {
|
|
100
|
+
cloned = JSON.parse(JSON.stringify(p));
|
|
101
|
+
}
|
|
102
|
+
const m = cloned._meta;
|
|
103
|
+
if (m?.iterable && m.iterateField) return cloned[m.iterateField] ?? [];
|
|
104
|
+
const { _meta, ...clean } = cloned;
|
|
105
|
+
return clean;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function buildSandbox(ctx, opts, logs) {
|
|
109
|
+
const stringify = (v) => {
|
|
110
|
+
if (v === void 0) return "undefined";
|
|
111
|
+
if (v === null) return "null";
|
|
112
|
+
if (typeof v === "object") {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.stringify(v, null, 2);
|
|
115
|
+
} catch {
|
|
116
|
+
return String(v);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return String(v);
|
|
120
|
+
};
|
|
121
|
+
const sandbox = {
|
|
122
|
+
payload: ctx.payload,
|
|
123
|
+
$: buildNodeLookup(opts.workspacePayloads),
|
|
124
|
+
headers: ctx.headers,
|
|
125
|
+
meta: ctx.meta,
|
|
126
|
+
JSON,
|
|
127
|
+
Math,
|
|
128
|
+
Date,
|
|
129
|
+
Array,
|
|
130
|
+
Object,
|
|
131
|
+
String,
|
|
132
|
+
Number,
|
|
133
|
+
Boolean,
|
|
134
|
+
RegExp,
|
|
135
|
+
parseInt,
|
|
136
|
+
parseFloat,
|
|
137
|
+
isNaN,
|
|
138
|
+
isFinite,
|
|
139
|
+
encodeURIComponent,
|
|
140
|
+
decodeURIComponent,
|
|
141
|
+
Map,
|
|
142
|
+
Set
|
|
143
|
+
};
|
|
144
|
+
if (opts.mode === "code" && logs) {
|
|
145
|
+
sandbox.console = {
|
|
146
|
+
log: (...args) => logs.push(args.map(stringify).join(" ")),
|
|
147
|
+
warn: (...args) => logs.push(`[warn] ${args.map(stringify).join(" ")}`),
|
|
148
|
+
error: (...args) => logs.push(`[error] ${args.map(stringify).join(" ")}`)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return vm.createContext(sandbox);
|
|
152
|
+
}
|
|
153
|
+
function evalJsExpression(expr, ctx, opts) {
|
|
154
|
+
const context = buildSandbox(ctx, { ...opts, mode: "text" });
|
|
155
|
+
try {
|
|
156
|
+
const script = new vm.Script(`(${expr})`, { filename: "template-expr.js" });
|
|
157
|
+
return script.runInContext(context, { timeout: opts.timeout ?? 1e3 });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(
|
|
160
|
+
`[template-engine] JS eval failed "${expr.slice(0, 80)}": ${err instanceof Error ? err.message : err}`
|
|
161
|
+
);
|
|
162
|
+
return void 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/expressions.ts
|
|
167
|
+
function resolveBranch(raw, ctx) {
|
|
168
|
+
const t = raw.trim();
|
|
169
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
|
|
170
|
+
return t.slice(1, -1);
|
|
171
|
+
}
|
|
172
|
+
if (t !== "" && !isNaN(Number(t))) {
|
|
173
|
+
return t;
|
|
174
|
+
}
|
|
175
|
+
return valueToString(resolvePath(t, ctx));
|
|
176
|
+
}
|
|
177
|
+
function parseComparand(raw) {
|
|
178
|
+
const t = raw.trim();
|
|
179
|
+
if (t === "null") return null;
|
|
180
|
+
if (t === "undefined") return void 0;
|
|
181
|
+
if (t === "true") return true;
|
|
182
|
+
if (t === "false") return false;
|
|
183
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
|
|
184
|
+
return t.slice(1, -1);
|
|
185
|
+
}
|
|
186
|
+
if (t !== "" && !isNaN(Number(t))) return Number(t);
|
|
187
|
+
return t;
|
|
188
|
+
}
|
|
189
|
+
function applyOperator(left, op, right) {
|
|
190
|
+
switch (op) {
|
|
191
|
+
case "===":
|
|
192
|
+
return left === right;
|
|
193
|
+
case "!==":
|
|
194
|
+
return left !== right;
|
|
195
|
+
// eslint-disable-next-line eqeqeq
|
|
196
|
+
case "==":
|
|
197
|
+
return left == right;
|
|
198
|
+
// eslint-disable-next-line eqeqeq
|
|
199
|
+
case "!=":
|
|
200
|
+
return left != right;
|
|
201
|
+
case ">":
|
|
202
|
+
return Number(left) > Number(right);
|
|
203
|
+
case "<":
|
|
204
|
+
return Number(left) < Number(right);
|
|
205
|
+
case ">=":
|
|
206
|
+
return Number(left) >= Number(right);
|
|
207
|
+
case "<=":
|
|
208
|
+
return Number(left) <= Number(right);
|
|
209
|
+
default:
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
var BRANCH = `(?:"[^"]*"|'[^']*'|\\S+)`;
|
|
214
|
+
var CMP_RE = new RegExp(
|
|
215
|
+
`^(.+?)\\s*(===|!==|==|!=|>=|<=|>|<)\\s*(.+?)\\s*\\?\\s*(${BRANCH})\\s*:\\s*(${BRANCH})$`
|
|
216
|
+
);
|
|
217
|
+
var TRUTHY_RE = new RegExp(
|
|
218
|
+
`^(!?)(.+?)\\s*\\?\\s*(${BRANCH})\\s*:\\s*(${BRANCH})$`
|
|
219
|
+
);
|
|
220
|
+
var NULL_COALESCE_RE = /^(.+?)\s*\?\?\s*("(?:[^"]*)"$|'(?:[^']*)'$|\S+$)/;
|
|
221
|
+
var PIPE_RE = /^(.+?)\s*\|\s*(json|upper|lower|trim)$/;
|
|
222
|
+
function tryNullCoalesce(expr, ctx) {
|
|
223
|
+
const m = expr.match(NULL_COALESCE_RE);
|
|
224
|
+
if (!m) return null;
|
|
225
|
+
const [, path, fallbackRaw] = m;
|
|
226
|
+
const val = resolvePath(path.trim(), ctx);
|
|
227
|
+
if (val === null || val === void 0 || val === "") {
|
|
228
|
+
return resolveBranch(fallbackRaw.trim(), ctx);
|
|
229
|
+
}
|
|
230
|
+
return valueToString(val);
|
|
231
|
+
}
|
|
232
|
+
function tryTernary(expr, ctx) {
|
|
233
|
+
const cmpM = expr.match(CMP_RE);
|
|
234
|
+
if (cmpM) {
|
|
235
|
+
const [, path2, op, comparand, trueRaw2, falseRaw2] = cmpM;
|
|
236
|
+
const left = resolvePath(path2.trim(), ctx);
|
|
237
|
+
const right = parseComparand(comparand.trim());
|
|
238
|
+
return applyOperator(left, op, right) ? resolveBranch(trueRaw2, ctx) : resolveBranch(falseRaw2, ctx);
|
|
239
|
+
}
|
|
240
|
+
const m = expr.match(TRUTHY_RE);
|
|
241
|
+
if (!m) return null;
|
|
242
|
+
const [, negate, path, trueRaw, falseRaw] = m;
|
|
243
|
+
const resolved = resolvePath(path.trim(), ctx);
|
|
244
|
+
let truthy = resolved !== void 0 && resolved !== null && resolved !== "" && resolved !== false && resolved !== 0;
|
|
245
|
+
if (negate === "!") truthy = !truthy;
|
|
246
|
+
return truthy ? resolveBranch(trueRaw, ctx) : resolveBranch(falseRaw, ctx);
|
|
247
|
+
}
|
|
248
|
+
function applyPipe(pipe, val) {
|
|
249
|
+
switch (pipe) {
|
|
250
|
+
case "json":
|
|
251
|
+
return JSON.stringify(val, null, 2);
|
|
252
|
+
case "upper":
|
|
253
|
+
return String(val ?? "").toUpperCase();
|
|
254
|
+
case "lower":
|
|
255
|
+
return String(val ?? "").toLowerCase();
|
|
256
|
+
case "trim":
|
|
257
|
+
return String(val ?? "").trim();
|
|
258
|
+
default:
|
|
259
|
+
return valueToString(val);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function evaluate(expr, ctx, opts) {
|
|
263
|
+
const trimmed = expr.trim();
|
|
264
|
+
if (trimmed.startsWith("!!")) {
|
|
265
|
+
const val = resolvePath(trimmed.slice(2).trim(), ctx);
|
|
266
|
+
return String(!!val);
|
|
267
|
+
}
|
|
268
|
+
if (trimmed.startsWith("!") && !trimmed.includes("?") && !trimmed.includes("=")) {
|
|
269
|
+
const val = resolvePath(trimmed.slice(1).trim(), ctx);
|
|
270
|
+
return String(!val);
|
|
271
|
+
}
|
|
272
|
+
const pipeM = trimmed.match(PIPE_RE);
|
|
273
|
+
if (pipeM) {
|
|
274
|
+
const [, path, pipe] = pipeM;
|
|
275
|
+
const val = resolvePath(path.trim(), ctx);
|
|
276
|
+
return applyPipe(pipe, val);
|
|
277
|
+
}
|
|
278
|
+
const ncResult = tryNullCoalesce(trimmed, ctx);
|
|
279
|
+
if (ncResult !== null) return ncResult;
|
|
280
|
+
const ternaryResult = tryTernary(trimmed, ctx);
|
|
281
|
+
if (ternaryResult !== null) return ternaryResult;
|
|
282
|
+
const resolved = resolvePath(trimmed, ctx);
|
|
283
|
+
if (resolved !== void 0) return valueToString(resolved);
|
|
284
|
+
if (opts?.allowJs) {
|
|
285
|
+
const jsResult = evalJsExpression(trimmed, ctx, opts);
|
|
286
|
+
if (jsResult !== void 0) return valueToString(jsResult);
|
|
287
|
+
}
|
|
288
|
+
return "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/json-mode.ts
|
|
292
|
+
function resolveJsonValue(value, ctx, opts) {
|
|
293
|
+
if (typeof value === "string") {
|
|
294
|
+
const singleExpr = value.match(/^\{\{((?:(?!\}\}).)*)\}\}$/);
|
|
295
|
+
if (singleExpr) {
|
|
296
|
+
const expr = singleExpr[1].trim();
|
|
297
|
+
const ncResult = tryNullCoalesce(expr, ctx);
|
|
298
|
+
if (ncResult !== null) return ncResult;
|
|
299
|
+
const ternaryResult = tryTernary(expr, ctx);
|
|
300
|
+
if (ternaryResult !== null) {
|
|
301
|
+
if (ternaryResult !== "" && !isNaN(Number(ternaryResult))) return Number(ternaryResult);
|
|
302
|
+
return ternaryResult;
|
|
303
|
+
}
|
|
304
|
+
if (expr.startsWith("!!")) {
|
|
305
|
+
const val = resolvePath(expr.slice(2).trim(), ctx);
|
|
306
|
+
return !!val;
|
|
307
|
+
}
|
|
308
|
+
if (expr.startsWith("!") && !expr.includes("?") && !expr.includes("=")) {
|
|
309
|
+
const val = resolvePath(expr.slice(1).trim(), ctx);
|
|
310
|
+
return !val;
|
|
311
|
+
}
|
|
312
|
+
if (/\|\s*(json|upper|lower|trim)$/.test(expr)) {
|
|
313
|
+
return evaluate(expr, ctx, opts);
|
|
314
|
+
}
|
|
315
|
+
const resolved = resolvePath(expr, ctx);
|
|
316
|
+
if (resolved !== void 0) return resolved;
|
|
317
|
+
if (opts?.allowJs) {
|
|
318
|
+
const jsResult = evalJsExpression(expr, ctx, opts);
|
|
319
|
+
if (jsResult !== void 0) return jsResult;
|
|
320
|
+
}
|
|
321
|
+
return "";
|
|
322
|
+
}
|
|
323
|
+
return value.replace(
|
|
324
|
+
/\{\{(.*?)\}\}/g,
|
|
325
|
+
(_, expr) => evaluate(expr, ctx, opts)
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
return value.map((item) => resolveJsonValue(item, ctx, opts));
|
|
330
|
+
}
|
|
331
|
+
if (value !== null && typeof value === "object") {
|
|
332
|
+
const result = {};
|
|
333
|
+
for (const [k, v] of Object.entries(value)) {
|
|
334
|
+
result[k] = resolveJsonValue(v, ctx, opts);
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
function resolveJsonTemplate(template, ctx, opts) {
|
|
341
|
+
const jsonReady = template.replace(/\{\{([\s\S]*?)\}\}/g, (_, inner) => {
|
|
342
|
+
const escaped = inner.replace(/(?<!\\)"/g, '\\"');
|
|
343
|
+
return `{{${escaped}}}`;
|
|
344
|
+
});
|
|
345
|
+
try {
|
|
346
|
+
const parsed = JSON.parse(jsonReady);
|
|
347
|
+
const resolved = resolveJsonValue(parsed, ctx, opts);
|
|
348
|
+
return {
|
|
349
|
+
output: JSON.stringify(resolved),
|
|
350
|
+
rawValue: resolved
|
|
351
|
+
};
|
|
352
|
+
} catch {
|
|
353
|
+
const output = template.replace(/\{\{([\s\S]*?)\}\}/g, (_, expr) => {
|
|
354
|
+
const val = evaluate(expr, ctx, opts);
|
|
355
|
+
return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
356
|
+
});
|
|
357
|
+
return { output };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/html-mode.ts
|
|
362
|
+
function escapeHtml(str) {
|
|
363
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
364
|
+
}
|
|
365
|
+
function resolveHtmlTemplate(template, ctx, opts) {
|
|
366
|
+
const output = template.replace(/\{\{([\s\S]*?)\}\}/g, (_, expr) => {
|
|
367
|
+
const val = evaluate(expr, ctx, opts);
|
|
368
|
+
return escapeHtml(val);
|
|
369
|
+
});
|
|
370
|
+
return { output };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/code-mode.ts
|
|
374
|
+
var vm2 = __toESM(require("vm"));
|
|
375
|
+
function executeCodeTemplate(code, ctx, opts) {
|
|
376
|
+
const logs = [];
|
|
377
|
+
const timeout = opts.timeout ?? 5e3;
|
|
378
|
+
let sandboxPayload = ctx.payload;
|
|
379
|
+
if (opts.batchMode) {
|
|
380
|
+
const meta = ctx.payload?._meta;
|
|
381
|
+
if (meta?.iterable && meta.iterateField) {
|
|
382
|
+
try {
|
|
383
|
+
sandboxPayload = structuredClone(
|
|
384
|
+
ctx.payload[meta.iterateField] ?? []
|
|
385
|
+
);
|
|
386
|
+
} catch {
|
|
387
|
+
sandboxPayload = JSON.parse(
|
|
388
|
+
JSON.stringify(ctx.payload[meta.iterateField] ?? [])
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
try {
|
|
393
|
+
sandboxPayload = structuredClone(ctx.payload);
|
|
394
|
+
} catch {
|
|
395
|
+
sandboxPayload = JSON.parse(JSON.stringify(ctx.payload));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
try {
|
|
400
|
+
sandboxPayload = structuredClone(ctx.payload);
|
|
401
|
+
} catch {
|
|
402
|
+
sandboxPayload = JSON.parse(JSON.stringify(ctx.payload));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const context = buildSandbox(
|
|
406
|
+
{ ...ctx, payload: sandboxPayload },
|
|
407
|
+
{ ...opts, mode: "code" },
|
|
408
|
+
logs
|
|
409
|
+
);
|
|
410
|
+
try {
|
|
411
|
+
const script = new vm2.Script(`(function() {
|
|
412
|
+
${code}
|
|
413
|
+
})()`, {
|
|
414
|
+
filename: "code-node.js"
|
|
415
|
+
});
|
|
416
|
+
const result = script.runInContext(context, { timeout });
|
|
417
|
+
if (result == null) {
|
|
418
|
+
return { output: null, statusCode: 200, logs };
|
|
419
|
+
}
|
|
420
|
+
return { output: result, statusCode: 200, logs };
|
|
421
|
+
} catch (err) {
|
|
422
|
+
let message;
|
|
423
|
+
if (err instanceof Error) {
|
|
424
|
+
message = err.message;
|
|
425
|
+
} else if (typeof err === "string") {
|
|
426
|
+
message = err;
|
|
427
|
+
} else {
|
|
428
|
+
message = String(err);
|
|
429
|
+
}
|
|
430
|
+
return { output: null, statusCode: 500, logs, error: message };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/render.ts
|
|
435
|
+
function resolveTextTemplate(template, ctx, opts) {
|
|
436
|
+
const output = template.replace(
|
|
437
|
+
/\{\{([\s\S]*?)\}\}/g,
|
|
438
|
+
(_, expr) => evaluate(expr, ctx, opts)
|
|
439
|
+
);
|
|
440
|
+
return { output };
|
|
441
|
+
}
|
|
442
|
+
function detectMode(template) {
|
|
443
|
+
return /^\s*[\[{]/.test(template) ? "json" : "text";
|
|
444
|
+
}
|
|
445
|
+
function renderTemplate(template, ctx, opts) {
|
|
446
|
+
const mode = opts?.mode ?? "auto";
|
|
447
|
+
switch (mode) {
|
|
448
|
+
case "code":
|
|
449
|
+
return executeCodeTemplate(template, ctx, opts ?? {});
|
|
450
|
+
case "json":
|
|
451
|
+
return resolveJsonTemplate(template, ctx, opts);
|
|
452
|
+
case "html":
|
|
453
|
+
return resolveHtmlTemplate(template, ctx, opts);
|
|
454
|
+
case "text":
|
|
455
|
+
return resolveTextTemplate(template, ctx, opts);
|
|
456
|
+
case "auto": {
|
|
457
|
+
const detected = detectMode(template);
|
|
458
|
+
if (detected === "json") {
|
|
459
|
+
return resolveJsonTemplate(template, ctx, opts);
|
|
460
|
+
}
|
|
461
|
+
return resolveTextTemplate(template, ctx, opts);
|
|
462
|
+
}
|
|
463
|
+
default:
|
|
464
|
+
return resolveTextTemplate(template, ctx, opts);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
468
|
+
0 && (module.exports = {
|
|
469
|
+
applyPipe,
|
|
470
|
+
evaluate,
|
|
471
|
+
renderTemplate,
|
|
472
|
+
resolvePath,
|
|
473
|
+
tryNullCoalesce,
|
|
474
|
+
tryTernary,
|
|
475
|
+
valueToString
|
|
476
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// src/resolve-path.ts
|
|
2
|
+
function resolvePath(path, ctx) {
|
|
3
|
+
const [prefix, ...rest] = path.split(".");
|
|
4
|
+
const key = rest.join(".");
|
|
5
|
+
let source;
|
|
6
|
+
switch (prefix) {
|
|
7
|
+
case "payload":
|
|
8
|
+
source = ctx.payload;
|
|
9
|
+
break;
|
|
10
|
+
case "headers":
|
|
11
|
+
source = ctx.headers;
|
|
12
|
+
break;
|
|
13
|
+
case "meta":
|
|
14
|
+
source = ctx.meta;
|
|
15
|
+
break;
|
|
16
|
+
default:
|
|
17
|
+
source = ctx[prefix];
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
if (!key) return source;
|
|
21
|
+
return key.split(".").reduce((curr, k) => {
|
|
22
|
+
if (curr === null || curr === void 0) return void 0;
|
|
23
|
+
const bracketMatch = k.match(/^([^[]*)\[(\d+)\]$/);
|
|
24
|
+
if (bracketMatch) {
|
|
25
|
+
const [, prop, indexStr] = bracketMatch;
|
|
26
|
+
let obj = curr;
|
|
27
|
+
if (prop && typeof obj === "object") {
|
|
28
|
+
obj = obj[prop];
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(obj)) {
|
|
31
|
+
return obj[Number(indexStr)];
|
|
32
|
+
}
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
if (typeof curr === "object") {
|
|
36
|
+
return curr[k];
|
|
37
|
+
}
|
|
38
|
+
return void 0;
|
|
39
|
+
}, source);
|
|
40
|
+
}
|
|
41
|
+
function valueToString(val) {
|
|
42
|
+
if (val === void 0 || val === null) return "";
|
|
43
|
+
if (typeof val === "object") return JSON.stringify(val);
|
|
44
|
+
return String(val);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/sandbox.ts
|
|
48
|
+
import * as vm from "vm";
|
|
49
|
+
function buildNodeLookup(workspacePayloads) {
|
|
50
|
+
return (nodeName) => {
|
|
51
|
+
if (!workspacePayloads) throw new Error("$() requires workspace context");
|
|
52
|
+
const p = workspacePayloads[nodeName];
|
|
53
|
+
if (!p) throw new Error(`Node "${nodeName}" not found`);
|
|
54
|
+
let cloned;
|
|
55
|
+
try {
|
|
56
|
+
cloned = structuredClone(p);
|
|
57
|
+
} catch {
|
|
58
|
+
cloned = JSON.parse(JSON.stringify(p));
|
|
59
|
+
}
|
|
60
|
+
const m = cloned._meta;
|
|
61
|
+
if (m?.iterable && m.iterateField) return cloned[m.iterateField] ?? [];
|
|
62
|
+
const { _meta, ...clean } = cloned;
|
|
63
|
+
return clean;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function buildSandbox(ctx, opts, logs) {
|
|
67
|
+
const stringify = (v) => {
|
|
68
|
+
if (v === void 0) return "undefined";
|
|
69
|
+
if (v === null) return "null";
|
|
70
|
+
if (typeof v === "object") {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(v, null, 2);
|
|
73
|
+
} catch {
|
|
74
|
+
return String(v);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return String(v);
|
|
78
|
+
};
|
|
79
|
+
const sandbox = {
|
|
80
|
+
payload: ctx.payload,
|
|
81
|
+
$: buildNodeLookup(opts.workspacePayloads),
|
|
82
|
+
headers: ctx.headers,
|
|
83
|
+
meta: ctx.meta,
|
|
84
|
+
JSON,
|
|
85
|
+
Math,
|
|
86
|
+
Date,
|
|
87
|
+
Array,
|
|
88
|
+
Object,
|
|
89
|
+
String,
|
|
90
|
+
Number,
|
|
91
|
+
Boolean,
|
|
92
|
+
RegExp,
|
|
93
|
+
parseInt,
|
|
94
|
+
parseFloat,
|
|
95
|
+
isNaN,
|
|
96
|
+
isFinite,
|
|
97
|
+
encodeURIComponent,
|
|
98
|
+
decodeURIComponent,
|
|
99
|
+
Map,
|
|
100
|
+
Set
|
|
101
|
+
};
|
|
102
|
+
if (opts.mode === "code" && logs) {
|
|
103
|
+
sandbox.console = {
|
|
104
|
+
log: (...args) => logs.push(args.map(stringify).join(" ")),
|
|
105
|
+
warn: (...args) => logs.push(`[warn] ${args.map(stringify).join(" ")}`),
|
|
106
|
+
error: (...args) => logs.push(`[error] ${args.map(stringify).join(" ")}`)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return vm.createContext(sandbox);
|
|
110
|
+
}
|
|
111
|
+
function evalJsExpression(expr, ctx, opts) {
|
|
112
|
+
const context = buildSandbox(ctx, { ...opts, mode: "text" });
|
|
113
|
+
try {
|
|
114
|
+
const script = new vm.Script(`(${expr})`, { filename: "template-expr.js" });
|
|
115
|
+
return script.runInContext(context, { timeout: opts.timeout ?? 1e3 });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(
|
|
118
|
+
`[template-engine] JS eval failed "${expr.slice(0, 80)}": ${err instanceof Error ? err.message : err}`
|
|
119
|
+
);
|
|
120
|
+
return void 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/expressions.ts
|
|
125
|
+
function resolveBranch(raw, ctx) {
|
|
126
|
+
const t = raw.trim();
|
|
127
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
|
|
128
|
+
return t.slice(1, -1);
|
|
129
|
+
}
|
|
130
|
+
if (t !== "" && !isNaN(Number(t))) {
|
|
131
|
+
return t;
|
|
132
|
+
}
|
|
133
|
+
return valueToString(resolvePath(t, ctx));
|
|
134
|
+
}
|
|
135
|
+
function parseComparand(raw) {
|
|
136
|
+
const t = raw.trim();
|
|
137
|
+
if (t === "null") return null;
|
|
138
|
+
if (t === "undefined") return void 0;
|
|
139
|
+
if (t === "true") return true;
|
|
140
|
+
if (t === "false") return false;
|
|
141
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
|
|
142
|
+
return t.slice(1, -1);
|
|
143
|
+
}
|
|
144
|
+
if (t !== "" && !isNaN(Number(t))) return Number(t);
|
|
145
|
+
return t;
|
|
146
|
+
}
|
|
147
|
+
function applyOperator(left, op, right) {
|
|
148
|
+
switch (op) {
|
|
149
|
+
case "===":
|
|
150
|
+
return left === right;
|
|
151
|
+
case "!==":
|
|
152
|
+
return left !== right;
|
|
153
|
+
// eslint-disable-next-line eqeqeq
|
|
154
|
+
case "==":
|
|
155
|
+
return left == right;
|
|
156
|
+
// eslint-disable-next-line eqeqeq
|
|
157
|
+
case "!=":
|
|
158
|
+
return left != right;
|
|
159
|
+
case ">":
|
|
160
|
+
return Number(left) > Number(right);
|
|
161
|
+
case "<":
|
|
162
|
+
return Number(left) < Number(right);
|
|
163
|
+
case ">=":
|
|
164
|
+
return Number(left) >= Number(right);
|
|
165
|
+
case "<=":
|
|
166
|
+
return Number(left) <= Number(right);
|
|
167
|
+
default:
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
var BRANCH = `(?:"[^"]*"|'[^']*'|\\S+)`;
|
|
172
|
+
var CMP_RE = new RegExp(
|
|
173
|
+
`^(.+?)\\s*(===|!==|==|!=|>=|<=|>|<)\\s*(.+?)\\s*\\?\\s*(${BRANCH})\\s*:\\s*(${BRANCH})$`
|
|
174
|
+
);
|
|
175
|
+
var TRUTHY_RE = new RegExp(
|
|
176
|
+
`^(!?)(.+?)\\s*\\?\\s*(${BRANCH})\\s*:\\s*(${BRANCH})$`
|
|
177
|
+
);
|
|
178
|
+
var NULL_COALESCE_RE = /^(.+?)\s*\?\?\s*("(?:[^"]*)"$|'(?:[^']*)'$|\S+$)/;
|
|
179
|
+
var PIPE_RE = /^(.+?)\s*\|\s*(json|upper|lower|trim)$/;
|
|
180
|
+
function tryNullCoalesce(expr, ctx) {
|
|
181
|
+
const m = expr.match(NULL_COALESCE_RE);
|
|
182
|
+
if (!m) return null;
|
|
183
|
+
const [, path, fallbackRaw] = m;
|
|
184
|
+
const val = resolvePath(path.trim(), ctx);
|
|
185
|
+
if (val === null || val === void 0 || val === "") {
|
|
186
|
+
return resolveBranch(fallbackRaw.trim(), ctx);
|
|
187
|
+
}
|
|
188
|
+
return valueToString(val);
|
|
189
|
+
}
|
|
190
|
+
function tryTernary(expr, ctx) {
|
|
191
|
+
const cmpM = expr.match(CMP_RE);
|
|
192
|
+
if (cmpM) {
|
|
193
|
+
const [, path2, op, comparand, trueRaw2, falseRaw2] = cmpM;
|
|
194
|
+
const left = resolvePath(path2.trim(), ctx);
|
|
195
|
+
const right = parseComparand(comparand.trim());
|
|
196
|
+
return applyOperator(left, op, right) ? resolveBranch(trueRaw2, ctx) : resolveBranch(falseRaw2, ctx);
|
|
197
|
+
}
|
|
198
|
+
const m = expr.match(TRUTHY_RE);
|
|
199
|
+
if (!m) return null;
|
|
200
|
+
const [, negate, path, trueRaw, falseRaw] = m;
|
|
201
|
+
const resolved = resolvePath(path.trim(), ctx);
|
|
202
|
+
let truthy = resolved !== void 0 && resolved !== null && resolved !== "" && resolved !== false && resolved !== 0;
|
|
203
|
+
if (negate === "!") truthy = !truthy;
|
|
204
|
+
return truthy ? resolveBranch(trueRaw, ctx) : resolveBranch(falseRaw, ctx);
|
|
205
|
+
}
|
|
206
|
+
function applyPipe(pipe, val) {
|
|
207
|
+
switch (pipe) {
|
|
208
|
+
case "json":
|
|
209
|
+
return JSON.stringify(val, null, 2);
|
|
210
|
+
case "upper":
|
|
211
|
+
return String(val ?? "").toUpperCase();
|
|
212
|
+
case "lower":
|
|
213
|
+
return String(val ?? "").toLowerCase();
|
|
214
|
+
case "trim":
|
|
215
|
+
return String(val ?? "").trim();
|
|
216
|
+
default:
|
|
217
|
+
return valueToString(val);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function evaluate(expr, ctx, opts) {
|
|
221
|
+
const trimmed = expr.trim();
|
|
222
|
+
if (trimmed.startsWith("!!")) {
|
|
223
|
+
const val = resolvePath(trimmed.slice(2).trim(), ctx);
|
|
224
|
+
return String(!!val);
|
|
225
|
+
}
|
|
226
|
+
if (trimmed.startsWith("!") && !trimmed.includes("?") && !trimmed.includes("=")) {
|
|
227
|
+
const val = resolvePath(trimmed.slice(1).trim(), ctx);
|
|
228
|
+
return String(!val);
|
|
229
|
+
}
|
|
230
|
+
const pipeM = trimmed.match(PIPE_RE);
|
|
231
|
+
if (pipeM) {
|
|
232
|
+
const [, path, pipe] = pipeM;
|
|
233
|
+
const val = resolvePath(path.trim(), ctx);
|
|
234
|
+
return applyPipe(pipe, val);
|
|
235
|
+
}
|
|
236
|
+
const ncResult = tryNullCoalesce(trimmed, ctx);
|
|
237
|
+
if (ncResult !== null) return ncResult;
|
|
238
|
+
const ternaryResult = tryTernary(trimmed, ctx);
|
|
239
|
+
if (ternaryResult !== null) return ternaryResult;
|
|
240
|
+
const resolved = resolvePath(trimmed, ctx);
|
|
241
|
+
if (resolved !== void 0) return valueToString(resolved);
|
|
242
|
+
if (opts?.allowJs) {
|
|
243
|
+
const jsResult = evalJsExpression(trimmed, ctx, opts);
|
|
244
|
+
if (jsResult !== void 0) return valueToString(jsResult);
|
|
245
|
+
}
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/json-mode.ts
|
|
250
|
+
function resolveJsonValue(value, ctx, opts) {
|
|
251
|
+
if (typeof value === "string") {
|
|
252
|
+
const singleExpr = value.match(/^\{\{((?:(?!\}\}).)*)\}\}$/);
|
|
253
|
+
if (singleExpr) {
|
|
254
|
+
const expr = singleExpr[1].trim();
|
|
255
|
+
const ncResult = tryNullCoalesce(expr, ctx);
|
|
256
|
+
if (ncResult !== null) return ncResult;
|
|
257
|
+
const ternaryResult = tryTernary(expr, ctx);
|
|
258
|
+
if (ternaryResult !== null) {
|
|
259
|
+
if (ternaryResult !== "" && !isNaN(Number(ternaryResult))) return Number(ternaryResult);
|
|
260
|
+
return ternaryResult;
|
|
261
|
+
}
|
|
262
|
+
if (expr.startsWith("!!")) {
|
|
263
|
+
const val = resolvePath(expr.slice(2).trim(), ctx);
|
|
264
|
+
return !!val;
|
|
265
|
+
}
|
|
266
|
+
if (expr.startsWith("!") && !expr.includes("?") && !expr.includes("=")) {
|
|
267
|
+
const val = resolvePath(expr.slice(1).trim(), ctx);
|
|
268
|
+
return !val;
|
|
269
|
+
}
|
|
270
|
+
if (/\|\s*(json|upper|lower|trim)$/.test(expr)) {
|
|
271
|
+
return evaluate(expr, ctx, opts);
|
|
272
|
+
}
|
|
273
|
+
const resolved = resolvePath(expr, ctx);
|
|
274
|
+
if (resolved !== void 0) return resolved;
|
|
275
|
+
if (opts?.allowJs) {
|
|
276
|
+
const jsResult = evalJsExpression(expr, ctx, opts);
|
|
277
|
+
if (jsResult !== void 0) return jsResult;
|
|
278
|
+
}
|
|
279
|
+
return "";
|
|
280
|
+
}
|
|
281
|
+
return value.replace(
|
|
282
|
+
/\{\{(.*?)\}\}/g,
|
|
283
|
+
(_, expr) => evaluate(expr, ctx, opts)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (Array.isArray(value)) {
|
|
287
|
+
return value.map((item) => resolveJsonValue(item, ctx, opts));
|
|
288
|
+
}
|
|
289
|
+
if (value !== null && typeof value === "object") {
|
|
290
|
+
const result = {};
|
|
291
|
+
for (const [k, v] of Object.entries(value)) {
|
|
292
|
+
result[k] = resolveJsonValue(v, ctx, opts);
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
function resolveJsonTemplate(template, ctx, opts) {
|
|
299
|
+
const jsonReady = template.replace(/\{\{([\s\S]*?)\}\}/g, (_, inner) => {
|
|
300
|
+
const escaped = inner.replace(/(?<!\\)"/g, '\\"');
|
|
301
|
+
return `{{${escaped}}}`;
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(jsonReady);
|
|
305
|
+
const resolved = resolveJsonValue(parsed, ctx, opts);
|
|
306
|
+
return {
|
|
307
|
+
output: JSON.stringify(resolved),
|
|
308
|
+
rawValue: resolved
|
|
309
|
+
};
|
|
310
|
+
} catch {
|
|
311
|
+
const output = template.replace(/\{\{([\s\S]*?)\}\}/g, (_, expr) => {
|
|
312
|
+
const val = evaluate(expr, ctx, opts);
|
|
313
|
+
return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
314
|
+
});
|
|
315
|
+
return { output };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/html-mode.ts
|
|
320
|
+
function escapeHtml(str) {
|
|
321
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
322
|
+
}
|
|
323
|
+
function resolveHtmlTemplate(template, ctx, opts) {
|
|
324
|
+
const output = template.replace(/\{\{([\s\S]*?)\}\}/g, (_, expr) => {
|
|
325
|
+
const val = evaluate(expr, ctx, opts);
|
|
326
|
+
return escapeHtml(val);
|
|
327
|
+
});
|
|
328
|
+
return { output };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/code-mode.ts
|
|
332
|
+
import * as vm2 from "vm";
|
|
333
|
+
function executeCodeTemplate(code, ctx, opts) {
|
|
334
|
+
const logs = [];
|
|
335
|
+
const timeout = opts.timeout ?? 5e3;
|
|
336
|
+
let sandboxPayload = ctx.payload;
|
|
337
|
+
if (opts.batchMode) {
|
|
338
|
+
const meta = ctx.payload?._meta;
|
|
339
|
+
if (meta?.iterable && meta.iterateField) {
|
|
340
|
+
try {
|
|
341
|
+
sandboxPayload = structuredClone(
|
|
342
|
+
ctx.payload[meta.iterateField] ?? []
|
|
343
|
+
);
|
|
344
|
+
} catch {
|
|
345
|
+
sandboxPayload = JSON.parse(
|
|
346
|
+
JSON.stringify(ctx.payload[meta.iterateField] ?? [])
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
try {
|
|
351
|
+
sandboxPayload = structuredClone(ctx.payload);
|
|
352
|
+
} catch {
|
|
353
|
+
sandboxPayload = JSON.parse(JSON.stringify(ctx.payload));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
try {
|
|
358
|
+
sandboxPayload = structuredClone(ctx.payload);
|
|
359
|
+
} catch {
|
|
360
|
+
sandboxPayload = JSON.parse(JSON.stringify(ctx.payload));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const context = buildSandbox(
|
|
364
|
+
{ ...ctx, payload: sandboxPayload },
|
|
365
|
+
{ ...opts, mode: "code" },
|
|
366
|
+
logs
|
|
367
|
+
);
|
|
368
|
+
try {
|
|
369
|
+
const script = new vm2.Script(`(function() {
|
|
370
|
+
${code}
|
|
371
|
+
})()`, {
|
|
372
|
+
filename: "code-node.js"
|
|
373
|
+
});
|
|
374
|
+
const result = script.runInContext(context, { timeout });
|
|
375
|
+
if (result == null) {
|
|
376
|
+
return { output: null, statusCode: 200, logs };
|
|
377
|
+
}
|
|
378
|
+
return { output: result, statusCode: 200, logs };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
let message;
|
|
381
|
+
if (err instanceof Error) {
|
|
382
|
+
message = err.message;
|
|
383
|
+
} else if (typeof err === "string") {
|
|
384
|
+
message = err;
|
|
385
|
+
} else {
|
|
386
|
+
message = String(err);
|
|
387
|
+
}
|
|
388
|
+
return { output: null, statusCode: 500, logs, error: message };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/render.ts
|
|
393
|
+
function resolveTextTemplate(template, ctx, opts) {
|
|
394
|
+
const output = template.replace(
|
|
395
|
+
/\{\{([\s\S]*?)\}\}/g,
|
|
396
|
+
(_, expr) => evaluate(expr, ctx, opts)
|
|
397
|
+
);
|
|
398
|
+
return { output };
|
|
399
|
+
}
|
|
400
|
+
function detectMode(template) {
|
|
401
|
+
return /^\s*[\[{]/.test(template) ? "json" : "text";
|
|
402
|
+
}
|
|
403
|
+
function renderTemplate(template, ctx, opts) {
|
|
404
|
+
const mode = opts?.mode ?? "auto";
|
|
405
|
+
switch (mode) {
|
|
406
|
+
case "code":
|
|
407
|
+
return executeCodeTemplate(template, ctx, opts ?? {});
|
|
408
|
+
case "json":
|
|
409
|
+
return resolveJsonTemplate(template, ctx, opts);
|
|
410
|
+
case "html":
|
|
411
|
+
return resolveHtmlTemplate(template, ctx, opts);
|
|
412
|
+
case "text":
|
|
413
|
+
return resolveTextTemplate(template, ctx, opts);
|
|
414
|
+
case "auto": {
|
|
415
|
+
const detected = detectMode(template);
|
|
416
|
+
if (detected === "json") {
|
|
417
|
+
return resolveJsonTemplate(template, ctx, opts);
|
|
418
|
+
}
|
|
419
|
+
return resolveTextTemplate(template, ctx, opts);
|
|
420
|
+
}
|
|
421
|
+
default:
|
|
422
|
+
return resolveTextTemplate(template, ctx, opts);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
export {
|
|
426
|
+
applyPipe,
|
|
427
|
+
evaluate,
|
|
428
|
+
renderTemplate,
|
|
429
|
+
resolvePath,
|
|
430
|
+
tryNullCoalesce,
|
|
431
|
+
tryTernary,
|
|
432
|
+
valueToString
|
|
433
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hostwebhook/template-engine",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Template interpolation and sandboxed JS execution engine for HostWebhook",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"hostwebhook",
|
|
25
|
+
"template",
|
|
26
|
+
"interpolation",
|
|
27
|
+
"sandbox"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"tsup": "^8.0.0",
|
|
36
|
+
"typescript": "^5.3.0",
|
|
37
|
+
"vitest": "^3.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|