From 1b90842d3011b47f50709ea7f23d8941646ce71e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 23 Jul 2025 13:33:58 -0700 Subject: [PATCH] Regex template function --- plugins/template-function-regex/src/index.ts | 74 +++++-- .../tests/regex.test.ts | 194 ++++++++++++++++++ src-web/lib/resolvedModelName.ts | 2 +- 3 files changed, 253 insertions(+), 17 deletions(-) create mode 100644 plugins/template-function-regex/tests/regex.test.ts diff --git a/plugins/template-function-regex/src/index.ts b/plugins/template-function-regex/src/index.ts index 5f30f1b3..905b5703 100644 --- a/plugins/template-function-regex/src/index.ts +++ b/plugins/template-function-regex/src/index.ts @@ -1,32 +1,74 @@ +import type { TemplateFunctionArg } from '@yaakapp-internal/plugins'; import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; +const inputArg: TemplateFunctionArg = { + type: 'text', + name: 'input', + label: 'Input Text', + multiLine: true, +}; + +const regexArg: TemplateFunctionArg = { + type: 'text', + name: 'regex', + label: 'Regular Expression', + placeholder: '\\w+', + defaultValue: '.*', + description: + 'A JavaScript regular expression. Use a capture group to reference parts of the match in the replacement.', +}; + export const plugin: PluginDefinition = { templateFunctions: [ { name: 'regex.match', - description: 'Extract', - args: [ - { - type: 'text', - name: 'regex', - label: 'Regular Expression', - placeholder: '^\\w+=(?\\w*)$', - defaultValue: '^(.*)$', - description: - 'A JavaScript regular expression, evaluated using the Node.js RegExp engine. Capture groups or named groups can be used to extract values.', - }, - { type: 'text', name: 'input', label: 'Input Text', multiLine: true }, - ], + description: 'Extract text using a regular expression', + args: [inputArg, regexArg], async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise { - if (!args.values.regex || !args.values.input) return ''; + const input = String(args.values.input ?? ''); + const regex = new RegExp(String(args.values.regex ?? '')); - const input = String(args.values.input); - const regex = new RegExp(String(args.values.regex)); const match = input.match(regex); return match?.groups ? (Object.values(match.groups)[0] ?? '') : (match?.[1] ?? match?.[0] ?? ''); }, }, + { + name: 'regex.replace', + description: 'Replace text using a regular expression', + args: [ + inputArg, + regexArg, + { + type: 'text', + name: 'replacement', + label: 'Replacement Text', + placeholder: 'hello $1', + description: + 'The replacement text. Use $1, $2, ... to reference capture groups or $& to reference the entire match.', + }, + { + type: 'text', + name: 'flags', + label: 'Flags', + placeholder: 'g', + defaultValue: 'g', + optional: true, + description: + 'Regular expression flags (g for global, i for case-insensitive, m for multiline, etc.)', + }, + ], + async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise { + const input = String(args.values.input ?? ''); + const replacement = String(args.values.replacement ?? ''); + const flags = String(args.values.flags || ''); + const regex = String(args.values.regex); + + if (!regex) return ''; + + return input.replace(new RegExp(String(args.values.regex), flags), replacement); + }, + }, ], }; diff --git a/plugins/template-function-regex/tests/regex.test.ts b/plugins/template-function-regex/tests/regex.test.ts new file mode 100644 index 00000000..34709e88 --- /dev/null +++ b/plugins/template-function-regex/tests/regex.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import type { Context } from '@yaakapp/api'; +import { plugin } from '../src'; + +describe('regex.match', () => { + const matchFunction = plugin.templateFunctions!.find(f => f.name === 'regex.match'); + + it('should exist', () => { + expect(matchFunction).toBeDefined(); + }); + + it('should extract first capture group', async () => { + const result = await matchFunction!.onRender({} as Context, { + values: { + regex: 'Hello (\\w+)', + input: 'Hello World', + }, + purpose: 'send', + }); + expect(result).toBe('World'); + }); + + it('should extract named capture group', async () => { + const result = await matchFunction!.onRender({} as Context, { + values: { + regex: 'Hello (?\\w+)', + input: 'Hello World', + }, + purpose: 'send', + }); + expect(result).toBe('World'); + }); + + it('should return full match when no capture groups', async () => { + const result = await matchFunction!.onRender({} as Context, { + values: { + regex: 'Hello \\w+', + input: 'Hello World' + }, + purpose: 'send', + }); + expect(result).toBe('Hello World'); + }); + + it('should return empty string when no match', async () => { + const result = await matchFunction!.onRender({} as Context, { + values: { + regex: 'Goodbye', + input: 'Hello World' + }, + purpose: 'send', + }); + expect(result).toBe(''); + }); + + it('should return empty string when regex is empty', async () => { + const result = await matchFunction!.onRender({} as Context, { + values: { + regex: '', + input: 'Hello World' + }, + purpose: 'send', + }); + expect(result).toBe(''); + }); + + it('should return empty string when input is empty', async () => { + const result = await matchFunction!.onRender({} as Context, { + values: { + regex: 'Hello', + input: '' + }, + purpose: 'send', + }); + expect(result).toBe(''); + }); +}); + +describe('regex.replace', () => { + const replaceFunction = plugin.templateFunctions!.find(f => f.name === 'regex.replace'); + + it('should exist', () => { + expect(replaceFunction).toBeDefined(); + }); + + it('should replace one occurrence by default', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: 'o', + input: 'Hello World', + replacement: 'a' + }, + purpose: 'send', + }); + expect(result).toBe('Hella World'); + }); + + it('should replace with capture groups', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: '(\\w+) (\\w+)', + input: 'Hello World', + replacement: '$2 $1' + }, + purpose: 'send', + }); + expect(result).toBe('World Hello'); + }); + + it('should replace with full match reference', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: 'World', + input: 'Hello World', + replacement: '[$&]' + }, + purpose: 'send', + }); + expect(result).toBe('Hello [World]'); + }); + + it('should respect flags parameter', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: 'hello', + input: 'Hello World', + replacement: 'Hi', + flags: 'i' + }, + purpose: 'send', + }); + expect(result).toBe('Hi World'); + }); + + it('should handle empty replacement', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: 'World', + input: 'Hello World', + replacement: '' + }, + purpose: 'send', + }); + expect(result).toBe('Hello '); + }); + + it('should return original input when no match', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: 'Goodbye', + input: 'Hello World', + replacement: 'Hi' + }, + purpose: 'send', + }); + expect(result).toBe('Hello World'); + }); + + it('should return empty string when regex is empty', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: '', + input: 'Hello World', + replacement: 'Hi' + }, + purpose: 'send', + }); + expect(result).toBe(''); + }); + + it('should return empty string when input is empty', async () => { + const result = await replaceFunction!.onRender({} as Context, { + values: { + regex: 'Hello', + input: '', + replacement: 'Hi' + }, + purpose: 'send', + }); + expect(result).toBe(''); + }); + + it('should throw on invalid regex', async () => { + const fn = replaceFunction!.onRender({} as Context, { + values: { + regex: '[', + input: 'Hello World', + replacement: 'Hi' + }, + purpose: 'send', + }); + await expect(fn).rejects.toThrow('Invalid regular expression: /[/: Unterminated character class'); + }); +}); diff --git a/src-web/lib/resolvedModelName.ts b/src-web/lib/resolvedModelName.ts index 9bdd11f6..80c36186 100644 --- a/src-web/lib/resolvedModelName.ts +++ b/src-web/lib/resolvedModelName.ts @@ -33,7 +33,7 @@ export function resolvedModelName(r: AnyModel | null): string { } // Strip unnecessary protocol - const withoutProto = withoutVariables.replace(/^https?:\/\//, ''); + const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, ''); return withoutProto; }