diff --git a/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx b/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx
index a2ac5ba4..2d5d1757 100644
--- a/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx
+++ b/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx
@@ -17,6 +17,7 @@ import { AutoResizingTextarea } from "../AutoResizingTextarea/AutoResizingTextar
import { Button } from "../ui/button";
import { AxiosError } from "axios";
import { toast } from "../ui/use-toast";
+import { TSON } from "@/util/tson";
import { cn } from "@/util/utils";
type Props = {
@@ -36,42 +37,13 @@ function WorkflowDataSchemaInputGroup({
const [generateWithAIActive, setGenerateWithAIActive] = useState(false);
const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState("");
- function computeJsonError(
- jsonText: string,
- ): { message: string; line?: number; column?: number } | null {
- try {
- JSON.parse(jsonText);
- return null;
- } catch (e) {
- const message = e instanceof Error ? e.message : "Invalid JSON";
- // Try to extract position and compute line/column for friendlier feedback
- const match = message.match(/position\s+(\d+)/i);
- if (!match) {
- return { message };
- }
- const pos = Number(match[1]);
- if (Number.isNaN(pos)) {
- return { message };
- }
- let line = 1;
- let col = 1;
- for (let i = 0; i < Math.min(pos, jsonText.length); i++) {
- if (jsonText[i] === "\n") {
- line += 1;
- col = 1;
- } else {
- col += 1;
- }
- }
- return { message, line, column: col };
- }
- }
-
- const jsonError = useMemo(() => {
+ const tsonResult = useMemo(() => {
if (value === "null") return null;
- return computeJsonError(value);
+ return TSON.parse(value);
}, [value]);
+ console.log({ tsonResult });
+
const getDataSchemaSuggestionMutation = useMutation({
mutationFn: async () => {
const client = await getClient(credentialGetter);
@@ -161,7 +133,9 @@ function WorkflowDataSchemaInputGroup({
- {jsonError && (
-
- {jsonError.line && jsonError.column
- ? `Invalid JSON (${jsonError.line}:${jsonError.column}) — ${jsonError.message}`
- : `Invalid JSON — ${jsonError.message}`}
-
+ {tsonResult !== null && !tsonResult.success && tsonResult.error && (
+ {tsonResult.error}
)}
)}
diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts
index d1bf316a..38ae1a0d 100644
--- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts
+++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts
@@ -2,6 +2,9 @@ import Dagre from "@dagrejs/dagre";
import type { Node } from "@xyflow/react";
import { Edge } from "@xyflow/react";
import { nanoid } from "nanoid";
+
+import { TSON } from "@/util/tson";
+
import {
WorkflowBlockType,
WorkflowBlockTypes,
@@ -2188,10 +2191,12 @@ function getWorkflowErrors(nodes: Array): Array {
}
// Validate Task data schema JSON when enabled (value different from "null")
if (node.data.dataSchema && node.data.dataSchema !== "null") {
- try {
- JSON.parse(node.data.dataSchema);
- } catch {
- errors.push(`${node.data.label}: Data schema is not valid JSON.`);
+ const result = TSON.parse(node.data.dataSchema);
+
+ if (!result.success) {
+ errors.push(
+ `${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
+ );
}
}
});
@@ -2227,10 +2232,12 @@ function getWorkflowErrors(nodes: Array): Array {
}
// Validate Extraction data schema JSON when enabled (value different from "null")
if (node.data.dataSchema && node.data.dataSchema !== "null") {
- try {
- JSON.parse(node.data.dataSchema);
- } catch {
- errors.push(`${node.data.label}: Data schema is not valid JSON.`);
+ const result = TSON.parse(node.data.dataSchema);
+
+ if (!result.success) {
+ errors.push(
+ `${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
+ );
}
}
});
diff --git a/skyvern-frontend/src/util/tson.test.ts b/skyvern-frontend/src/util/tson.test.ts
new file mode 100644
index 00000000..adc92949
--- /dev/null
+++ b/skyvern-frontend/src/util/tson.test.ts
@@ -0,0 +1,91 @@
+// TSON.parse.test.ts
+import { TSON } from "./tson";
+import { describe, test, expect } from "vitest";
+
+describe("TSON.parse", () => {
+ test("single top-level template works", () => {
+ const input = "{{ hello }}";
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual("");
+ });
+
+ test("preserves double braces inside quoted strings", () => {
+ const input = '{"a": "{{ hello }}"}';
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ a: "{{ hello }}" });
+ });
+
+ test("replaces double braces outside strings with stub", () => {
+ const input = '{"a": {{ hello }} }';
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ a: "" });
+ });
+
+ test("handles double braces in keys and values", () => {
+ const input = `
+{
+ "hello": "world",
+ {{foo}}: "bar",
+ "baz": {{quux}}
+}`;
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ hello: "world",
+ "": "bar",
+ baz: "",
+ });
+ });
+
+ test("does not allow trailing commas", () => {
+ const input = `
+{
+ "hello": "world",
+ {{foo}}: "bar",
+ "baz": {{quux}},
+}`;
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Expected double-quoted property name");
+ });
+
+ test("detects unclosed double braces", () => {
+ const input = "{{ unclosed";
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Unclosed");
+ });
+
+ test("detects unmatched closing double braces", () => {
+ const input = "closed }}";
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Unmatched");
+ });
+
+ test("handles nested double braces", () => {
+ const input = "{{ {{ nested }} }}";
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual("");
+ });
+
+ test("handles double braces in arrays", () => {
+ const input = '[{{ }}, {{ }}, "normal"]';
+ const result = TSON.parse(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual(["", "", "normal"]);
+ });
+});
diff --git a/skyvern-frontend/src/util/tson.ts b/skyvern-frontend/src/util/tson.ts
new file mode 100644
index 00000000..dc01254a
--- /dev/null
+++ b/skyvern-frontend/src/util/tson.ts
@@ -0,0 +1,223 @@
+type JSONValue =
+ | string
+ | number
+ | boolean
+ | null
+ | JSONValue[]
+ | { [key: string]: JSONValue };
+
+interface ParseResult {
+ success: boolean;
+ data?: JSONValue;
+ error?: string;
+}
+
+const placeholder = () => "";
+
+/**
+ * TSON ("templated JSON") is a superset of JSON, where double curly braces {{...}} can:
+ * - exist anywhere outside of string literals, and
+ * - are treated as placeholders
+ */
+const TSON = {
+ parse(input: string): ParseResult {
+ try {
+ const balanceCheck = checkDoubleBraceBalance(input);
+
+ if (!balanceCheck.balanced) {
+ return {
+ success: false,
+ error: balanceCheck.error,
+ };
+ }
+
+ const pipeline = [
+ replaceBracesOutsideQuotes,
+ // removeTrailingCommas,
+ JSON.parse,
+ ];
+
+ const parsed = pipeline.reduce((acc, fn) => fn(acc), input) as JSONValue;
+
+ return {
+ success: true,
+ data: parsed,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ },
+};
+
+function checkDoubleBraceBalance(input: string): {
+ balanced: boolean;
+ error?: string;
+} {
+ let inString = false;
+ let escapeNext = false;
+ let depth = 0;
+
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+ const nextChar = input[i + 1];
+
+ // handle escape sequences
+ if (escapeNext) {
+ escapeNext = false;
+ continue;
+ }
+
+ if (char === "\\") {
+ escapeNext = true;
+ continue;
+ }
+
+ // inside-string tracking
+ if (char === '"') {
+ inString = !inString;
+ continue;
+ }
+
+ // double braces counts (only outside strings)
+ if (!inString) {
+ if (char === "{" && nextChar === "{") {
+ depth++;
+ i++; // skip next char
+ } else if (char === "}" && nextChar === "}") {
+ depth--;
+ if (depth < 0) {
+ return {
+ balanced: false,
+ error: `Unmatched closing }} at position ${i}`,
+ };
+ }
+ i++; // skip next char
+ }
+ }
+ }
+
+ if (depth > 0) {
+ return {
+ balanced: false,
+ error: `Unclosed {{ - missing ${depth} closing }}`,
+ };
+ }
+
+ return { balanced: true };
+}
+
+function replaceBracesOutsideQuotes(input: string): string {
+ let result = "";
+ let inString = false;
+ let escapeNext = false;
+ let inDoubleBrace = 0; // track nesting depth of {{...}}
+
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+ const nextChar = input[i + 1];
+
+ // escape sequences
+ if (escapeNext) {
+ if (inDoubleBrace === 0) {
+ result += char;
+ }
+ escapeNext = false;
+ continue;
+ }
+
+ if (char === "\\") {
+ if (inDoubleBrace === 0) {
+ result += char;
+ }
+ escapeNext = true;
+ continue;
+ }
+
+ // inside-string tracking
+ if (char === '"') {
+ inString = !inString;
+ if (inDoubleBrace === 0) {
+ result += char;
+ }
+ continue;
+ }
+
+ // double braces (only outside strings)
+ if (!inString) {
+ if (char === "{" && nextChar === "{") {
+ if (inDoubleBrace === 0) {
+ result += `"${placeholder()}"`;
+ }
+ inDoubleBrace++;
+ i++; // skip next char
+ continue;
+ } else if (char === "}" && nextChar === "}") {
+ inDoubleBrace--;
+ i++; // skip next char
+ continue;
+ }
+ }
+
+ // add characters when we're not inside double braces
+ if (inDoubleBrace === 0) {
+ result += char;
+ }
+ }
+
+ return result;
+}
+
+export function removeTrailingCommas(input: string): string {
+ let result = "";
+ let inString = false;
+ let escapeNext = false;
+
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i];
+
+ // escape sequences
+ if (escapeNext) {
+ result += char;
+ escapeNext = false;
+ continue;
+ }
+
+ if (char === "\\") {
+ result += char;
+ escapeNext = true;
+ continue;
+ }
+
+ // inside-string tracking
+ if (char === '"') {
+ inString = !inString;
+ result += char;
+ continue;
+ }
+
+ // check for trailing commas (outside strings)
+ if (!inString && char === ",") {
+ // look-ahead for the next non-whitespace character
+ let j = i + 1;
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ while (j < input.length && /\s/.test(input[j])) {
+ j++;
+ }
+
+ // if next non-whitespace is } or ], skip the comma
+ if (j < input.length && (input[j] === "}" || input[j] === "]")) {
+ continue; // Skip this comma
+ }
+ }
+
+ result += char;
+ }
+
+ return result;
+}
+
+export { TSON };