From cef1129d4b6bb06b354139c5c329cfad7d00a307 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Viet <123613986+NgoQuocViet2001@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:14:14 +0700 Subject: [PATCH] fix(template-function-json): escape control characters in json.escape (#607) --- plugins/template-function-json/package.json | 3 +- plugins/template-function-json/src/index.ts | 6 ++- .../tests/escape.test.ts | 50 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 plugins/template-function-json/tests/escape.test.ts diff --git a/plugins/template-function-json/package.json b/plugins/template-function-json/package.json index 7b8e8847..ef0242f2 100755 --- a/plugins/template-function-json/package.json +++ b/plugins/template-function-json/package.json @@ -8,7 +8,8 @@ "types": "src/index.ts", "scripts": { "build": "yaakcli build", - "dev": "yaakcli dev" + "dev": "yaakcli dev", + "test": "vp test --run tests" }, "dependencies": { "jsonpath-plus": "^10.3.0" diff --git a/plugins/template-function-json/src/index.ts b/plugins/template-function-json/src/index.ts index 445ee502..f978d14e 100755 --- a/plugins/template-function-json/src/index.ts +++ b/plugins/template-function-json/src/index.ts @@ -85,7 +85,11 @@ export const plugin: PluginDefinition = { ], async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise { const input = String(args.values.input ?? ""); - return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + // JSON.stringify produces a spec-correct string literal: it escapes + // the backslash and quote this used to handle, and also the control + // characters it did not. Slicing off the surrounding quotes leaves + // the escaped inner text this function is meant to emit. + return JSON.stringify(input).slice(1, -1); }, }, { diff --git a/plugins/template-function-json/tests/escape.test.ts b/plugins/template-function-json/tests/escape.test.ts new file mode 100644 index 00000000..65d99ba9 --- /dev/null +++ b/plugins/template-function-json/tests/escape.test.ts @@ -0,0 +1,50 @@ +import type { Context } from "@yaakapp/api"; +import { describe, expect, it } from "vite-plus/test"; +import { plugin } from "../src"; + +const LF = String.fromCharCode(10); +const TAB = String.fromCharCode(9); +const CR = String.fromCharCode(13); + +describe("json.escape", () => { + const escapeFunction = plugin.templateFunctions?.find((f) => f.name === "json.escape"); + + const escape = async (input: string) => + await escapeFunction!.onRender({} as Context, { values: { input } } as never); + + // The point of the function is that the result can be dropped between two + // quotes in a JSON document, so that is what these assert. + const embeds = (escaped: string | null) => { + JSON.parse(`{"k":"${escaped}"}`); + return JSON.parse(`{"k":"${escaped}"}`).k; + }; + + it("should exist", () => { + expect(escapeFunction).toBeTruthy(); + }); + + it("escapes a quote", async () => { + const input = `say "hi"`; + expect(embeds(await escape(input))).toBe(input); + }); + + it("escapes a backslash", async () => { + const input = `a${String.fromCharCode(92)}b`; + expect(embeds(await escape(input))).toBe(input); + }); + + it("escapes a newline", async () => { + const input = `line1${LF}line2`; + expect(embeds(await escape(input))).toBe(input); + }); + + it("escapes a tab and a carriage return", async () => { + const input = `a${TAB}b${CR}c`; + expect(embeds(await escape(input))).toBe(input); + }); + + it("round-trips a pretty-printed JSON document", async () => { + const input = JSON.stringify({ name: `he said "hi"`, items: [1, 2] }, null, 2); + expect(embeds(await escape(input))).toBe(input); + }); +});