diff --git a/packages/shared/src/routine-variables.test.ts b/packages/shared/src/routine-variables.test.ts index a129ee48..15d63a65 100644 --- a/packages/shared/src/routine-variables.test.ts +++ b/packages/shared/src/routine-variables.test.ts @@ -76,6 +76,31 @@ describe("routine variable helpers", () => { ]); }); + it("extracts snake_case variable names", () => { + expect(extractRoutineVariableNames("Open {{pr_url}} for review")).toEqual(["pr_url"]); + }); + + it("extracts variable names whose underscores were markdown-escaped by a WYSIWYG editor", () => { + // MDXEditor / mdast-util-to-markdown defensively escape intraword underscores + // when serializing rich-text back to markdown, so `{{pr_url}}` is stored as `{{pr\_url}}`. + expect(extractRoutineVariableNames("Open {{pr\\_url}} for review")).toEqual(["pr_url"]); + expect(extractRoutineVariableNames("{{pr\\_url\\_v2}}")).toEqual(["pr_url_v2"]); + }); + + it("syncRoutineVariablesWithTemplate handles markdown-escaped underscores", () => { + expect( + syncRoutineVariablesWithTemplate("Open {{pr\\_url}}", []), + ).toEqual([ + { name: "pr_url", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ]); + }); + + it("interpolates variables that appear with markdown-escaped underscores", () => { + expect( + interpolateRoutineTemplate("Open {{pr\\_url}}", { pr_url: "https://example.com" }), + ).toBe("Open https://example.com"); + }); + it("interpolates built-in variables alongside user variables", () => { const builtins = getBuiltinRoutineVariableValues(); const allVars = { ...builtins, repo: "paperclip" }; diff --git a/packages/shared/src/routine-variables.ts b/packages/shared/src/routine-variables.ts index b83148d4..84257188 100644 --- a/packages/shared/src/routine-variables.ts +++ b/packages/shared/src/routine-variables.ts @@ -1,6 +1,14 @@ import type { RoutineVariable } from "./types/routine.js"; -const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g; +// Tolerate markdown-escaped underscores (`\_`) inside placeholders. WYSIWYG markdown +// editors (e.g. MDXEditor) serialize `_` between word chars as `\_` to prevent +// reparse-as-emphasis, so a user-typed `{{pr_url}}` is stored as `{{pr\_url}}`. +const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z](?:\\_|[A-Za-z0-9_])*)\s*\}\}/g; + +function unescapeRoutineVariableName(raw: string): string { + return raw.replace(/\\_/g, "_"); +} + type RoutineTemplateInput = string | null | undefined | Array; /** @@ -50,7 +58,7 @@ export function extractRoutineVariableNames(template: RoutineTemplateInput): str const found = new Set(); for (const source of normalizeRoutineTemplateInput(template)) { for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) { - const name = match[1]; + const name = match[1] ? unescapeRoutineVariableName(match[1]) : ""; if (name && !found.has(name)) { found.add(name); } @@ -97,7 +105,8 @@ export function interpolateRoutineTemplate( if (template == null) return null; if (!values || Object.keys(values).length === 0) return template; return template.replace(ROUTINE_VARIABLE_MATCHER, (match, rawName: string) => { - if (!(rawName in values)) return match; - return stringifyRoutineVariableValue(values[rawName]); + const name = unescapeRoutineVariableName(rawName); + if (!(name in values)) return match; + return stringifyRoutineVariableValue(values[name]); }); }