@manablox/workflows 0.2.0
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/package.json +33 -0
- package/src/conditions.ts +88 -0
- package/src/cron.ts +181 -0
- package/src/engine.ts +499 -0
- package/src/index.ts +9 -0
- package/src/mail.ts +38 -0
- package/src/push.ts +61 -0
- package/src/service.ts +167 -0
- package/src/steps.ts +256 -0
- package/src/template.ts +65 -0
- package/src/validate.ts +267 -0
- package/test/conditions.test.ts +80 -0
- package/test/cron.test.ts +79 -0
- package/test/engine.test.ts +555 -0
- package/test/template.test.ts +41 -0
- package/test/validate.test.ts +129 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +9 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Manablox,
|
|
3
|
+
ManabloxError,
|
|
4
|
+
WORKFLOW_CONDITION_OPERATOR_LABELS,
|
|
5
|
+
WORKFLOW_CONDITION_OPERATORS,
|
|
6
|
+
WORKFLOW_EVENT_LABELS,
|
|
7
|
+
WORKFLOW_EVENTS,
|
|
8
|
+
WORKFLOW_STEP_LABELS,
|
|
9
|
+
WORKFLOW_STEP_TYPES,
|
|
10
|
+
} from '@manablox/core';
|
|
11
|
+
import type { PushSubscriptionRow, Repositories, WorkflowRow, WorkflowRunRow } from '@manablox/db';
|
|
12
|
+
import { requireInSpace } from '@manablox/services';
|
|
13
|
+
import type { WorkflowEngine } from './engine.js';
|
|
14
|
+
import { validateWorkflow, type WorkflowInput } from './validate.js';
|
|
15
|
+
|
|
16
|
+
export type { WorkflowInput, WorkflowRow, WorkflowRunRow };
|
|
17
|
+
|
|
18
|
+
/** What the editor needs to draw the palette, served rather than duplicated there. */
|
|
19
|
+
export interface WorkflowCatalog {
|
|
20
|
+
events: Array<{ id: string; label: string; description: string }>;
|
|
21
|
+
stepTypes: Array<{ id: string; label: string; description: string; available: boolean }>;
|
|
22
|
+
operators: Array<{ id: string; label: string }>;
|
|
23
|
+
/** Whether the instance can send mail / push at all; the editor warns when it cannot. */
|
|
24
|
+
mail: boolean;
|
|
25
|
+
push: boolean;
|
|
26
|
+
/** The VAPID public key a browser subscribes with, when push is configured. */
|
|
27
|
+
pushPublicKey: string | null;
|
|
28
|
+
adminUrl: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Workflows: the rules — a trigger that exists, steps with somewhere to go, a run that
|
|
33
|
+
* belongs to the space it is asked for in — with the engine doing the running.
|
|
34
|
+
*/
|
|
35
|
+
export class WorkflowService {
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly manablox: Manablox,
|
|
38
|
+
private readonly repos: Repositories,
|
|
39
|
+
private readonly engine: WorkflowEngine,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
catalog(): WorkflowCatalog {
|
|
43
|
+
const mail = Boolean(this.manablox.config.mail.smtpUrl);
|
|
44
|
+
const push = Boolean(
|
|
45
|
+
this.manablox.config.push.vapidPublicKey && this.manablox.config.push.vapidPrivateKey,
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
events: WORKFLOW_EVENTS.map((id) => ({ id, ...WORKFLOW_EVENT_LABELS[id] })),
|
|
49
|
+
stepTypes: WORKFLOW_STEP_TYPES.map((id) => ({
|
|
50
|
+
id,
|
|
51
|
+
...WORKFLOW_STEP_LABELS[id],
|
|
52
|
+
available: id === 'email' ? mail : id === 'push' ? push : true,
|
|
53
|
+
})),
|
|
54
|
+
operators: WORKFLOW_CONDITION_OPERATORS.map((id) => ({
|
|
55
|
+
id,
|
|
56
|
+
label: WORKFLOW_CONDITION_OPERATOR_LABELS[id],
|
|
57
|
+
})),
|
|
58
|
+
mail,
|
|
59
|
+
push,
|
|
60
|
+
pushPublicKey: push ? (this.manablox.config.push.vapidPublicKey ?? null) : null,
|
|
61
|
+
adminUrl: this.engine.adminUrl,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
list(spaceId: string): Promise<WorkflowRow[]> {
|
|
66
|
+
return this.repos.workflows.listBySpace(spaceId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get(spaceId: string, id: string): Promise<WorkflowRow> {
|
|
70
|
+
return this.find(spaceId, id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async create(spaceId: string, input: WorkflowInput): Promise<WorkflowRow> {
|
|
74
|
+
const valid = validateWorkflow(input, this.validationEnvironment());
|
|
75
|
+
return this.repos.workflows.create({ spaceId, ...valid });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async update(spaceId: string, id: string, input: WorkflowInput): Promise<WorkflowRow> {
|
|
79
|
+
await this.find(spaceId, id);
|
|
80
|
+
const valid = validateWorkflow(input, this.validationEnvironment());
|
|
81
|
+
return this.repos.workflows.update(id, valid);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The on/off switch, kept apart from a full save so the list can flip it. */
|
|
85
|
+
async setEnabled(spaceId: string, id: string, enabled: boolean): Promise<WorkflowRow> {
|
|
86
|
+
await this.find(spaceId, id);
|
|
87
|
+
return this.repos.workflows.update(id, { enabled });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async delete(spaceId: string, id: string): Promise<void> {
|
|
91
|
+
await this.find(spaceId, id);
|
|
92
|
+
await this.repos.workflows.delete(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async runs(spaceId: string, id: string, limit = 50): Promise<WorkflowRunRow[]> {
|
|
96
|
+
await this.find(spaceId, id);
|
|
97
|
+
return this.repos.workflows.listRuns(id, limit);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async run(spaceId: string, id: string): Promise<WorkflowRunRow> {
|
|
101
|
+
return requireInSpace(
|
|
102
|
+
await this.repos.workflows.findRun(id),
|
|
103
|
+
spaceId,
|
|
104
|
+
'workflow.run.notFound',
|
|
105
|
+
{
|
|
106
|
+
id,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Runs a workflow now, against a document of the space when one is named. An
|
|
113
|
+
* event-triggered workflow needs one — its steps read `content` — while a scheduled
|
|
114
|
+
* workflow may run against its own selection.
|
|
115
|
+
*/
|
|
116
|
+
async runNow(spaceId: string, id: string, contentId: string | null): Promise<WorkflowRunRow> {
|
|
117
|
+
const workflow = await this.find(spaceId, id);
|
|
118
|
+
let document = null;
|
|
119
|
+
if (contentId) {
|
|
120
|
+
document = requireInSpace(
|
|
121
|
+
await this.repos.content.findById(contentId),
|
|
122
|
+
spaceId,
|
|
123
|
+
'content.notFound',
|
|
124
|
+
{ id: contentId },
|
|
125
|
+
);
|
|
126
|
+
} else if (workflow.trigger.kind === 'event') {
|
|
127
|
+
throw ManabloxError.badRequest('workflow.run.documentRequired');
|
|
128
|
+
}
|
|
129
|
+
return this.engine.runManually(workflow, document);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- push subscriptions --------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
subscriptions(userId: string): Promise<PushSubscriptionRow[]> {
|
|
135
|
+
return this.repos.workflows.subscriptionsOf(userId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async subscribe(
|
|
139
|
+
userId: string,
|
|
140
|
+
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
|
|
141
|
+
userAgent: string | null,
|
|
142
|
+
): Promise<PushSubscriptionRow> {
|
|
143
|
+
if (!this.catalog().push) throw ManabloxError.badRequest('workflow.push.notConfigured');
|
|
144
|
+
if (!/^https:\/\//.test(subscription.endpoint)) {
|
|
145
|
+
throw ManabloxError.badRequest('workflow.push.subscriptionInvalid');
|
|
146
|
+
}
|
|
147
|
+
return this.repos.workflows.subscribe({ userId, ...subscription, userAgent });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
unsubscribe(userId: string, endpoint: string): Promise<void> {
|
|
151
|
+
return this.repos.workflows.unsubscribe(userId, endpoint);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -------------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
private async find(spaceId: string, id: string): Promise<WorkflowRow> {
|
|
157
|
+
return requireInSpace(await this.repos.workflows.findById(id), spaceId, 'workflow.notFound', {
|
|
158
|
+
id,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private validationEnvironment() {
|
|
163
|
+
return {
|
|
164
|
+
typeExists: (typeId: string) => this.manablox.contentTypes.tryGet(typeId) !== undefined,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/steps.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
isEmailAddress,
|
|
4
|
+
ManabloxError,
|
|
5
|
+
type WorkflowEmailStep,
|
|
6
|
+
type WorkflowHttpStep,
|
|
7
|
+
type WorkflowPushStep,
|
|
8
|
+
type WorkflowRunContext,
|
|
9
|
+
type WorkflowStep,
|
|
10
|
+
} from '@manablox/core';
|
|
11
|
+
import type { Repositories } from '@manablox/db';
|
|
12
|
+
import { evaluateCondition } from './conditions.js';
|
|
13
|
+
import type { Mailer } from './mail.js';
|
|
14
|
+
import type { Pusher } from './push.js';
|
|
15
|
+
import { render, renderJson } from './template.js';
|
|
16
|
+
|
|
17
|
+
/** The outside world as the steps see it; tests swap every part of it. */
|
|
18
|
+
export interface StepEnvironment {
|
|
19
|
+
repos: Repositories;
|
|
20
|
+
mailer: Mailer | null;
|
|
21
|
+
pusher: Pusher | null;
|
|
22
|
+
fetch: typeof fetch;
|
|
23
|
+
/** Where the admin lives, for links in mails and notifications. */
|
|
24
|
+
adminUrl: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type StepOutcome =
|
|
28
|
+
| { kind: 'ok'; message: string | null; detail: Record<string, unknown> | null }
|
|
29
|
+
| { kind: 'stop'; message: string }
|
|
30
|
+
| { kind: 'wait'; minutes: number };
|
|
31
|
+
|
|
32
|
+
/** Runs one step against a context. Throws when the step fails; the runner logs it. */
|
|
33
|
+
export async function executeStep(
|
|
34
|
+
step: WorkflowStep,
|
|
35
|
+
context: WorkflowRunContext,
|
|
36
|
+
env: StepEnvironment,
|
|
37
|
+
): Promise<StepOutcome> {
|
|
38
|
+
switch (step.type) {
|
|
39
|
+
case 'email':
|
|
40
|
+
return sendEmail(step, context, env);
|
|
41
|
+
case 'http':
|
|
42
|
+
return callHttp(step, context, env);
|
|
43
|
+
case 'push':
|
|
44
|
+
return sendPush(step, context, env);
|
|
45
|
+
case 'condition':
|
|
46
|
+
return evaluateCondition(step, context)
|
|
47
|
+
? { kind: 'ok', message: 'Conditions met', detail: null }
|
|
48
|
+
: { kind: 'stop', message: 'Conditions not met' };
|
|
49
|
+
case 'delay':
|
|
50
|
+
return { kind: 'wait', minutes: step.minutes };
|
|
51
|
+
case 'branch':
|
|
52
|
+
// The runner walks a fork itself; it never lands here.
|
|
53
|
+
return evaluateCondition(step, context)
|
|
54
|
+
? { kind: 'ok', message: 'Rules hold', detail: { branch: 'then' } }
|
|
55
|
+
: { kind: 'ok', message: 'Rules do not hold', detail: { branch: 'else' } };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- email --------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async function sendEmail(
|
|
62
|
+
step: WorkflowEmailStep,
|
|
63
|
+
context: WorkflowRunContext,
|
|
64
|
+
env: StepEnvironment,
|
|
65
|
+
): Promise<StepOutcome> {
|
|
66
|
+
if (!env.mailer) throw new ManabloxError('workflow.mail.notConfigured');
|
|
67
|
+
|
|
68
|
+
const recipients = new Set<string>();
|
|
69
|
+
for (const template of step.to) {
|
|
70
|
+
for (const address of render(template, context).split(/[,\s;]+/)) {
|
|
71
|
+
if (address && isEmailAddress(address)) recipients.add(address.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (step.toRoles.length) {
|
|
75
|
+
const members = await env.repos.users.membersOf(context.space.id);
|
|
76
|
+
for (const member of members) {
|
|
77
|
+
if (step.toRoles.includes(member.role) && member.user.email) {
|
|
78
|
+
recipients.add(member.user.email.toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (recipients.size === 0) throw new Error('No recipient address resolved');
|
|
83
|
+
|
|
84
|
+
const subject = render(step.subject, context);
|
|
85
|
+
const body = render(step.body, context);
|
|
86
|
+
const to = [...recipients];
|
|
87
|
+
const sent = await env.mailer.send({
|
|
88
|
+
to,
|
|
89
|
+
subject,
|
|
90
|
+
text: step.html ? stripTags(body) : body,
|
|
91
|
+
...(step.html ? { html: body } : {}),
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
kind: 'ok',
|
|
95
|
+
message: `Sent to ${to.join(', ')}`,
|
|
96
|
+
detail: { to, subject, messageId: sent.id },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stripTags(html: string): string {
|
|
101
|
+
return html
|
|
102
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
103
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
104
|
+
.replace(/<\/p>/gi, '\n\n')
|
|
105
|
+
.replace(/<[^>]+>/g, '')
|
|
106
|
+
.replace(/ /g, ' ')
|
|
107
|
+
.replace(/&/g, '&')
|
|
108
|
+
.replace(/</g, '<')
|
|
109
|
+
.replace(/>/g, '>')
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- http ---------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async function callHttp(
|
|
116
|
+
step: WorkflowHttpStep,
|
|
117
|
+
context: WorkflowRunContext,
|
|
118
|
+
env: StepEnvironment,
|
|
119
|
+
): Promise<StepOutcome> {
|
|
120
|
+
const url = render(step.url, context);
|
|
121
|
+
const headers = new Headers();
|
|
122
|
+
for (const header of step.headers) headers.set(header.name, render(header.value, context));
|
|
123
|
+
|
|
124
|
+
let body: string | undefined;
|
|
125
|
+
if (step.method !== 'GET' && step.body.mode !== 'none') {
|
|
126
|
+
if (step.body.mode === 'event') {
|
|
127
|
+
body = JSON.stringify(context);
|
|
128
|
+
if (!headers.has('content-type')) headers.set('content-type', 'application/json');
|
|
129
|
+
} else {
|
|
130
|
+
const template = step.body.template;
|
|
131
|
+
// A template that is itself JSON is rendered as JSON, so placeholders keep types.
|
|
132
|
+
body = looksLikeJson(template) ? renderJson(template, context) : render(template, context);
|
|
133
|
+
if (!headers.has('content-type')) {
|
|
134
|
+
headers.set('content-type', looksLikeJson(template) ? 'application/json' : 'text/plain');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
headers.set('x-manablox-event', context.event);
|
|
140
|
+
headers.set('x-manablox-workflow', context.workflow.id);
|
|
141
|
+
if (step.secret && body !== undefined) {
|
|
142
|
+
headers.set(
|
|
143
|
+
'x-manablox-signature',
|
|
144
|
+
`sha256=${createHmac('sha256', step.secret).update(body).digest('hex')}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
let response: Response;
|
|
150
|
+
try {
|
|
151
|
+
response = await env.fetch(url, {
|
|
152
|
+
method: step.method,
|
|
153
|
+
headers,
|
|
154
|
+
...(body !== undefined ? { body } : {}),
|
|
155
|
+
signal: AbortSignal.timeout(step.timeoutMs),
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
159
|
+
throw new Error(`${step.method} ${url} failed: ${reason}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const snippet = (await response.text().catch(() => '')).slice(0, 500);
|
|
163
|
+
const detail = {
|
|
164
|
+
url,
|
|
165
|
+
method: step.method,
|
|
166
|
+
status: response.status,
|
|
167
|
+
ms: Date.now() - started,
|
|
168
|
+
response: snippet,
|
|
169
|
+
};
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const error = new Error(`${step.method} ${url} answered HTTP ${response.status}`);
|
|
172
|
+
(error as Error & { detail?: Record<string, unknown> }).detail = detail;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
return { kind: 'ok', message: `HTTP ${response.status}`, detail };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function looksLikeJson(template: string): boolean {
|
|
179
|
+
const trimmed = template.trim();
|
|
180
|
+
if (
|
|
181
|
+
!(trimmed.startsWith('{') && trimmed.endsWith('}')) &&
|
|
182
|
+
!(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
183
|
+
) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
JSON.parse(trimmed);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- push ---------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
async function sendPush(
|
|
197
|
+
step: WorkflowPushStep,
|
|
198
|
+
context: WorkflowRunContext,
|
|
199
|
+
env: StepEnvironment,
|
|
200
|
+
): Promise<StepOutcome> {
|
|
201
|
+
if (!env.pusher) throw new ManabloxError('workflow.push.notConfigured');
|
|
202
|
+
|
|
203
|
+
const userIds = new Set(step.userIds);
|
|
204
|
+
if (step.roles.length || userIds.size === 0) {
|
|
205
|
+
const members = await env.repos.users.membersOf(context.space.id);
|
|
206
|
+
for (const member of members) {
|
|
207
|
+
if (step.roles.length === 0 || step.roles.includes(member.role)) userIds.add(member.userId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const subscriptions = await env.repos.workflows.subscriptionsFor([...userIds]);
|
|
212
|
+
if (subscriptions.length === 0) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'ok',
|
|
215
|
+
message: 'Nobody among the recipients has enabled notifications',
|
|
216
|
+
detail: { recipients: userIds.size, devices: 0 },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const renderedUrl = render(step.url, context).trim();
|
|
221
|
+
const url = renderedUrl
|
|
222
|
+
? /^https?:\/\//i.test(renderedUrl)
|
|
223
|
+
? renderedUrl
|
|
224
|
+
: `${env.adminUrl.replace(/\/$/, '')}/${renderedUrl.replace(/^\//, '')}`
|
|
225
|
+
: context.url;
|
|
226
|
+
const payload = { title: render(step.title, context), body: render(step.body, context), url };
|
|
227
|
+
|
|
228
|
+
let sent = 0;
|
|
229
|
+
let gone = 0;
|
|
230
|
+
const failures: string[] = [];
|
|
231
|
+
for (const subscription of subscriptions) {
|
|
232
|
+
try {
|
|
233
|
+
const result = await env.pusher.send(
|
|
234
|
+
{ endpoint: subscription.endpoint, keys: subscription.keys },
|
|
235
|
+
payload,
|
|
236
|
+
);
|
|
237
|
+
if (result === 'gone') {
|
|
238
|
+
gone++;
|
|
239
|
+
await env.repos.workflows.dropSubscription(subscription.id);
|
|
240
|
+
} else {
|
|
241
|
+
sent++;
|
|
242
|
+
await env.repos.workflows.markSubscriptionUsed(subscription.id);
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const detail = { recipients: userIds.size, devices: subscriptions.length, sent, gone, failures };
|
|
250
|
+
if (sent === 0 && failures.length) {
|
|
251
|
+
const error = new Error(`Every push failed: ${failures[0]}`);
|
|
252
|
+
(error as Error & { detail?: Record<string, unknown> }).detail = detail;
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
return { kind: 'ok', message: `Sent to ${sent} device${sent === 1 ? '' : 's'}`, detail };
|
|
256
|
+
}
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The placeholder syntax templates use: `{{ content.title }}`, `{{ content.fields.body }}`,
|
|
3
|
+
* `{{ actor.email }}`. A path resolves into the run context; an object or array comes out
|
|
4
|
+
* as JSON, anything missing as an empty string. Deliberately no logic — a workflow that
|
|
5
|
+
* needs a branch has a condition step for it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_.[\]-]+)\s*\}\}/g;
|
|
9
|
+
|
|
10
|
+
export function resolvePath(root: unknown, path: string): unknown {
|
|
11
|
+
let current: unknown = root;
|
|
12
|
+
for (const segment of path.replace(/\[(\d+)\]/g, '.$1').split('.')) {
|
|
13
|
+
if (segment === '') continue;
|
|
14
|
+
if (current === null || current === undefined) return undefined;
|
|
15
|
+
if (typeof current !== 'object') return undefined;
|
|
16
|
+
current = (current as Record<string, unknown>)[segment];
|
|
17
|
+
}
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function stringify(value: unknown): string {
|
|
22
|
+
if (value === null || value === undefined) return '';
|
|
23
|
+
if (typeof value === 'string') return value;
|
|
24
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
25
|
+
if (value instanceof Date) return value.toISOString();
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function render(template: string, context: unknown): string {
|
|
30
|
+
return template.replace(PLACEHOLDER, (_match, path: string) =>
|
|
31
|
+
stringify(resolvePath(context, path)),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Renders inside a JSON document: a placeholder that stands alone in a string is
|
|
37
|
+
* replaced by the value itself (`"count": "{{ documents.length }}"` becomes a number,
|
|
38
|
+
* `"doc": "{{ content }}"` an object); one embedded in text renders as text.
|
|
39
|
+
*/
|
|
40
|
+
export function renderJson(template: string, context: unknown): string {
|
|
41
|
+
const parsed: unknown = JSON.parse(template);
|
|
42
|
+
const walk = (value: unknown): unknown => {
|
|
43
|
+
if (typeof value === 'string') {
|
|
44
|
+
const alone = /^\{\{\s*([a-zA-Z0-9_.[\]-]+)\s*\}\}$/.exec(value);
|
|
45
|
+
if (alone) {
|
|
46
|
+
const resolved = resolvePath(context, alone[1] as string);
|
|
47
|
+
return resolved === undefined ? null : resolved;
|
|
48
|
+
}
|
|
49
|
+
return render(value, context);
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
52
|
+
if (value && typeof value === 'object') {
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, walk(entry)]),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
};
|
|
59
|
+
return JSON.stringify(walk(parsed));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every placeholder a template names, for the editor's hints and for validation. */
|
|
63
|
+
export function placeholders(template: string): string[] {
|
|
64
|
+
return [...template.matchAll(PLACEHOLDER)].map((match) => match[1] as string);
|
|
65
|
+
}
|