From 3fde792c53254a11fc22b080d03ceb7c54c0b041 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:44:28 +0000 Subject: [PATCH] feat(expr): client-side TemplateEngine port with parity tests --- frontend/src/expr/templateEngine.test.ts | 75 +++++++++++++ frontend/src/expr/templateEngine.ts | 131 +++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 frontend/src/expr/templateEngine.test.ts create mode 100644 frontend/src/expr/templateEngine.ts diff --git a/frontend/src/expr/templateEngine.test.ts b/frontend/src/expr/templateEngine.test.ts new file mode 100644 index 0000000..7cae53a --- /dev/null +++ b/frontend/src/expr/templateEngine.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { render } from "./templateEngine"; + +const J = (json: string) => JSON.parse(json); + +describe("templateEngine.render — parity with VolcanicCore.Core.TemplateEngine", () => { + it("no placeholders returns the template unchanged", () => { + expect(render("hello world", J(`{"x":1}`))).toBe("hello world"); + }); + + it("whole input renders as compact json", () => { + expect(render("got: {input}", J(`{"a":1,"b":"x"}`))).toBe(`got: {"a":1,"b":"x"}`); + }); + + it("top-level string field renders without json quotes", () => { + expect(render("value: {input.test}", J(`{"test":"hello"}`))).toBe("value: hello"); + }); + + it("nested path lookup", () => { + expect(render("{input.a.b.c}", J(`{"a":{"b":{"c":42}}}`))).toBe("42"); + }); + + it("array index lookup", () => { + expect(render("{input.tags[1]}", J(`{"tags":["x","y","z"]}`))).toBe("y"); + }); + + it("missing property renders as empty string", () => { + expect(render("value: {input.missing}", J(`{"other":1}`))).toBe("value: "); + }); + + it("missing nested path renders as empty string", () => { + expect(render("[{input.a.b.c}]", J(`{"a":{"b":{}}}`))).toBe("[]"); + }); + + it("array index out of range renders as empty string", () => { + expect(render("<{input.tags[9]}>", J(`{"tags":["only"]}`))).toBe("<>"); + }); + + it("numeric and boolean and null leaves render as their string form", () => { + expect(render("{input.n}|{input.f}|{input.b}|{input.z}", J(`{"n":42,"f":1.5,"b":true,"z":null}`))) + .toBe("42|1.5|true|"); + }); + + it("object and array leaves render as compact json", () => { + expect(render("{input.o}/{input.a}", J(`{"o":{"k":1},"a":[1,2]}`))).toBe(`{"k":1}/[1,2]`); + }); + + it("doubled braces render as literal single braces", () => { + expect(render("{{not a template}}", J(`{}`))).toBe("{not a template}"); + }); + + it("doubled braces around a real placeholder", () => { + expect(render("{{{input.x}}}", J(`{"x":"hello"}`))).toBe("{hello}"); + }); + + it("null input renders placeholders as empty string", () => { + expect(render("hi {input.name}", null)).toBe("hi "); + }); + + it("whole input placeholder with null input renders empty", () => { + expect(render("{input}", null)).toBe(""); + }); + + it("unrecognised placeholder is left as literal text", () => { + expect(render("{node.foo}", J(`{"foo":1}`))).toBe("{node.foo}"); + }); + + it("unclosed brace is left as literal text", () => { + expect(render("start {input.x and more", J(`{"x":1}`))).toBe("start {input.x and more"); + }); + + it("bracketed string key lookup", () => { + expect(render(`{input["weird.key"]}`, J(`{"weird.key":"v"}`))).toBe("v"); + }); +}); diff --git a/frontend/src/expr/templateEngine.ts b/frontend/src/expr/templateEngine.ts new file mode 100644 index 0000000..262b138 --- /dev/null +++ b/frontend/src/expr/templateEngine.ts @@ -0,0 +1,131 @@ +// Client-side port of VolcanicCore.Core.TemplateEngine.Render. MUST stay +// byte-exact with the backend — the dialog preview shows the user what the +// executor will produce. Divergence is a bug. Backend tests live in +// tests/VolcanicCore.Tests/TemplateEngineTests.cs; add a matching case here +// when adding one there. + +export function render(template: string, input: unknown): string { + if (!template || template.indexOf("{") < 0) return template; + + let out = ""; + let i = 0; + while (i < template.length) { + const c = template[i]; + + if (c === "{") { + if (template[i + 1] === "{") { + out += "{"; + i += 2; + continue; + } + const end = template.indexOf("}", i + 1); + if (end < 0) { + out += template.slice(i); + break; + } + const expr = template.slice(i + 1, end); + const evald = tryEvaluate(expr, input); + if (evald === undefined) { + out += "{" + expr + "}"; + } else { + out += evald; + } + i = end + 1; + continue; + } + + if (c === "}" && template[i + 1] === "}") { + out += "}"; + i += 2; + continue; + } + + out += c; + i++; + } + return out; +} + +// Returns undefined if the expression is not one we own (so the caller +// passes it through verbatim), or a string (possibly empty) if it is. +function tryEvaluate(expr: string, input: unknown): string | undefined { + expr = expr.trim(); + if (!expr.startsWith("input")) return undefined; + + let rest = expr.slice(5); + + // input is null/undefined → any well-formed `input`-rooted expression renders empty + if (input === null || input === undefined) { + if (rest.length === 0) return ""; + return isWellFormedTail(rest) ? "" : undefined; + } + + let walker: unknown = input; + let have = true; + while (rest.length > 0) { + if (rest[0] === ".") { + let j = 1; + while (j < rest.length && isIdentChar(rest[j])) j++; + if (j === 1) return undefined; + const name = rest.slice(1, j); + if (have && isPlainObject(walker) && name in walker) walker = walker[name]; + else have = false; + rest = rest.slice(j); + continue; + } + if (rest[0] === "[") { + const close = rest.indexOf("]"); + if (close < 0) return undefined; + const inside = rest.slice(1, close).trim(); + if (inside.length >= 2 && inside[0] === `"` && inside[inside.length - 1] === `"`) { + const key = inside.slice(1, -1); + if (have && isPlainObject(walker) && key in walker) walker = walker[key]; + else have = false; + } else if (/^-?\d+$/.test(inside)) { + const idx = Number(inside); + if (have && Array.isArray(walker) && idx >= 0 && idx < walker.length) walker = walker[idx]; + else have = false; + } else { + return undefined; + } + rest = rest.slice(close + 1); + continue; + } + return undefined; + } + return have ? renderValue(walker) : ""; +} + +function isWellFormedTail(tail: string): boolean { + let i = 0; + while (i < tail.length) { + if (tail[i] === ".") { + let j = i + 1; + while (j < tail.length && isIdentChar(tail[j])) j++; + if (j === i + 1) return false; + i = j; + } else if (tail[i] === "[") { + const close = tail.slice(i).indexOf("]"); + if (close < 0) return false; + i += close + 1; + } else { + return false; + } + } + return true; +} + +function renderValue(v: unknown): string { + if (v === null || v === undefined) return ""; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return JSON.stringify(v); +} + +function isIdentChar(c: string): boolean { + return /[A-Za-z0-9_]/.test(c); +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +}