Add shared replacement method

This commit is contained in:
Dries Augustyns
2025-12-03 13:57:15 +01:00
parent 8defb9f8ab
commit b3e6d03962
6 changed files with 49 additions and 52 deletions
+4 -16
View File
@@ -5,6 +5,7 @@ import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {renderTemplate} from '@plunk/shared';
import {BillingLimitService} from './BillingLimitService.js'; import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js'; import {DomainService} from './DomainService.js';
@@ -552,28 +553,15 @@ export class EmailService {
/** /**
* Format email template by replacing variables in subject and body * Format email template by replacing variables in subject and body
* Supports {{variable}} and {{variable ?? defaultValue}} syntax * Uses shared template rendering from @plunk/shared
*/ */
public static format({subject, body, data}: {subject: string; body: string; data: Record<string, unknown>}): { public static format({subject, body, data}: {subject: string; body: string; data: Record<string, unknown>}): {
subject: string; subject: string;
body: string; body: string;
} { } {
const replaceVariables = (text: string) => {
return text.replace(/\{\{(.*?)\}\}/g, (match, key) => {
const [mainKey, defaultValue] = key.split('??').map((s: string) => s.trim());
// Handle array values (for lists)
if (Array.isArray(data[mainKey])) {
return data[mainKey].map((e: string) => `<li>${e}</li>`).join('\n');
}
return data[mainKey] ?? defaultValue ?? '';
});
};
return { return {
subject: replaceVariables(subject), subject: renderTemplate(subject, data),
body: replaceVariables(body), body: renderTemplate(body, data),
}; };
} }
@@ -8,7 +8,7 @@ import type {
Workflow, Workflow,
} from '@plunk/db'; } from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db'; import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowStepConfigSchemas} from '@plunk/shared'; import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
@@ -863,16 +863,10 @@ export class WorkflowExecutionService {
/** /**
* Helper: Render template with variables * Helper: Render template with variables
* Uses shared template rendering from @plunk/shared
*/ */
private static renderTemplate(template: string, variables: Record<string, unknown>): string { private static renderTemplate(template: string, variables: Record<string, unknown>): string {
let rendered = template; return renderTemplate(template, variables);
for (const [key, value] of Object.entries(variables)) {
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
rendered = rendered.replace(regex, String(value || ''));
}
return rendered;
} }
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
@@ -12,6 +12,7 @@ import {ResizableImage} from './ResizableImage';
import {useContactFields, useContacts} from '../../lib/hooks/useContacts'; import {useContactFields, useContacts} from '../../lib/hooks/useContacts';
import {useConfig} from '../../lib/hooks/useConfig'; import {useConfig} from '../../lib/hooks/useConfig';
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {renderTemplate} from '@plunk/shared';
import { import {
Button, Button,
Dialog, Dialog,
@@ -276,32 +277,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
}; };
const replaceVariables = (text: string, contactData: Record<string, unknown>) => { const replaceVariables = (text: string, contactData: Record<string, unknown>) => {
return text.replace(/\{\{(.*?)\}\}/g, (match, key) => { return renderTemplate(text, contactData);
const [mainKey, defaultValue] = key.split('??').map((s: string) => s.trim());
// Handle special variables
if (mainKey === 'unsubscribeUrl') return contactData.unsubscribeUrl || defaultValue || '#';
if (mainKey === 'subscribeUrl') return contactData.subscribeUrl || defaultValue || '#';
if (mainKey === 'manageUrl') return contactData.manageUrl || defaultValue || '#';
// Handle nested property access (e.g., data.firstName)
const getValue = (obj: Record<string, unknown>, path: string): unknown => {
return path.split('.').reduce((current: Record<string, unknown> | unknown, key) => {
if (current && typeof current === 'object' && !Array.isArray(current)) {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj);
};
// Try multiple lookup strategies
const value =
getValue(contactData, mainKey) || // Try as nested path (e.g., data.firstName)
contactData[mainKey] || // Try as top-level property
(contactData.data as Record<string, unknown>)?.[mainKey]; // Try in data object
return value ?? defaultValue ?? '';
});
}; };
const getPreviewHtml = () => { const getPreviewHtml = () => {
+1
View File
@@ -1,2 +1,3 @@
export * from './schemas/index.js'; export * from './schemas/index.js';
export * from './operators.js'; export * from './operators.js';
export * from './template.js';
+38
View File
@@ -0,0 +1,38 @@
/**
* Render email template by replacing variables
* Supports {{variable}} and {{variable ?? defaultValue}} syntax
* Also supports nested access like {{data.firstName}}
*
* Example:
* renderTemplate('Hello {{name}}!', { name: 'World' }) -> 'Hello World!'
* renderTemplate('Hello {{data.name}}!', { data: { name: 'World' } }) -> 'Hello World!'
* renderTemplate('Hello {{name ?? Guest}}!', {}) -> 'Hello Guest!'
*/
export function renderTemplate(template: string, variables: Record<string, unknown>): string {
return template.replace(/\{\{(.*?)\}\}/g, (match, key) => {
const [mainKey, defaultValue] = key.split('??').map((s: string) => s.trim());
// Handle nested property access (e.g., data.firstName)
const getValue = (obj: Record<string, unknown>, path: string): unknown => {
return path.split('.').reduce((current: Record<string, unknown> | unknown, key) => {
if (current && typeof current === 'object' && !Array.isArray(current)) {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj);
};
// Try multiple lookup strategies
const value =
getValue(variables, mainKey) || // Try as nested path (e.g., data.firstName)
variables[mainKey] || // Try as top-level property
(variables.data as Record<string, unknown>)?.[mainKey]; // Try in data object
// Handle array values (for lists)
if (Array.isArray(value)) {
return value.map((e: string) => `<li>${e}</li>`).join('\n');
}
return value ?? defaultValue ?? '';
});
}