Skip to content

Commit 1b90842

Browse files
committed
Regex template function
1 parent f1acb3c commit 1b90842

File tree

3 files changed

+251
-15
lines changed

3 files changed

+251
-15
lines changed

plugins/template-function-regex/src/index.ts

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,73 @@
1+
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
12
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
23

4+
const inputArg: TemplateFunctionArg = {
5+
type: 'text',
6+
name: 'input',
7+
label: 'Input Text',
8+
multiLine: true,
9+
};
10+
11+
const regexArg: TemplateFunctionArg = {
12+
type: 'text',
13+
name: 'regex',
14+
label: 'Regular Expression',
15+
placeholder: '\\w+',
16+
defaultValue: '.*',
17+
description:
18+
'A JavaScript regular expression. Use a capture group to reference parts of the match in the replacement.',
19+
};
20+
321
export const plugin: PluginDefinition = {
422
templateFunctions: [
523
{
624
name: 'regex.match',
7-
description: 'Extract',
25+
description: 'Extract text using a regular expression',
26+
args: [inputArg, regexArg],
27+
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
28+
const input = String(args.values.input ?? '');
29+
const regex = new RegExp(String(args.values.regex ?? ''));
30+
31+
const match = input.match(regex);
32+
return match?.groups
33+
? (Object.values(match.groups)[0] ?? '')
34+
: (match?.[1] ?? match?.[0] ?? '');
35+
},
36+
},
37+
{
38+
name: 'regex.replace',
39+
description: 'Replace text using a regular expression',
840
args: [
41+
inputArg,
42+
regexArg,
43+
{
44+
type: 'text',
45+
name: 'replacement',
46+
label: 'Replacement Text',
47+
placeholder: 'hello $1',
48+
description:
49+
'The replacement text. Use $1, $2, ... to reference capture groups or $& to reference the entire match.',
50+
},
951
{
1052
type: 'text',
11-
name: 'regex',
12-
label: 'Regular Expression',
13-
placeholder: '^\\w+=(?<value>\\w*)$',
14-
defaultValue: '^(.*)$',
53+
name: 'flags',
54+
label: 'Flags',
55+
placeholder: 'g',
56+
defaultValue: 'g',
57+
optional: true,
1558
description:
16-
'A JavaScript regular expression, evaluated using the Node.js RegExp engine. Capture groups or named groups can be used to extract values.',
59+
'Regular expression flags (g for global, i for case-insensitive, m for multiline, etc.)',
1760
},
18-
{ type: 'text', name: 'input', label: 'Input Text', multiLine: true },
1961
],
2062
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
21-
if (!args.values.regex || !args.values.input) return '';
63+
const input = String(args.values.input ?? '');
64+
const replacement = String(args.values.replacement ?? '');
65+
const flags = String(args.values.flags || '');
66+
const regex = String(args.values.regex);
2267

23-
const input = String(args.values.input);
24-
const regex = new RegExp(String(args.values.regex));
25-
const match = input.match(regex);
26-
return match?.groups
27-
? (Object.values(match.groups)[0] ?? '')
28-
: (match?.[1] ?? match?.[0] ?? '');
68+
if (!regex) return '';
69+
70+
return input.replace(new RegExp(String(args.values.regex), flags), replacement);
2971
},
3072
},
3173
],
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Context } from '@yaakapp/api';
3+
import { plugin } from '../src';
4+
5+
describe('regex.match', () => {
6+
const matchFunction = plugin.templateFunctions!.find(f => f.name === 'regex.match');
7+
8+
it('should exist', () => {
9+
expect(matchFunction).toBeDefined();
10+
});
11+
12+
it('should extract first capture group', async () => {
13+
const result = await matchFunction!.onRender({} as Context, {
14+
values: {
15+
regex: 'Hello (\\w+)',
16+
input: 'Hello World',
17+
},
18+
purpose: 'send',
19+
});
20+
expect(result).toBe('World');
21+
});
22+
23+
it('should extract named capture group', async () => {
24+
const result = await matchFunction!.onRender({} as Context, {
25+
values: {
26+
regex: 'Hello (?<name>\\w+)',
27+
input: 'Hello World',
28+
},
29+
purpose: 'send',
30+
});
31+
expect(result).toBe('World');
32+
});
33+
34+
it('should return full match when no capture groups', async () => {
35+
const result = await matchFunction!.onRender({} as Context, {
36+
values: {
37+
regex: 'Hello \\w+',
38+
input: 'Hello World'
39+
},
40+
purpose: 'send',
41+
});
42+
expect(result).toBe('Hello World');
43+
});
44+
45+
it('should return empty string when no match', async () => {
46+
const result = await matchFunction!.onRender({} as Context, {
47+
values: {
48+
regex: 'Goodbye',
49+
input: 'Hello World'
50+
},
51+
purpose: 'send',
52+
});
53+
expect(result).toBe('');
54+
});
55+
56+
it('should return empty string when regex is empty', async () => {
57+
const result = await matchFunction!.onRender({} as Context, {
58+
values: {
59+
regex: '',
60+
input: 'Hello World'
61+
},
62+
purpose: 'send',
63+
});
64+
expect(result).toBe('');
65+
});
66+
67+
it('should return empty string when input is empty', async () => {
68+
const result = await matchFunction!.onRender({} as Context, {
69+
values: {
70+
regex: 'Hello',
71+
input: ''
72+
},
73+
purpose: 'send',
74+
});
75+
expect(result).toBe('');
76+
});
77+
});
78+
79+
describe('regex.replace', () => {
80+
const replaceFunction = plugin.templateFunctions!.find(f => f.name === 'regex.replace');
81+
82+
it('should exist', () => {
83+
expect(replaceFunction).toBeDefined();
84+
});
85+
86+
it('should replace one occurrence by default', async () => {
87+
const result = await replaceFunction!.onRender({} as Context, {
88+
values: {
89+
regex: 'o',
90+
input: 'Hello World',
91+
replacement: 'a'
92+
},
93+
purpose: 'send',
94+
});
95+
expect(result).toBe('Hella World');
96+
});
97+
98+
it('should replace with capture groups', async () => {
99+
const result = await replaceFunction!.onRender({} as Context, {
100+
values: {
101+
regex: '(\\w+) (\\w+)',
102+
input: 'Hello World',
103+
replacement: '$2 $1'
104+
},
105+
purpose: 'send',
106+
});
107+
expect(result).toBe('World Hello');
108+
});
109+
110+
it('should replace with full match reference', async () => {
111+
const result = await replaceFunction!.onRender({} as Context, {
112+
values: {
113+
regex: 'World',
114+
input: 'Hello World',
115+
replacement: '[$&]'
116+
},
117+
purpose: 'send',
118+
});
119+
expect(result).toBe('Hello [World]');
120+
});
121+
122+
it('should respect flags parameter', async () => {
123+
const result = await replaceFunction!.onRender({} as Context, {
124+
values: {
125+
regex: 'hello',
126+
input: 'Hello World',
127+
replacement: 'Hi',
128+
flags: 'i'
129+
},
130+
purpose: 'send',
131+
});
132+
expect(result).toBe('Hi World');
133+
});
134+
135+
it('should handle empty replacement', async () => {
136+
const result = await replaceFunction!.onRender({} as Context, {
137+
values: {
138+
regex: 'World',
139+
input: 'Hello World',
140+
replacement: ''
141+
},
142+
purpose: 'send',
143+
});
144+
expect(result).toBe('Hello ');
145+
});
146+
147+
it('should return original input when no match', async () => {
148+
const result = await replaceFunction!.onRender({} as Context, {
149+
values: {
150+
regex: 'Goodbye',
151+
input: 'Hello World',
152+
replacement: 'Hi'
153+
},
154+
purpose: 'send',
155+
});
156+
expect(result).toBe('Hello World');
157+
});
158+
159+
it('should return empty string when regex is empty', async () => {
160+
const result = await replaceFunction!.onRender({} as Context, {
161+
values: {
162+
regex: '',
163+
input: 'Hello World',
164+
replacement: 'Hi'
165+
},
166+
purpose: 'send',
167+
});
168+
expect(result).toBe('');
169+
});
170+
171+
it('should return empty string when input is empty', async () => {
172+
const result = await replaceFunction!.onRender({} as Context, {
173+
values: {
174+
regex: 'Hello',
175+
input: '',
176+
replacement: 'Hi'
177+
},
178+
purpose: 'send',
179+
});
180+
expect(result).toBe('');
181+
});
182+
183+
it('should throw on invalid regex', async () => {
184+
const fn = replaceFunction!.onRender({} as Context, {
185+
values: {
186+
regex: '[',
187+
input: 'Hello World',
188+
replacement: 'Hi'
189+
},
190+
purpose: 'send',
191+
});
192+
await expect(fn).rejects.toThrow('Invalid regular expression: /[/: Unterminated character class');
193+
});
194+
});

src-web/lib/resolvedModelName.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function resolvedModelName(r: AnyModel | null): string {
3333
}
3434

3535
// Strip unnecessary protocol
36-
const withoutProto = withoutVariables.replace(/^https?:\/\//, '');
36+
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, '');
3737

3838
return withoutProto;
3939
}

0 commit comments

Comments
 (0)