feat(expr): client-side TemplateEngine port with parity tests
This commit is contained in:
75
frontend/src/expr/templateEngine.test.ts
Normal file
75
frontend/src/expr/templateEngine.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
131
frontend/src/expr/templateEngine.ts
Normal file
131
frontend/src/expr/templateEngine.ts
Normal file
@@ -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<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
Reference in New Issue
Block a user