diff --git a/README.md b/README.md index aacdd9c..9ca5177 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ The node can solve multiple use cases when creating content like: * WordPress posts * Telegram/Slack messages * Use helpers to filter templates +* Create custom helpers for business-specific logic +* Dynamic content generation with complex data transformations The sky is your limit! @@ -140,9 +142,109 @@ Total invoice: 133.10€ I recommend using this method if you want to send multiple invoices. ## Helpers -Now the node supports helpers thanks to the [@jaredwray/fumanchu](https://www.npmjs.com/package/@jaredwray/fumanchu#helpers) package. -We recommend checking +The node supports both built-in helpers and custom helpers thanks to the [@jaredwray/fumanchu](https://www.npmjs.com/package/@jaredwray/fumanchu#helpers) package. + +### Built-in Helpers +The node includes a comprehensive set of built-in helpers for common operations like date formatting, string manipulation, comparisons, and more. Check the [fumanchu helpers documentation](https://www.npmjs.com/package/@jaredwray/fumanchu#helpers) for a complete list. + +### Custom Helpers +You can now define your own custom Handlebars helpers to extend the templating functionality. This feature allows you to: +- Create reusable functions for complex data transformations +- Implement business-specific logic directly in templates +- Load helpers from external sources for better code organization + +#### Enabling Custom Helpers +1. Check the **"Use Custom Helpers"** option in the node configuration +2. Choose your helpers source: + - **From Field**: Write JavaScript code directly in the node + - **From URL**: Load helpers from an external JavaScript file + +#### Custom Helpers Format +Your custom helpers should be defined as a JavaScript module that exports an object with helper functions: + +```javascript +module.exports = { + // String manipulation helpers + uppercase: function(str) { + return str.toUpperCase(); + }, + + capitalize: function(str) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + }, + + // Date formatting helpers + formatDate: function(date, format) { + const d = new Date(date); + if (format === 'short') { + return d.toLocaleDateString(); + } + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + // Math helpers + multiply: function(a, b) { + return a * b; + }, + + percentage: function(value, total) { + return ((value / total) * 100).toFixed(2) + '%'; + }, + + // Conditional helpers + isEven: function(number) { + return number % 2 === 0; + }, + + // Array helpers + joinWithComma: function(array) { + return array.join(', '); + } +}; +``` +#### Loading Helpers from URL +For better code organization, you can host your helpers in an external JavaScript file: + +```javascript +// https://mydomain.com/helpers/custom-helpers.js +module.exports = { + formatCurrency: function(amount, currency = 'EUR') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency + }).format(amount); + }, + + slugify: function(text) { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + } +}; +``` + +Then use the URL in the node configuration: `https://mydomain.com/helpers/custom-helpers.js` + +#### Security and Limitations +- Custom helpers run in a sandboxed environment for security +- Execution timeout is set to 5 seconds to prevent infinite loops +- Basic Node.js modules are available (console, Buffer, setTimeout, etc.) +- Helper functions should be pure functions without side effects for best results + +#### Error Handling +If there are issues with your custom helpers: +- Syntax errors in the JavaScript code will be reported +- Invalid helper exports will show descriptive error messages +- Network issues when loading from URL will be handled gracefully + +We recommend testing your helpers thoroughly before using them in production workflows ## Doubts about templates syntax Please, check the [official page](https://handlebarsjs.com/guide/expressions.html#basic-usage) to review all the existing expressions in Handlebars. diff --git a/nodes/DocumentGenerator/DocumentGenerator.node.ts b/nodes/DocumentGenerator/DocumentGenerator.node.ts index 3fea71d..00908f4 100644 --- a/nodes/DocumentGenerator/DocumentGenerator.node.ts +++ b/nodes/DocumentGenerator/DocumentGenerator.node.ts @@ -1,12 +1,14 @@ import { handlebars, helpers } from '@jaredwray/fumanchu'; -import { IExecuteFunctions } from 'n8n-core'; -import { +import type { IExecuteFunctions } from 'n8n-core'; +import type { IBinaryKeyData, IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import * as vm from 'vm'; /** * A node which allows you to generate documents by templates. @@ -108,6 +110,83 @@ export class DocumentGenerator implements INodeType { description: 'The template URL to use for rendering. Please check the official page for Handlebars syntax.', }, + { + displayName: 'Use Custom Helpers', + name: 'useCustomHelpers', + type: 'boolean', + default: false, + description: 'Whether to use custom Handlebars helpers', + displayOptions: { + show: { + operation: ['render'], + }, + }, + }, + { + displayName: 'Helpers Source', + name: 'helpersSource', + type: 'options', + options: [ + { + name: 'From Field', + value: 'field', + description: 'Provide helpers code directly in a field', + }, + { + name: 'From URL', + value: 'url', + description: 'Load helpers from a URL', + }, + ], + default: 'field', + displayOptions: { + show: { + useCustomHelpers: [true], + }, + }, + }, + { + displayName: 'Custom Helpers Code', + name: 'helpersCode', + type: 'string', + required: true, + typeOptions: { + rows: 10, + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + useCustomHelpers: [true], + helpersSource: ['field'], + }, + }, + default: '', + placeholder: `// Example helpers +module.exports = { + uppercase: function(str) { + return str.toUpperCase(); + }, + formatDate: function(date) { + return new Date(date).toLocaleDateString(); + } +};`, + description: 'JavaScript code that exports an object with helper functions. Each property should be a function that can be used in templates.', + }, + { + displayName: 'Helpers URL', + name: 'helpersURL', + type: 'string', + required: true, + displayOptions: { + show: { + useCustomHelpers: [true], + helpersSource: ['url'], + }, + }, + default: '', + placeholder: 'https://mydomain.com/helpers/custom-helpers.js', + description: 'URL to a JavaScript file that exports an object with helper functions', + }, { displayName: 'Define a Custom Output Key', name: 'customOutputKey', @@ -135,7 +214,7 @@ export class DocumentGenerator implements INodeType { default: '', placeholder: 'text', description: 'The output property name where we save rendered text', - } + }, ], }; // The execute method will go here @@ -159,8 +238,73 @@ export class DocumentGenerator implements INodeType { const templateURL = this.getNodeParameter('templateURL', 0) as string; template = await this.helpers.request(templateURL); } + + // Initialize default helpers helpers({ handlebars }, {}); + // Load custom helpers if enabled + const useCustomHelpers = this.getNodeParameter('useCustomHelpers', 0) as boolean; + if (useCustomHelpers) { + try { + const helpersSource = this.getNodeParameter('helpersSource', 0) as string; + let helpersCode = ''; + + if (helpersSource === 'field') { + helpersCode = this.getNodeParameter('helpersCode', 0) as string; + } else if (helpersSource === 'url') { + const helpersURL = this.getNodeParameter('helpersURL', 0) as string; + helpersCode = await this.helpers.request(helpersURL); + } + + if (helpersCode.trim()) { + // Create a safe execution context + const sandbox = { + module: { exports: {} }, + exports: {}, + require: require, // Allow require for basic Node.js modules + console: console, + Buffer: Buffer, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + clearInterval: clearInterval, + }; + + // Execute the helpers code in the sandbox + vm.createContext(sandbox); + vm.runInContext(helpersCode, sandbox, { + timeout: 5000, // 5 second timeout + displayErrors: true, + }); + + // Extract the exported helpers + const customHelpers = sandbox.module.exports || sandbox.exports; + + if (typeof customHelpers === 'object' && customHelpers !== null) { + // Register each helper with handlebars + Object.keys(customHelpers).forEach((helperName) => { + const helper = (customHelpers as Record)[helperName]; + if (typeof helper === 'function') { + handlebars.registerHelper(helperName, helper as (context?: unknown, ...args: unknown[]) => unknown); + } + }); + } else { + throw new NodeOperationError( + this.getNode(), + 'Custom helpers must export an object with helper functions', + { itemIndex: 0 } + ); + } + } + } catch (error) { + throw new NodeOperationError( + this.getNode(), + `Failed to load custom helpers: ${error.message}`, + { itemIndex: 0 } + ); + } + } + const templateHelper = handlebars.compile(template); let key = 'text'; @@ -169,20 +313,20 @@ export class DocumentGenerator implements INodeType { } if (oneTemplate) { - var cleanedItems = items.map(function (item) { + const cleanedItems = items.map((item) => { return item.json; }); - let newItemJson: IDataObject = {}; + const newItemJson: IDataObject = {}; newItemJson[key] = templateHelper({ items: cleanedItems }); returnData.push({ json: newItemJson }); } else { for (let i = 0; i < items.length; i++) { - let item = items[i]; + const item = items[i]; if (operation === 'render') { - let newItemJson: IDataObject = {}; + const newItemJson: IDataObject = {}; // Get email input // Get additional fields input - var rendered = templateHelper(item.json); + const rendered = templateHelper(item.json); newItemJson[key] = rendered; returnData.push({ json: newItemJson,