diff --git a/backend/Dockerfile b/backend/Dockerfile index 8c133962..c8c7e99a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM golang:1.23-alpine AS builder # Instalar dependencias necesarias -RUN apk add --no-cache git openjdk17 +RUN apk add --no-cache git openjdk17 curl # Establecer directorio de trabajo WORKDIR /app/src @@ -21,8 +21,10 @@ RUN go mod init xslt-playground && \ # Build extension functions jar WORKDIR /app/ext COPY ext/ . -RUN javac com/xsltplayground/ext/CustomFunctions.java && \ - jar cf custom-functions.jar com/xsltplayground/ext/CustomFunctions.class +RUN mkdir -p /tmp/saxon && \ + curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \ + javac -cp /tmp/saxon/saxon-he.jar com/xsltplayground/ext/CustomFunctions.java com/xsltplayground/Runner.java && \ + jar cf custom-functions.jar com WORKDIR /app/src diff --git a/backend/ext/com/xsltplayground/Runner.java b/backend/ext/com/xsltplayground/Runner.java new file mode 100644 index 00000000..2f26b508 --- /dev/null +++ b/backend/ext/com/xsltplayground/Runner.java @@ -0,0 +1,200 @@ +package com.xsltplayground; + +import com.xsltplayground.ext.CustomFunctions; +import net.sf.saxon.s9api.*; + +import javax.xml.transform.Source; +import javax.xml.transform.stream.StreamSource; +import java.io.File; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.Map; + +public class Runner { + public static void main(String[] args) { + try { + Map rawParams = new LinkedHashMap<>(); + Map fileParams = new LinkedHashMap<>(); + String sourcePath = null; + String xslPath = null; + String outPath = null; + boolean trace = false; + boolean cliTrace = false; // -T flag (parsed but not used directly) + String traceOutPath = null; + + for (String a : args) { + if (a.startsWith("-s:")) { + sourcePath = a.substring(3); + } else if (a.startsWith("-xsl:")) { + xslPath = a.substring(5); + } else if (a.startsWith("-o:")) { + outPath = a.substring(3); + } else if (a.startsWith("+")) { + // +name=/path/to/file (file-based param, typically XML) + int eq = a.indexOf('='); + if (eq > 1) { + String name = a.substring(1, eq); + String path = a.substring(eq + 1); + fileParams.put(name, path); + } + } else if (a.equals("-trace")) { + trace = true; + } else if (a.equals("-T")) { + cliTrace = true; + } else if (a.startsWith("-traceout:")) { + traceOutPath = a.substring("-traceout:".length()); + } else if (a.contains("=")) { + int eq = a.indexOf('='); + String name = a.substring(0, eq); + String val = a.substring(eq + 1); + rawParams.put(name, val); + } + } + + if (xslPath == null || outPath == null) { + System.err.println("Missing -xsl: or -o: arguments"); + System.exit(2); + } + + Processor proc = new Processor(false); + CustomFunctions.registerAll(proc); + + XsltCompiler compiler = proc.newXsltCompiler(); + XsltExecutable exec; + if (trace) { + // Transform the stylesheet to inject xsl:message after each xsl:variable. + // Messages are written in a block form so backend can capture multiline XML: + // TRACE_VAR_START|name \n [serialized value or string value] \n TRACE_VAR_END + Processor iproc = new Processor(false); + XsltCompiler icomp = iproc.newXsltCompiler(); + String instrumenter = + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + // Instrument top-level variables + " " + + " " + + " " + + " " + + " " + + " TRACE_VAR_START|" + + " " + + " " + + " " + + " $" + + " " + + " TRACE_VAR_END" + + " " + + " " + + // Instrument non-top-level variables + " " + + " " + + " " + + " " + + " " + + " TRACE_VAR_START|" + + " " + + " " + + " " + + " $" + + " " + + " TRACE_VAR_END" + + " " + + " " + + ""; + + XsltExecutable instExec = icomp.compile(new StreamSource(new java.io.StringReader(instrumenter))); + XsltTransformer instTr = instExec.load(); + XdmNode styleDoc = iproc.newDocumentBuilder().build(new StreamSource(new File(xslPath))); + instTr.setInitialContextNode(styleDoc); + java.io.StringWriter sw = new java.io.StringWriter(); + Serializer out = iproc.newSerializer(sw); + instTr.setDestination(out); + instTr.transform(); + String instrumented = sw.toString(); + exec = compiler.compile(new StreamSource(new java.io.StringReader(instrumented))); + } else { + exec = compiler.compile(new StreamSource(new File(xslPath))); + } + XsltTransformer transformer = exec.load(); + + // If a trace output path is provided, tee System.err to that file so that + // xsl:message (and other diagnostics) also land in the trace file. + java.io.PrintStream originalErr = System.err; + java.io.FileOutputStream traceFos = null; + java.io.PrintStream tracePs = null; + java.io.PrintStream teeErr = null; + if (traceOutPath != null && !traceOutPath.isEmpty()) { + try { + traceFos = new java.io.FileOutputStream(new File(traceOutPath), true); + tracePs = new java.io.PrintStream(traceFos, true, "UTF-8"); + final java.io.PrintStream err1 = originalErr; + final java.io.PrintStream err2 = tracePs; + java.io.OutputStream tee = new java.io.OutputStream() { + @Override public void write(int b) throws java.io.IOException { err1.write(b); err2.write(b); } + @Override public void write(byte[] b) throws java.io.IOException { err1.write(b); err2.write(b); } + @Override public void write(byte[] b, int off, int len) throws java.io.IOException { err1.write(b, off, len); err2.write(b, off, len); } + @Override public void flush() throws java.io.IOException { err1.flush(); err2.flush(); } + @Override public void close() throws java.io.IOException { err2.close(); err1.flush(); } + }; + teeErr = new java.io.PrintStream(tee, true, "UTF-8"); + System.setErr(teeErr); + } catch (Exception e) { + // If we fail to set up the trace file, continue without it + e.printStackTrace(originalErr); + } + } + + if (sourcePath != null && !sourcePath.isEmpty()) { + Source src = new StreamSource(new File(sourcePath)); + XdmNode doc = proc.newDocumentBuilder().build(src); + transformer.setInitialContextNode(doc); + } + + // Parameters as strings + for (Map.Entry e : rawParams.entrySet()) { + transformer.setParameter(new QName(e.getKey()), new XdmAtomicValue(e.getValue())); + } + // File parameters (parse as XML if it looks like XML; else pass as string) + for (Map.Entry e : fileParams.entrySet()) { + String name = e.getKey(); + String path = e.getValue(); + String content = new String(Files.readAllBytes(new File(path).toPath())) + .trim(); + if (content.startsWith("<")) { + XdmNode node = proc.newDocumentBuilder().build(new StreamSource(new File(path))); + transformer.setParameter(new QName(name), node); + } else { + transformer.setParameter(new QName(name), new XdmAtomicValue(content)); + } + } + + Serializer ser = proc.newSerializer(new File(outPath)); + transformer.setDestination(ser); + try { + transformer.transform(); + } finally { + // Restore System.err and close the trace stream if used + if (teeErr != null) { + System.setErr(originalErr); + try { teeErr.flush(); } catch (Exception ignored) {} + } + if (tracePs != null) { + try { tracePs.flush(); } catch (Exception ignored) {} + try { tracePs.close(); } catch (Exception ignored) {} + } + if (traceFos != null) { + try { traceFos.close(); } catch (Exception ignored) {} + } + } + } catch (Exception e) { + e.printStackTrace(System.err); + System.exit(1); + } + } +} diff --git a/backend/ext/com/xsltplayground/ext/CustomFunctions.java b/backend/ext/com/xsltplayground/ext/CustomFunctions.java index af716f8c..202442ae 100644 --- a/backend/ext/com/xsltplayground/ext/CustomFunctions.java +++ b/backend/ext/com/xsltplayground/ext/CustomFunctions.java @@ -382,4 +382,233 @@ public class CustomFunctions { public static boolean xor(boolean a, boolean b) { return a ^ b; } + + // --------------------------------------------------------------------- + // Saxon HE integration + // --------------------------------------------------------------------- + // Saxon-HE doesn't support reflexive java: calls. To keep XSLT unchanged + // (e.g., xmlns:java="java:com.xsltplayground.ext.CustomFunctions" and + // java:uuid()), register integrated extension functions programmatically. + // Call CustomFunctions.registerAll(processor) once during initialization. + + public static void registerAll(net.sf.saxon.s9api.Processor processor) { + final String namespace = "java:" + CustomFunctions.class.getName(); + + java.lang.reflect.Method[] methods = CustomFunctions.class.getDeclaredMethods(); + for (java.lang.reflect.Method m : methods) { + int mods = m.getModifiers(); + if (!java.lang.reflect.Modifier.isStatic(mods) || !java.lang.reflect.Modifier.isPublic(mods)) { + continue; + } + if (m.getName().equals("registerAll")) { + continue; + } + + String camel = m.getName(); + String kebab = camelToKebab(camel); + + if (m.isVarArgs()) { + int fixed = m.getParameterCount() - 1; + int maxVarargs = 8; + for (int extra = 0; extra <= maxVarargs; extra++) { + int arity = fixed + (extra == 0 ? 1 : extra); + processor.registerExtensionFunction(new SaxonDynamicFunction(namespace, camel, m, arity)); + if (!kebab.equals(camel)) { + processor.registerExtensionFunction(new SaxonDynamicFunction(namespace, kebab, m, arity)); + } + } + } else { + int arity = m.getParameterCount(); + processor.registerExtensionFunction(new SaxonDynamicFunction(namespace, camel, m, arity)); + if (!kebab.equals(camel)) { + processor.registerExtensionFunction(new SaxonDynamicFunction(namespace, kebab, m, arity)); + } + } + } + + // Back-compat alias: support XSLT calls named "parse-dateTime" + // in addition to the canonical kebab-case "parse-date-time". + try { + java.lang.reflect.Method pdt = CustomFunctions.class.getDeclaredMethod("parseDateTime", String.class, String.class); + processor.registerExtensionFunction(new SaxonDynamicFunction(namespace, "parse-dateTime", pdt, 2)); + } catch (NoSuchMethodException ignore) { + } + } + + private static String camelToKebab(String s) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isUpperCase(c)) { + if (i > 0) sb.append('-'); + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static final class SaxonDynamicFunction implements net.sf.saxon.s9api.ExtensionFunction { + private final String namespace; + private final String localName; + private final java.lang.reflect.Method method; + private final int arity; + + SaxonDynamicFunction(String namespace, String localName, java.lang.reflect.Method method, int arity) { + this.namespace = namespace; + this.localName = localName; + this.method = method; + this.arity = arity; + } + + @Override + public net.sf.saxon.s9api.QName getName() { + return new net.sf.saxon.s9api.QName(namespace, localName); + } + + @Override + public net.sf.saxon.s9api.SequenceType[] getArgumentTypes() { + net.sf.saxon.s9api.SequenceType any = net.sf.saxon.s9api.SequenceType + .makeSequenceType(net.sf.saxon.s9api.ItemType.ANY_ITEM, + net.sf.saxon.s9api.OccurrenceIndicator.ZERO_OR_MORE); + net.sf.saxon.s9api.SequenceType[] arr = new net.sf.saxon.s9api.SequenceType[arity]; + for (int i = 0; i < arity; i++) arr[i] = any; + return arr; + } + + @Override + public net.sf.saxon.s9api.SequenceType getResultType() { + // Be permissive on result typing as well + return net.sf.saxon.s9api.SequenceType + .makeSequenceType(net.sf.saxon.s9api.ItemType.ANY_ITEM, + net.sf.saxon.s9api.OccurrenceIndicator.ZERO_OR_MORE); + } + + @Override + public net.sf.saxon.s9api.XdmValue call(net.sf.saxon.s9api.XdmValue[] arguments) throws net.sf.saxon.s9api.SaxonApiException { + try { + Object[] args = buildJavaArgs(arguments); + Object result = method.invoke(null, args); + return toXdmValue(result); + } catch (java.lang.reflect.InvocationTargetException ite) { + Throwable cause = ite.getTargetException() != null ? ite.getTargetException() : ite; + throw new net.sf.saxon.s9api.SaxonApiException("Error in extension function '" + method.getName() + "': " + cause.getMessage(), cause); + } catch (Exception e) { + throw new net.sf.saxon.s9api.SaxonApiException("Failed to invoke extension function '" + method.getName() + "'", e); + } + } + + private Object[] buildJavaArgs(net.sf.saxon.s9api.XdmValue[] arguments) throws Exception { + Class[] params = method.getParameterTypes(); + boolean isVarArgs = method.isVarArgs(); + + if (!isVarArgs) { + Object[] out = new Object[params.length]; + for (int i = 0; i < params.length; i++) { + out[i] = fromXdm(arguments[i], params[i]); + } + return out; + } else { + int fixed = params.length - 1; + Object[] out = new Object[params.length]; + for (int i = 0; i < fixed; i++) { + out[i] = fromXdm(arguments[i], params[i]); + } + // Collect remaining args into the varargs array + Class comp = params[params.length - 1].getComponentType(); + int varCount = Math.max(0, arguments.length - fixed); + Object varArray = java.lang.reflect.Array.newInstance(comp, varCount); + for (int i = 0; i < varCount; i++) { + Object v = fromXdm(arguments[fixed + i], comp); + java.lang.reflect.Array.set(varArray, i, v); + } + out[out.length - 1] = varArray; + return out; + } + } + + // Type mapping helpers removed; using ANY_SEQUENCE for resilience across versions. + + private static net.sf.saxon.s9api.XdmValue toXdmValue(Object result) { + if (result == null) return net.sf.saxon.s9api.XdmEmptySequence.getInstance(); + if (result instanceof net.sf.saxon.s9api.XdmValue) return (net.sf.saxon.s9api.XdmValue) result; + if (result instanceof String) return new net.sf.saxon.s9api.XdmAtomicValue((String) result); + if (result instanceof Integer) return new net.sf.saxon.s9api.XdmAtomicValue(((Integer) result).longValue()); + if (result instanceof Long) return new net.sf.saxon.s9api.XdmAtomicValue(((Long) result).longValue()); + if (result instanceof Double) return new net.sf.saxon.s9api.XdmAtomicValue(((Double) result).doubleValue()); + if (result instanceof Boolean) return new net.sf.saxon.s9api.XdmAtomicValue(((Boolean) result).booleanValue()); + if (result instanceof org.w3c.dom.Node) { + // Serialize DOM node to string for portability + try { + javax.xml.transform.TransformerFactory tf = javax.xml.transform.TransformerFactory.newInstance(); + javax.xml.transform.Transformer t = tf.newTransformer(); + java.io.StringWriter sw = new java.io.StringWriter(); + t.transform(new javax.xml.transform.dom.DOMSource((org.w3c.dom.Node) result), new javax.xml.transform.stream.StreamResult(sw)); + return new net.sf.saxon.s9api.XdmAtomicValue(sw.toString()); + } catch (Exception e) { + return new net.sf.saxon.s9api.XdmAtomicValue(""); + } + } + // Generic fallback to string + return new net.sf.saxon.s9api.XdmAtomicValue(String.valueOf(result)); + } + + private static Object fromXdm(net.sf.saxon.s9api.XdmValue value, Class target) throws Exception { + if (target == String.class) { + if (value == null || value.size() == 0) return ""; + for (net.sf.saxon.s9api.XdmItem it : value) { return it.getStringValue(); } + return ""; + } + if (target == int.class || target == Integer.class) { + if (value == null || value.size() == 0) return (target == Integer.class ? null : 0); + String s = ""; for (net.sf.saxon.s9api.XdmItem it : value) { s = it.getStringValue(); break; } + return Integer.parseInt(s); + } + if (target == long.class || target == Long.class) { + if (value == null || value.size() == 0) return (target == Long.class ? null : 0L); + String s = ""; for (net.sf.saxon.s9api.XdmItem it : value) { s = it.getStringValue(); break; } + return Long.parseLong(s); + } + if (target == double.class || target == Double.class) { + if (value == null || value.size() == 0) return (target == Double.class ? null : 0d); + String s = ""; for (net.sf.saxon.s9api.XdmItem it : value) { s = it.getStringValue(); break; } + return Double.parseDouble(s); + } + if (target == boolean.class || target == Boolean.class) { + if (value == null || value.size() == 0) return (target == Boolean.class ? null : false); + String s = ""; for (net.sf.saxon.s9api.XdmItem it : value) { s = it.getStringValue(); break; } + return Boolean.parseBoolean(s); + } + if (target == org.w3c.dom.Node.class) { + if (value == null || value.size() == 0) return null; + if (value instanceof net.sf.saxon.s9api.XdmNode) { + javax.xml.transform.Source src = ((net.sf.saxon.s9api.XdmNode) value).asSource(); + javax.xml.transform.TransformerFactory tf = javax.xml.transform.TransformerFactory.newInstance(); + javax.xml.transform.Transformer tr = tf.newTransformer(); + javax.xml.transform.dom.DOMResult res = new javax.xml.transform.dom.DOMResult(); + tr.transform(src, res); + return res.getNode(); + } + // Fallback: try to parse string as XML + String xml = ""; for (net.sf.saxon.s9api.XdmItem it : value) { xml = it.getStringValue(); break; } + javax.xml.parsers.DocumentBuilderFactory dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(new org.xml.sax.InputSource(new java.io.StringReader(xml))); + } + if (target.isArray() && target.getComponentType() == String.class) { + // Expect a sequence of strings in a single argument + if (value == null || value.size() == 0) return new String[0]; + java.util.List items = new java.util.ArrayList<>(); + for (net.sf.saxon.s9api.XdmItem it : value) { + items.add(it.getStringValue()); + } + return items.toArray(new String[0]); + } + // Default to string conversion + if (value == null || value.size() == 0) return null; + for (net.sf.saxon.s9api.XdmItem it : value) { return it.getStringValue(); } + return null; + } + } } diff --git a/backend/src/main.go b/backend/src/main.go index c4ca3de6..a2fa0587 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -28,11 +28,19 @@ type TransformRequest struct { XSLT string `json:"xslt"` Version string `json:"version"` Parameters map[string]string `json:"parameters"` + Trace bool `json:"trace"` } type TransformResponse struct { - Result string `json:"result"` - DurationMs int64 `json:"duration_ms"` + Result string `json:"result"` + DurationMs int64 `json:"duration_ms"` + Trace []TraceEntry `json:"trace,omitempty"` + TraceText string `json:"trace_text,omitempty"` +} + +type TraceEntry struct { + Name string `json:"name"` + Value string `json:"value"` } type AppConfig struct { @@ -184,12 +192,25 @@ func main() { cmdArgs = append(cmdArgs, "-cp", config.SaxonClasspath, - "net.sf.saxon.Transform", + "com.xsltplayground.Runner", "-s:"+inputPath, "-xsl:"+xsltPath, "-o:"+outputPath, ) + var tracePath string + if req.Trace { + // Enable Runner instrumentation and capture trace output + cmdArgs = append(cmdArgs, "-trace") + // Provide a trace output file to capture Saxon messages + tracePath = filepath.Join(tmpDir, "trace.log") + cmdArgs = append(cmdArgs, "-traceout:"+tracePath) + // Also enable Saxon CLI tracing flag for richer diagnostics + cmdArgs = append(cmdArgs, "-T") + } + + log.Printf("+++++++++++++ msg %s", strings.Join(cmdArgs, "\n")) + idx := 0 for k, v := range req.Parameters { paramFile := filepath.Join(tmpDir, fmt.Sprintf("param_%d", idx)) @@ -214,6 +235,8 @@ func main() { return } + log.Printf("+++++++++++++ msg %s", strings.Join(cmdArgs, "\n")) + cmd := exec.Command("java", "@"+argsPath) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -246,7 +269,56 @@ func main() { duration := time.Since(start).Milliseconds() log.Printf("transformation done in %dms", duration) - c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration}) + + var traceEntries []TraceEntry + var traceText string + if req.Trace { + // Load trace text from file (preferred) or stderr + if tracePath != "" { + if data, err := os.ReadFile(tracePath); err == nil { + traceText = string(data) + } + } + if traceText == "" { + traceText = stderr.String() + } + + // Parse block-based variable traces and legacy single-line ones + lines := strings.Split(traceText, "\n") + capturing := false + var currName string + var buf []string + for _, l := range lines { + if strings.HasPrefix(l, "TRACE_VAR_START|") { + capturing = true + currName = strings.TrimPrefix(l, "TRACE_VAR_START|") + buf = nil + continue + } + if strings.HasPrefix(l, "TRACE_VAR_END") { + if capturing { + value := strings.Join(buf, "\n") + traceEntries = append(traceEntries, TraceEntry{Name: currName, Value: value}) + } + capturing = false + currName = "" + buf = nil + continue + } + if capturing { + buf = append(buf, l) + continue + } + if strings.HasPrefix(l, "TRACE_VAR|") { + parts := strings.SplitN(l, "|", 3) + if len(parts) == 3 { + traceEntries = append(traceEntries, TraceEntry{Name: parts[1], Value: parts[2]}) + } + } + } + } + + c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText}) }) r.GET("/", func(c *gin.Context) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 33d3a552..3a66054b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,31 @@ import Buymeacoffee from "./BuyMeACoffee"; const PARAM_START = ""; const PARAM_END = ""; +function parseErrorLines(txt) { + if (!txt) return []; + const starts = []; + const regex = /(^|\r?\n)(Warning|Error)\b/g; // tokens at start or after newline + let m; + while ((m = regex.exec(txt)) !== null) { + const start = m.index + (m[1] ? m[1].length : 0); + starts.push(start); + } + if (starts.length === 0) { + return [txt.trim()].filter(Boolean); + } + const lines = []; + // Any leading text before the first token as its own entry + const leading = txt.slice(0, starts[0]).trim(); + if (leading) lines.push(leading); + for (let i = 0; i < starts.length; i++) { + const s = starts[i]; + const e = i + 1 < starts.length ? starts[i + 1] : txt.length; + const chunk = txt.slice(s, e).trim(); + if (chunk) lines.push(chunk); + } + return lines; +} + function stripParamBlock(text) { const start = text.indexOf(PARAM_START); const end = text.indexOf(PARAM_END); @@ -117,23 +142,33 @@ function defaultTab() { } export default function App() { - const [tabs, setTabs] = useState(() => { - if (goPro) { - try { - const stored = sessionStorage.getItem("tabs"); - if (stored) return JSON.parse(stored); - } catch {} - } - return [defaultTab()]; - }); - const [active, setActive] = useState(() => tabs[0].id); + // Load persisted workspace from localStorage (if present) + let initialTabs = [defaultTab()]; + try { + const stored = localStorage.getItem("tabs"); + if (stored) initialTabs = JSON.parse(stored); + } catch {} + let initialActive = initialTabs[0]?.id; + try { + const sAct = localStorage.getItem("active"); + if (sAct) initialActive = JSON.parse(sAct); + } catch {} + + const [tabs, setTabs] = useState(initialTabs); + const [active, setActive] = useState(initialActive); const [editorFocused, setEditorFocused] = useState(false); const [result, setResult] = useState(""); const [error, setError] = useState(""); + const [errorLines, setErrorLines] = useState([]); const [duration, setDuration] = useState(null); const [user, setUser] = useState(null); const [auth, setAuth] = useState(null); const resultEditorRef = useRef(null); + const [traceEnabled, setTraceEnabled] = useState(() => { + try { return JSON.parse(localStorage.getItem("traceEnabled") || "false"); } catch { return false; } + }); + const [traceEntries, setTraceEntries] = useState([]); + const [traceCollapsed, setTraceCollapsed] = useState(false); const backendBase = (env.VITE_BACKEND_URL || "").replace(/\/$/, ""); console.log("Using this URL as backendURL:", backendBase); @@ -143,12 +178,17 @@ export default function App() { ga4react.initialize().catch(err => console.error(err)); }, []); + // Persist workspace on change useEffect(() => { - if (goPro) { - sessionStorage.setItem("tabs", JSON.stringify(tabs)); - } - - }, [tabs]); + try { + localStorage.setItem("tabs", JSON.stringify(tabs)); + localStorage.setItem("active", JSON.stringify(active)); + } catch {} + }, [tabs, active]); + + useEffect(() => { + try { localStorage.setItem("traceEnabled", JSON.stringify(traceEnabled)); } catch {} + }, [traceEnabled]); useEffect(() => { if (!goPro) return; @@ -203,23 +243,44 @@ export default function App() { xslt: xsltText, version: ver, parameters: paramObj, + trace: traceEnabled, }), }); if (!res.ok) { - const txt = await res.text(); + let txt = ""; + try { + // Prefer JSON to decode escaped newlines (\n) + const j = await res.json(); + if (j && typeof j.error === "string") { + txt = j.error; + } else { + txt = JSON.stringify(j); + } + } catch { + // Fallback to raw text + txt = await res.text(); + } + const lines = parseErrorLines(txt || res.statusText || ""); setError(txt || res.statusText); + setErrorLines(lines); setDuration(null); setResult(""); + setTraceEntries([]); return; } const data = await res.json(); setResult(data.result); setDuration(data.duration_ms); setError(""); + setErrorLines([]); + setTraceEntries(traceEnabled ? (data.trace || []) : []); } catch (e) { - setError(String(e)); + const txt = String(e); + setError(txt); + setErrorLines(parseErrorLines(txt)); setResult(""); setDuration(null); + setTraceEntries([]); } }, 500); @@ -229,7 +290,7 @@ export default function App() { activeTab.version, activeTab.params, ); - }, [activeTab]); + }, [activeTab, traceEnabled]); useEffect(() => { syncParams(); @@ -339,6 +400,26 @@ export default function App() { xsltplayground.com
+ {goPro && auth && (
{user ? ( @@ -450,6 +531,10 @@ export default function App() { +
- e.preventDefault(), - onDrop: (e) => - handleDrop(e, (t) => +
+
+ e.preventDefault(), + onDrop: (e) => + handleDrop(e, (t) => + setTabs((tabs) => + tabs.map((tab) => + tab.id === active ? { ...tab, xslt: stripParamBlock(t), params: addParams(t,tab) } : tab, + ), + ), + ), + }} + value={editorFocused ? activeTab.xslt : injectParamBlock(activeTab.xslt, activeTab.params)} + onChange={(v) => setTabs((tabs) => tabs.map((tab) => - tab.id === active ? { ...tab, xslt: stripParamBlock(t), params: addParams(t,tab) } : tab, + tab.id === active ? { ...tab, xslt: stripParamBlock(v || ""), params: addParams(v,tab) } : tab, ), - ), - ), - }} - value={editorFocused ? activeTab.xslt : injectParamBlock(activeTab.xslt, activeTab.params)} - onChange={(v) => - setTabs((tabs) => - tabs.map((tab) => - tab.id === active ? { ...tab, xslt: stripParamBlock(v || ""), params: addParams(v,tab) } : tab, - ), - ) - } - onFocus={() => setEditorFocused(true)} - onBlur={() => { - setEditorFocused(false); - syncParams(); - }} - options={{ minimap: { enabled: false }, automaticLayout: true }} - /> + ) + } + onFocus={() => setEditorFocused(true)} + onBlur={() => { + setEditorFocused(false); + syncParams(); + }} + options={{ minimap: { enabled: false }, automaticLayout: true }} + /> +
+ {traceEnabled && ( +
+
+ + {!traceCollapsed && ( + + Trace Variables {traceEntries.length ? `(${traceEntries.length})` : ''} + + )} +
+ {!traceCollapsed && ( + + + {traceEntries.map((t, i) => ( + + + + + ))} + +
{t.name}{t.value}
+ )} +
+ )} +
{error ? ( -
{error}
+
+ {errorLines && errorLines.length > 0 ? ( + + + {errorLines.map((l, i) => ( + + + + + ))} + +
🚨{l}
+ ) : ( +
+ 🚨 + {error} +
+ )} +
) : ( duration !== null && (
Success in {duration} ms
diff --git a/frontend/src/style.css b/frontend/src/style.css index 07cb82a9..7a5418f8 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -59,6 +59,38 @@ body, background: #fee; color: #900; padding: 0.5rem; + border: 1px solid #f3c2c2; + border-left: 4px solid #d00; + max-height: 30vh; /* limit height */ + overflow: auto; /* enable scroll when needed */ +} + +/* Tabular error list */ +.error-table { + width: 100%; + border-collapse: collapse; +} + +.error-row { + vertical-align: top; +} + +.error-icon { + width: 1.75rem; + text-align: center; + padding-right: 0.25rem; + color: #d00; +} + +.error-text { + white-space: pre-wrap; + word-break: break-word; +} + +.error-line { + display: flex; + align-items: flex-start; + gap: 0.25rem; } .success-box { @@ -120,6 +152,54 @@ body, min-height: 0; } +.editor-split { + display: flex; + gap: 0.5rem; + flex: 1 1 auto; + min-height: 0; +} + +.xslt-editor-wrap { + flex: 1 1 auto; + min-width: 0; /* allow editor to shrink */ +} + +.trace-panel { + width: 30%; + background: #fff; + border: 1px solid #ddd; + overflow: auto; +} + +.trace-header { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid #eee; + font-weight: bold; + background: #fafafa; +} + +.trace-table { + width: 100%; + border-collapse: collapse; +} + +.trace-name { + width: 30%; + vertical-align: top; + padding: 0.25rem; + font-family: monospace; + color: #333; + border-right: 1px solid #f0f0f0; +} + +.trace-value { + vertical-align: top; + padding: 0.25rem; + white-space: pre-wrap; + word-break: break-word; + font-family: monospace; +} + .result { height: 40vh;