From b3e6d039623d3c7754683beca8ee68a17d854b2e Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 3 Dec 2025 13:57:15 +0100 Subject: [PATCH] Add shared replacement method --- apps/api/src/services/EmailService.ts | 20 ++-------- .../src/services/WorkflowExecutionService.ts | 12 ++---- apps/landing/next-env.d.ts | 2 +- .../components/EmailEditor/EmailEditor.tsx | 28 +------------- packages/shared/src/index.ts | 1 + packages/shared/src/template.ts | 38 +++++++++++++++++++ 6 files changed, 49 insertions(+), 52 deletions(-) create mode 100644 packages/shared/src/template.ts diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 79695e8..a1ca47c 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -5,6 +5,7 @@ import signale from 'signale'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; +import {renderTemplate} from '@plunk/shared'; import {BillingLimitService} from './BillingLimitService.js'; import {DomainService} from './DomainService.js'; @@ -552,28 +553,15 @@ export class EmailService { /** * 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}): { subject: 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) => `
  • ${e}
  • `).join('\n'); - } - - return data[mainKey] ?? defaultValue ?? ''; - }); - }; - return { - subject: replaceVariables(subject), - body: replaceVariables(body), + subject: renderTemplate(subject, data), + body: renderTemplate(body, data), }; } diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index c63ef22..93e3d04 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -8,7 +8,7 @@ import type { Workflow, } 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 {HttpException} from '../exceptions/index.js'; @@ -863,16 +863,10 @@ export class WorkflowExecutionService { /** * Helper: Render template with variables + * Uses shared template rendering from @plunk/shared */ private static renderTemplate(template: string, variables: Record): string { - let rendered = template; - - for (const [key, value] of Object.entries(variables)) { - const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); - rendered = rendered.replace(regex, String(value || '')); - } - - return rendered; + return renderTemplate(template, variables); } /** diff --git a/apps/landing/next-env.d.ts b/apps/landing/next-env.d.ts index 7996d35..1970904 100644 --- a/apps/landing/next-env.d.ts +++ b/apps/landing/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/web/src/components/EmailEditor/EmailEditor.tsx b/apps/web/src/components/EmailEditor/EmailEditor.tsx index 6d84e98..72396d1 100644 --- a/apps/web/src/components/EmailEditor/EmailEditor.tsx +++ b/apps/web/src/components/EmailEditor/EmailEditor.tsx @@ -12,6 +12,7 @@ import {ResizableImage} from './ResizableImage'; import {useContactFields, useContacts} from '../../lib/hooks/useContacts'; import {useConfig} from '../../lib/hooks/useConfig'; import {useEffect, useRef, useState} from 'react'; +import {renderTemplate} from '@plunk/shared'; import { Button, Dialog, @@ -276,32 +277,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT }; const replaceVariables = (text: string, contactData: Record) => { - return text.replace(/\{\{(.*?)\}\}/g, (match, key) => { - 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, path: string): unknown => { - return path.split('.').reduce((current: Record | unknown, key) => { - if (current && typeof current === 'object' && !Array.isArray(current)) { - return (current as Record)[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)?.[mainKey]; // Try in data object - - return value ?? defaultValue ?? ''; - }); + return renderTemplate(text, contactData); }; const getPreviewHtml = () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7d95cbd..e42acf1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './schemas/index.js'; export * from './operators.js'; +export * from './template.js'; diff --git a/packages/shared/src/template.ts b/packages/shared/src/template.ts new file mode 100644 index 0000000..b1198b2 --- /dev/null +++ b/packages/shared/src/template.ts @@ -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 { + 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, path: string): unknown => { + return path.split('.').reduce((current: Record | unknown, key) => { + if (current && typeof current === 'object' && !Array.isArray(current)) { + return (current as Record)[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)?.[mainKey]; // Try in data object + + // Handle array values (for lists) + if (Array.isArray(value)) { + return value.map((e: string) => `
  • ${e}
  • `).join('\n'); + } + + return value ?? defaultValue ?? ''; + }); +} \ No newline at end of file