diff --git a/package-lock.json b/package-lock.json
index ef2da3f0..897fd4e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"file-saver": "^2.0.5",
"flat": "^6.0.1",
"geodesy": "1.1.3",
+ "handlebars": "^4.7.8",
"highlight.js": "^11.9.0",
"ieee754": "^1.2.1",
"jimp": "^0.22.12",
@@ -10842,6 +10843,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/handlebars": {
+ "version": "4.7.8",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+ "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
"node_modules/has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
@@ -13654,7 +13676,6 @@
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
- "dev": true,
"license": "MIT"
},
"node_modules/netmask": {
@@ -16859,7 +16880,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -18877,6 +18897,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "license": "MIT"
+ },
"node_modules/worker-loader": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-3.0.8.tgz",
diff --git a/package.json b/package.json
index b3492a8e..3555a121 100644
--- a/package.json
+++ b/package.json
@@ -132,6 +132,7 @@
"file-saver": "^2.0.5",
"flat": "^6.0.1",
"geodesy": "1.1.3",
+ "handlebars": "^4.7.8",
"highlight.js": "^11.9.0",
"ieee754": "^1.2.1",
"jimp": "^0.22.12",
diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 71b311e6..470c828c 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -373,7 +373,8 @@
"Extract EXIF",
"Extract ID3",
"Extract Files",
- "RAKE"
+ "RAKE",
+ "Template"
]
},
{
diff --git a/src/core/operations/Template.mjs b/src/core/operations/Template.mjs
new file mode 100644
index 00000000..60b73e1d
--- /dev/null
+++ b/src/core/operations/Template.mjs
@@ -0,0 +1,53 @@
+/**
+ * @author kendallgoto [k@kgo.to]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import Handlebars from "handlebars";
+
+/**
+ * Template operation
+ */
+class Template extends Operation {
+
+ /**
+ * Template constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Template";
+ this.module = "Handlebars";
+ this.description = "Render a template with Handlebars/Mustache substituting variables using JSON input. Templates will be rendered to plain-text only, to prevent XSS.";
+ this.infoURL = "https://handlebarsjs.com/";
+ this.inputType = "JSON";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Template definition (.handlebars)",
+ type: "text",
+ value: ""
+ }
+ ];
+ }
+
+ /**
+ * @param {JSON} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [templateStr] = args;
+ try {
+ const template = Handlebars.compile(templateStr);
+ return template(input);
+ } catch (e) {
+ throw new OperationError(e);
+ }
+ }
+}
+
+export default Template;
diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs
index bb7016bb..d14f2ff6 100644
--- a/tests/operations/index.mjs
+++ b/tests/operations/index.mjs
@@ -157,6 +157,7 @@ import "./tests/Subsection.mjs";
import "./tests/SwapCase.mjs";
import "./tests/SymmetricDifference.mjs";
import "./tests/TakeNthBytes.mjs";
+import "./tests/Template.mjs";
import "./tests/TextEncodingBruteForce.mjs";
import "./tests/ToFromInsensitiveRegex.mjs";
import "./tests/TranslateDateTimeFormat.mjs";
diff --git a/tests/operations/tests/Template.mjs b/tests/operations/tests/Template.mjs
new file mode 100644
index 00000000..6ee54913
--- /dev/null
+++ b/tests/operations/tests/Template.mjs
@@ -0,0 +1,53 @@
+/**
+ * @author kendallgoto [k@kgo.to]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import TestRegister from "../../lib/TestRegister.mjs";
+TestRegister.addTests([
+ {
+ "name": "Template: Simple Print",
+ "input": "{}",
+ "expectedOutput": "Hello, world!",
+ "recipeConfig": [
+ {
+ "op": "Template",
+ "args": ["Hello, world!"]
+ }
+ ]
+ },
+ {
+ "name": "Template: Print Basic Variables",
+ "input": "{\"one\": 1, \"two\": 2}",
+ "expectedOutput": "1 2",
+ "recipeConfig": [
+ {
+ "op": "Template",
+ "args": ["{{ one }} {{ two }}"]
+ }
+ ]
+ },
+ {
+ "name": "Template: Partials",
+ "input": "{\"users\":[{\"name\":\"Someone\",\"age\":25},{\"name\":\"Someone Else\",\"age\":32}]}",
+ "expectedOutput": "Name: Someone\nAge: 25\n\nName: Someone Else\nAge: 32\n\n",
+ "recipeConfig": [
+ {
+ "op": "Template",
+ "args": ["{{#*inline \"user\"}}\nName: {{ name }}\nAge: {{ age }}\n{{/inline}}\n{{#each users}}\n{{> user}}\n\n{{/each}}"]
+ }
+ ]
+ },
+ {
+ "name": "Template: Disallow XSS",
+ "input": "{\"test\": \"\"}",
+ "expectedOutput": "<script></script>",
+ "recipeConfig": [
+ {
+ "op": "Template",
+ "args": ["{{ test }}"]
+ }
+ ]
+ }
+]);