@accordproject/template-engine 2.8.0 → 3.0.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.
package/README.md CHANGED
@@ -20,6 +20,31 @@ At a high-level the template engine converts a TemplateMark DOM to an AgreementM
20
20
 
21
21
  ![Template Interpreter](./assets/template-interpreter.png)
22
22
 
23
+ ## Execution Pipeline Overview
24
+
25
+ Internally, the Template Engine evaluates templates through a structured execution pipeline:
26
+
27
+ 1. **Type Validation**
28
+ The TemplateMark JSON document and the incoming agreement data are validated against their respective Concerto models to ensure structural and type correctness.
29
+
30
+ 2. **TypeScript Compilation**
31
+ Embedded TypeScript expressions (such as conditionals, clauses, and formulae) are compiled into JavaScript using the `TemplateMarkToJavaScriptCompiler`.
32
+
33
+ 3. **User Code Evaluation**
34
+ During agreement generation, compiled JavaScript expressions are evaluated using the `JavaScriptEvaluator`.
35
+ The evaluator supports two execution strategies:
36
+ - `evalDangerously()` — Executes JavaScript directly within the current process.
37
+ This should only be used with trusted code or in a sandboxed environment (e.g., browser).
38
+ - `evalChildProcess()` — Executes JavaScript in an isolated Node.js child process for improved safety and isolation and is recommended for untrusted template content on the server.
39
+
40
+ 4. **AgreementMark Generation**
41
+ The TemplateMark document is traversed, evaluated values are inserted into the document structure, and an AgreementMark JSON document is produced.
42
+
43
+ 5. **Output Validation**
44
+ The generated AgreementMark document is validated before being returned.
45
+
46
+ This layered architecture ensures type-safety, deterministic execution of template logic, and isolation during runtime evaluation.
47
+
23
48
  ## Hello World Template
24
49
 
25
50
  Let's create the simplest template imaginable, the infamous "hello world"!
@@ -102,7 +127,7 @@ This AgreementMark JSON document can then be passed to the `@accordproject/markd
102
127
 
103
128
  The Hello World example just scratches the surface of what can be accomplished! TemplateMark can define optional sections, conditional sections, TypeScript formulae/calculations and even reference external data.
104
129
 
105
- Refer to the [full](https://github.com/accordproject/template-engine/tree/main/test/templates/full) example for details.
130
+ Refer to the [full](https://github.com/accordproject/template-engine/tree/main/test/templates/good/full) example for details.
106
131
 
107
132
  > More detailed syntax documentation is to come!
108
133
  Read the existing documentation at: https://docs.accordproject.org/docs/markup-templatemark.html
package/dist/index.d.ts CHANGED
@@ -152,15 +152,15 @@ declare class TemplateArchiveProcessor {
152
152
  * @param {object} request - the request to send to the template logic
153
153
  * @param {object} state - the current state of the template
154
154
  * @param {[string]} currentTime - the current time, defaults to now
155
- * @param {[number]} utcOffset - the UTC offer, defaults to zero
155
+ * @param {[number]} utcOffset - the UTC offset, defaults to zero
156
156
  * @returns {Promise} the response and any events
157
157
  */
158
158
  trigger(data: any, request: any, state?: any, currentTime?: string, utcOffset?: number): Promise<TriggerResponse>;
159
159
  /**
160
160
  * Init the logic of a template
161
161
  * @param {[string]} currentTime - the current time, defaults to now
162
- * @param {[number]} utcOffset - the UTC offer, defaults to zero
163
- * @returns {Promise} the response and any events
162
+ * @param {[number]} utcOffset - the UTC offset, defaults to zero
163
+ * @returns {Promise<InitResponse>} the new state
164
164
  */
165
165
  init(data: any, currentTime?: string, utcOffset?: number): Promise<InitResponse>;
166
166
  }
package/dist/index.js CHANGED
@@ -122,6 +122,16 @@ function integerDrafter(value, format) {
122
122
  }
123
123
  }
124
124
 
125
+ function isDuration(value) {
126
+ return value != null && typeof value === "object" && "amount" in value && "unit" in value && typeof value.amount === "number" && typeof value.unit === "string";
127
+ }
128
+ function durationDrafter(value) {
129
+ if (!isDuration(value)) {
130
+ return "0 unknown";
131
+ }
132
+ return `${value.amount} ${value.unit}`;
133
+ }
134
+
125
135
  function longDrafter(value, format) {
126
136
  if (format) {
127
137
  return draftIntegerFormat(value, format);
@@ -210,6 +220,10 @@ function getDrafter(typeName) {
210
220
  return longDrafter;
211
221
  case "org.accordproject.money@0.3.0.MonetaryAmount":
212
222
  return monetaryAmountDrafter;
223
+ case "org.accordproject.time@0.3.0.Duration":
224
+ return durationDrafter;
225
+ case "org.accordproject.time@0.3.0.Period":
226
+ return durationDrafter;
213
227
  case "String":
214
228
  return stringDrafter;
215
229
  default:
@@ -700,7 +714,7 @@ function getJsonPath(rootData, currentNode, paths) {
700
714
  }
701
715
  }
702
716
  }
703
- if (currentNode.name !== "this") {
717
+ if (currentNode.name !== "this" && currentNode.name !== "top") {
704
718
  withPath.push(`['${currentNode.name}']`);
705
719
  }
706
720
  return withPath.length > 0 ? `$${withPath.join("")}` : "$";
@@ -890,8 +904,17 @@ async function generateAgreement(modelManager, clauseLibrary, templateMark, data
890
904
  }
891
905
  } else if (CONDITIONAL_DEFINITION_RE.test(nodeClass)) {
892
906
  if (context.condition) {
893
- const result = userCodeResults[this.path.join("/")];
894
- context.isTrue = !!result;
907
+ const key = this.path.join("/");
908
+ const resultStr = userCodeResults[key];
909
+ if (resultStr === void 0 || resultStr === null) {
910
+ context.isTrue = false;
911
+ } else {
912
+ try {
913
+ context.isTrue = !!JSON.parse(resultStr);
914
+ } catch (err) {
915
+ throw new Error(`Invalid JSON boolean result for condition '${key}': ${String(err)}`);
916
+ }
917
+ }
895
918
  } else {
896
919
  const path = getJsonPath(templateMark, context, this.path);
897
920
  const variableValues = jp.query(data, path, 1);
@@ -912,16 +935,27 @@ async function generateAgreement(modelManager, clauseLibrary, templateMark, data
912
935
  } else if (CLAUSE_DEFINITION_RE.test(nodeClass)) {
913
936
  const path = getJsonPath(templateMark, context, this.path);
914
937
  const variableValues = jp.query(data, path, 1);
915
- if (context.name !== "top" && (variableValues.length === 0 || variableValues[0] === void 0 || variableValues[0] === null)) {
916
- delete context.nodes;
917
- stopHere = true;
918
- } else if (context.condition) {
938
+ if (context.condition) {
919
939
  checkCode(context.condition);
920
- const result = !!userCodeResults[this.path.join("/")];
940
+ const key = this.path.join("/");
941
+ const resultStr = userCodeResults[key];
942
+ let result = false;
943
+ if (resultStr === void 0 || resultStr === null) {
944
+ result = false;
945
+ } else {
946
+ try {
947
+ result = !!JSON.parse(resultStr);
948
+ } catch (err) {
949
+ throw new Error(`Invalid JSON boolean result for condition '${key}': ${String(err)}`);
950
+ }
951
+ }
921
952
  if (!result) {
922
953
  delete context.nodes;
923
954
  stopHere = true;
924
955
  }
956
+ } else if (context.name !== "top" && (variableValues.length === 0 || variableValues[0] === void 0 || variableValues[0] === null)) {
957
+ delete context.nodes;
958
+ stopHere = true;
925
959
  }
926
960
  delete context.condition;
927
961
  delete context.functionName;
@@ -970,18 +1004,53 @@ class TemplateMarkInterpreter {
970
1004
  * @throws {Error} if the templateMark document is invalid
971
1005
  */
972
1006
  checkTypes(templateMark) {
973
- const modelManager = new concertoCore.ModelManager({ strict: true });
1007
+ const modelManager = new concertoCore.ModelManager();
974
1008
  modelManager.addCTOModel(markdownCommon.ConcertoMetaModel.MODEL, "concertometamodel.cto");
975
1009
  modelManager.addCTOModel(markdownCommon.CommonMarkModel.MODEL, "commonmark.cto");
976
1010
  modelManager.addCTOModel(markdownCommon.TemplateMarkModel.MODEL, "templatemark.cto");
977
1011
  const factory = new concertoCore.Factory(modelManager);
978
- const serializer = new concertoCore.Serializer(factory, modelManager);
1012
+ const serializer = new concertoCore.Serializer(factory, modelManager, {});
979
1013
  try {
980
- serializer.fromJSON(templateMark);
981
- return templateMark;
1014
+ serializer.fromJSON(templateMark, {});
982
1015
  } catch (err) {
983
1016
  throw new Error(`Generated invalid agreement: ${err}: ${JSON.stringify(templateMark, null, 2)}`);
984
1017
  }
1018
+ const errors = [];
1019
+ const templateClass = this.templateClass;
1020
+ const guardBlockPaths = /* @__PURE__ */ new Map();
1021
+ traverse(templateMark).forEach(function(node) {
1022
+ if (!node || typeof node !== "object" || !node.$class) return;
1023
+ const currentPath = this.path.join("/");
1024
+ if (OPTIONAL_DEFINITION_RE.test(node.$class) || CONDITIONAL_DEFINITION_RE.test(node.$class) || WITH_DEFINITION_RE.test(node.$class)) {
1025
+ guardBlockPaths.set(node.name, currentPath);
1026
+ }
1027
+ if (VARIABLE_DEFINITION_RE.test(node.$class) || ENUM_VARIABLE_DEFINITION_RE.test(node.$class) || FORMATTED_VARIABLE_DEFINITION_RE.test(node.$class)) {
1028
+ const propName = node.name;
1029
+ if (propName && propName !== "this") {
1030
+ try {
1031
+ const property = templateClass.getProperty(propName);
1032
+ if (property && property.isOptional()) {
1033
+ const guardPath = guardBlockPaths.get(propName);
1034
+ const isGuarded = guardPath !== void 0 && (currentPath === guardPath || currentPath.startsWith(guardPath + "/"));
1035
+ if (!isGuarded) {
1036
+ errors.push({
1037
+ propertyName: propName,
1038
+ message: `Optional property '${propName}' is used without a guard. Wrap it in {{#optional ${propName}}}...{{/optional}} or {{#if ${propName}}}...{{/if}}.`
1039
+ });
1040
+ }
1041
+ }
1042
+ } catch {
1043
+ }
1044
+ }
1045
+ }
1046
+ });
1047
+ if (errors.length > 0) {
1048
+ const errorMessage = `Optional properties used without guards: ${errors.map((e) => e.propertyName).join(", ")}`;
1049
+ const error = new Error(errorMessage);
1050
+ error.errors = errors;
1051
+ throw error;
1052
+ }
1053
+ return templateMark;
985
1054
  }
986
1055
  /**
987
1056
  * Compiles the code nodes containing TS to code nodes containing JS.
@@ -1014,22 +1083,22 @@ class TemplateMarkInterpreter {
1014
1083
  return compiler.compile(templateMark);
1015
1084
  }
1016
1085
  validateCiceroMark(ciceroMark) {
1017
- const modelManager = new concertoCore.ModelManager({ strict: true });
1086
+ const modelManager = new concertoCore.ModelManager();
1018
1087
  modelManager.addCTOModel(markdownCommon.ConcertoMetaModel.MODEL, "concertometamodel.cto");
1019
1088
  modelManager.addCTOModel(markdownCommon.CommonMarkModel.MODEL, "commonmark.cto");
1020
1089
  modelManager.addCTOModel(markdownCommon.CiceroMarkModel.MODEL, "ciceromark.cto");
1021
1090
  const factory = new concertoCore.Factory(modelManager);
1022
- const serializer = new concertoCore.Serializer(factory, modelManager);
1091
+ const serializer = new concertoCore.Serializer(factory, modelManager, {});
1023
1092
  try {
1024
- return serializer.fromJSON(ciceroMark);
1093
+ return serializer.fromJSON(ciceroMark, {});
1025
1094
  } catch (err) {
1026
1095
  throw new Error(`Generated invalid agreement: ${err}: ${JSON.stringify(ciceroMark, null, 2)}`);
1027
1096
  }
1028
1097
  }
1029
1098
  async generate(templateMark, data, options) {
1030
1099
  const factory = new concertoCore.Factory(this.modelManager);
1031
- const serializer = new concertoCore.Serializer(factory, this.modelManager);
1032
- const templateData = serializer.fromJSON(data);
1100
+ const serializer = new concertoCore.Serializer(factory, this.modelManager, {});
1101
+ const templateData = serializer.fromJSON(data, {});
1033
1102
  if (templateData.getFullyQualifiedType() !== this.templateClass.getFullyQualifiedName()) {
1034
1103
  throw new Error(`Template data must be of type '${this.templateClass.getFullyQualifiedName()}'.`);
1035
1104
  }
@@ -1078,7 +1147,7 @@ class TemplateArchiveProcessor {
1078
1147
  * @param {object} request - the request to send to the template logic
1079
1148
  * @param {object} state - the current state of the template
1080
1149
  * @param {[string]} currentTime - the current time, defaults to now
1081
- * @param {[number]} utcOffset - the UTC offer, defaults to zero
1150
+ * @param {[number]} utcOffset - the UTC offset, defaults to zero
1082
1151
  * @returns {Promise} the response and any events
1083
1152
  */
1084
1153
  async trigger(data, request, state, currentTime, utcOffset) {
@@ -1098,6 +1167,8 @@ class TemplateArchiveProcessor {
1098
1167
  const result = compiler.compile(code);
1099
1168
  compiledCode[tsFile.getIdentifier()] = result;
1100
1169
  }
1170
+ const resolvedTime = currentTime ?? (/* @__PURE__ */ new Date()).toISOString();
1171
+ const resolvedOffset = utcOffset ?? 0;
1101
1172
  const evaluator = new JavaScriptEvaluator();
1102
1173
  const evalResponse = await evaluator.evalDangerously({
1103
1174
  templateLogic: true,
@@ -1106,7 +1177,7 @@ class TemplateArchiveProcessor {
1106
1177
  code: compiledCode["logic/logic.ts"].code,
1107
1178
  // TODO DCS - how to find the code to run?
1108
1179
  argumentNames: ["data", "request", "state"],
1109
- arguments: [data, request, state, currentTime, utcOffset]
1180
+ arguments: [data, request, state, resolvedTime, resolvedOffset]
1110
1181
  });
1111
1182
  if (evalResponse.result) {
1112
1183
  return evalResponse.result;
@@ -1120,8 +1191,8 @@ class TemplateArchiveProcessor {
1120
1191
  /**
1121
1192
  * Init the logic of a template
1122
1193
  * @param {[string]} currentTime - the current time, defaults to now
1123
- * @param {[number]} utcOffset - the UTC offer, defaults to zero
1124
- * @returns {Promise} the response and any events
1194
+ * @param {[number]} utcOffset - the UTC offset, defaults to zero
1195
+ * @returns {Promise<InitResponse>} the new state
1125
1196
  */
1126
1197
  async init(data, currentTime, utcOffset) {
1127
1198
  const logicManager = this.template.getLogicManager();
@@ -1140,6 +1211,8 @@ class TemplateArchiveProcessor {
1140
1211
  const result = compiler.compile(code);
1141
1212
  compiledCode[tsFile.getIdentifier()] = result;
1142
1213
  }
1214
+ const resolvedTime = currentTime ?? (/* @__PURE__ */ new Date()).toISOString();
1215
+ const resolvedOffset = utcOffset ?? 0;
1143
1216
  const evaluator = new JavaScriptEvaluator();
1144
1217
  const evalResponse = await evaluator.evalDangerously({
1145
1218
  templateLogic: true,
@@ -1148,7 +1221,7 @@ class TemplateArchiveProcessor {
1148
1221
  code: compiledCode["logic/logic.ts"].code,
1149
1222
  // TODO DCS - how to find the code to run?
1150
1223
  argumentNames: ["data"],
1151
- arguments: [data, currentTime, utcOffset]
1224
+ arguments: [data, resolvedTime, resolvedOffset]
1152
1225
  });
1153
1226
  if (evalResponse.result) {
1154
1227
  return evalResponse.result;