From 44a72ca4ba1a61864383bfa65c2782e0ef3c3466 Mon Sep 17 00:00:00 2001 From: Alexandre Vazquez Date: Tue, 22 Jul 2025 19:02:44 +0200 Subject: [PATCH 1/5] backend: pass parameter XML via files --- backend/src/main.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/src/main.go b/backend/src/main.go index f1b19ecf..0a76bf73 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -189,9 +189,19 @@ func main() { "-xsl:"+xsltPath, "-o:"+outputPath, ) + + idx := 0 for k, v := range req.Parameters { - cmdArgs = append(cmdArgs, fmt.Sprintf("%s=%s", k, v)) + paramFile := filepath.Join(tmpDir, fmt.Sprintf("param_%d", idx)) + if err := os.WriteFile(paramFile, []byte(v), 0644); err != nil { + log.Printf("write parameter %s failed: %v", k, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot write parameter"}) + return + } + cmdArgs = append(cmdArgs, fmt.Sprintf("%s=@%s", k, paramFile)) + idx++ } + if err := os.WriteFile(argsPath, []byte(strings.Join(cmdArgs, "\n")), 0644); err != nil { log.Printf("write args file failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot write args"}) From d76c80011c749ac93f76274653a80a82c0aac9de Mon Sep 17 00:00:00 2001 From: Alexandre Vazquez Date: Sun, 27 Jul 2025 16:54:01 +0200 Subject: [PATCH 2/5] Expand Saxon extension functions --- README.md | 7 + backend/Dockerfile | 13 +- .../xsltplayground/ext/CustomFunctions.java | 385 ++++++++++++++++++ 3 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 backend/ext/com/xsltplayground/ext/CustomFunctions.java diff --git a/README.md b/README.md index 2f7f1a0b..f0cb053b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,13 @@ By default it listens on port `8000` as configured in `backend/app.config`. The same file sets `saxon_classpath` so the Java process can load Saxon and its dependencies from `/opt/saxon/*`. +The backend image also builds a small jar with custom Saxon extension +functions. It gets copied to `/opt/saxon/custom-functions.jar` during the +Docker build. You can call these from XSLT using the namespace +`xmlns:tib="java:com.xsltplayground.ext.CustomFunctions"`. The jar exposes +many helper functions such as `tib:uuid()`, `tib:timestamp()` and +`tib:addToDate()`. + ### Environment When `VITE_GO_PRO=true` the backend stores transformation history and diff --git a/backend/Dockerfile b/backend/Dockerfile index 45398568..8c133962 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 +RUN apk add --no-cache git openjdk17 # Establecer directorio de trabajo WORKDIR /app/src @@ -18,6 +18,14 @@ RUN go mod init xslt-playground && \ go mod tidy && \ go build -o server . +# 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 + +WORKDIR /app/src + # Etapa final (runtime) FROM alpine:latest @@ -36,6 +44,9 @@ RUN mkdir -p /opt/saxon && \ curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \ curl -L -o /opt/saxon/xmlresolver.jar https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar +# Extension functions jar +COPY --from=builder /app/ext/custom-functions.jar /opt/saxon/ + # Exponer el puerto por defecto y ejecutar el binario EXPOSE 8000 diff --git a/backend/ext/com/xsltplayground/ext/CustomFunctions.java b/backend/ext/com/xsltplayground/ext/CustomFunctions.java new file mode 100644 index 00000000..af716f8c --- /dev/null +++ b/backend/ext/com/xsltplayground/ext/CustomFunctions.java @@ -0,0 +1,385 @@ +package com.xsltplayground.ext; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.Charset; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Random; +import java.util.UUID; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Node; + +public class CustomFunctions { + public static String uuid() { + return UUID.randomUUID().toString(); + } + + public static long timestamp() { + return System.currentTimeMillis(); + } + + public static String addToDate(String date, int years, int months, int days) { + LocalDate d = LocalDate.parse(date); + d = d.plusYears(years).plusMonths(months).plusDays(days); + return d.toString(); + } + + public static String trim(String s) { + return s == null ? "" : s.trim(); + } + + public static String addToDateTime(String dt, int y, int m, int d, int h, int min, int s) { + LocalDateTime t = LocalDateTime.parse(dt); + t = t.plusYears(y).plusMonths(m).plusDays(d).plusHours(h).plusMinutes(min).plusSeconds(s); + return t.toString(); + } + + public static String addToTime(String time, int h, int m, int s) { + LocalTime t = LocalTime.parse(time); + t = t.plusHours(h).plusMinutes(m).plusSeconds(s); + return t.toString(); + } + + public static int base64Length(String b64) { + byte[] data = Base64.getDecoder().decode(b64); + return data.length; + } + + public static String base64ToHex(String b64) { + byte[] data = Base64.getDecoder().decode(b64); + StringBuilder sb = new StringBuilder(); + for (byte by : data) { + sb.append(String.format("%02x", by)); + } + return sb.toString(); + } + + public static String base64ToString(String b64, String encoding) throws Exception { + if (encoding == null || encoding.isEmpty()) { + encoding = "UTF-8"; + } + byte[] data = Base64.getDecoder().decode(b64); + return new String(data, Charset.forName(encoding)); + } + + public static int compareDate(String d1, String d2) { + LocalDate a = LocalDate.parse(d1); + LocalDate b = LocalDate.parse(d2); + return a.compareTo(b); + } + + public static int compareDateTime(String dt1, String dt2) { + LocalDateTime a = LocalDateTime.parse(dt1); + LocalDateTime b = LocalDateTime.parse(dt2); + return a.compareTo(b); + } + + public static int compareTime(String t1, String t2) { + LocalTime a = LocalTime.parse(t1); + LocalTime b = LocalTime.parse(t2); + return a.compareTo(b); + } + + public static String concatBase64(String... parts) { + int total = 0; + byte[][] data = new byte[parts.length][]; + for (int i = 0; i < parts.length; i++) { + data[i] = Base64.getDecoder().decode(parts[i]); + total += data[i].length; + } + byte[] out = new byte[total]; + int pos = 0; + for (byte[] arr : data) { + System.arraycopy(arr, 0, out, pos, arr.length); + pos += arr.length; + } + return Base64.getEncoder().encodeToString(out); + } + + public static String concatSequence(String... values) { + StringBuilder sb = new StringBuilder(); + for (String s : values) { + if (s != null) + sb.append(s); + } + return sb.toString(); + } + + public static String concatSequenceFormat(String[] values, String sep, Boolean excludeEmpty) { + if (sep == null) sep = ""; + boolean skipEmpty = excludeEmpty != null && excludeEmpty.booleanValue(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + String v = values[i]; + if (skipEmpty && (v == null || v.isEmpty())) { + continue; + } + if (sb.length() > 0) sb.append(sep); + if (v != null) sb.append(v); + } + return sb.toString(); + } + + public static String createDate(int y, int m, int d) { + LocalDate ld = LocalDate.of(y, m, d); + return ld.toString(); + } + + public static String createDateTime(int y, int m, int d, int h, int min, int s, Integer ms) { + LocalDateTime t = LocalDateTime.of(y, m, d, h, min, s, ms == null ? 0 : ms * 1_000_000); + return t.toString(); + } + + public static String createDateTimeTimezone(int y, int m, int d, int h, int min, int s, int ms, int offH, int offM) { + ZoneOffset off = ZoneOffset.ofHoursMinutes(offH, offM); + OffsetDateTime odt = OffsetDateTime.of(y, m, d, h, min, s, ms * 1_000_000, off); + return odt.toString(); + } + + public static String createTime(int h, int m, int s, Integer ms) { + LocalTime t = LocalTime.of(h, m, s, ms == null ? 0 : ms * 1_000_000); + return t.toString(); + } + + public static String currentDateTimeTimezone(int offH, int offM) { + ZoneOffset off = ZoneOffset.ofHoursMinutes(offH, offM); + OffsetDateTime odt = OffsetDateTime.now(off); + return odt.toString(); + } + + public static String getCenturyFromDate(String date) { + int year = LocalDate.parse(date).getYear(); + return Integer.toString(year / 100); + } + + public static String getCenturyFromDateTime(String dt) { + int year = LocalDateTime.parse(dt).getYear(); + return Integer.toString(year / 100); + } + + public static String headBase64(String b64, int len) { + byte[] data = Base64.getDecoder().decode(b64); + if (len < 0 || len > data.length) len = data.length; + byte[] sub = new byte[len]; + System.arraycopy(data, 0, sub, 0, len); + return Base64.getEncoder().encodeToString(sub); + } + + public static int hexLength(String hex) { + return hex.length() / 2; + } + + public static String hexToBase64(String hex) { + byte[] data = new byte[hex.length() / 2]; + for (int i = 0; i < data.length; i++) { + int idx = i * 2; + data[i] = (byte) Integer.parseInt(hex.substring(idx, idx + 2), 16); + } + return Base64.getEncoder().encodeToString(data); + } + + public static String hexToString(String hex, String encoding) throws Exception { + if (encoding == null || encoding.isEmpty()) { + encoding = "UTF-8"; + } + byte[] data = new byte[hex.length() / 2]; + for (int i = 0; i < data.length; i++) { + int idx = i * 2; + data[i] = (byte) Integer.parseInt(hex.substring(idx, idx + 2), 16); + } + return new String(data, Charset.forName(encoding)); + } + + public static String ifAbsent(String value, String def) { + if (value == null || value.isEmpty()) { + return def; + } + return value; + } + + public static int indexOf(String s, String t) { + return s.indexOf(t) + 1; + } + + public static int lastIndexOf(String s, String t) { + return s.lastIndexOf(t) + 1; + } + + public static String left(String s, int len) { + if (len <= 0) return ""; + if (len >= s.length()) return s; + return s.substring(0, len); + } + + public static String pad(String s, int length, String padChar) { + if (padChar == null || padChar.isEmpty()) padChar = " "; + if (s.length() >= length) return s; + StringBuilder sb = new StringBuilder(s); + while (sb.length() < length) { + sb.append(padChar.charAt(0)); + } + return sb.toString(); + } + + public static String padAndLimit(String s, int length, String padChar) throws Exception { + if (padChar == null || padChar.isEmpty()) padChar = " "; + if (s.length() > length) throw new Exception("too long"); + StringBuilder sb = new StringBuilder(s); + while (sb.length() < length) { + sb.append(padChar.charAt(0)); + } + return sb.toString(); + } + + public static String padBase64(String b64, String filler, int length) { + byte[] a = Base64.getDecoder().decode(b64); + byte[] f = Base64.getDecoder().decode(filler); + if (a.length >= length) return b64; + byte[] out = new byte[length]; + System.arraycopy(a, 0, out, 0, a.length); + int pos = a.length; + while (pos < length) { + int copy = Math.min(f.length, length - pos); + System.arraycopy(f, 0, out, pos, copy); + pos += copy; + } + return Base64.getEncoder().encodeToString(out); + } + + public static String padFront(String s, int length, String padChar) { + if (padChar == null || padChar.isEmpty()) padChar = " "; + if (s.length() >= length) return s; + StringBuilder sb = new StringBuilder(); + while (sb.length() < length - s.length()) { + sb.append(padChar.charAt(0)); + } + sb.append(s); + return sb.toString(); + } + + public static String parseDate(String format, String text) { + DateTimeFormatter f = DateTimeFormatter.ofPattern(format); + LocalDate d = LocalDate.parse(text, f); + return d.toString(); + } + + public static String parseDateTime(String format, String text) { + DateTimeFormatter f = DateTimeFormatter.ofPattern(format); + LocalDateTime t = LocalDateTime.parse(text, f); + return t.atOffset(ZoneOffset.systemDefault().getRules().getOffset(t)).toString(); + } + + public static String parseTime(String format, String text) { + DateTimeFormatter f = DateTimeFormatter.ofPattern(format); + LocalTime t = LocalTime.parse(text, f); + return t.toString(); + } + + public static int random(int min, int max) { + return new Random().nextInt((max - min) + 1) + min; + } + + public static String renderXml(Node node, Boolean omit, Boolean indent) throws Exception { + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer t = tf.newTransformer(); + if (omit != null && omit.booleanValue()) { + t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + } + if (indent != null && indent.booleanValue()) { + t.setOutputProperty(OutputKeys.INDENT, "yes"); + } + java.io.StringWriter sw = new java.io.StringWriter(); + t.transform(new DOMSource(node), new StreamResult(sw)); + return sw.toString(); + } + + public static String right(String s, int len) { + if (len <= 0) return ""; + if (len >= s.length()) return s; + return s.substring(s.length() - len); + } + + public static double roundFraction(double num, int digits) { + BigDecimal bd = BigDecimal.valueOf(num); + bd = bd.setScale(digits, RoundingMode.HALF_UP); + return bd.doubleValue(); + } + + public static String stringRoundFraction(double num, int digits) { + BigDecimal bd = BigDecimal.valueOf(num); + bd = bd.setScale(digits, RoundingMode.HALF_UP); + return bd.toPlainString(); + } + + public static String stringToBase64(String s, String encoding) throws Exception { + if (encoding == null || encoding.isEmpty()) encoding = "UTF-8"; + byte[] data = s.getBytes(Charset.forName(encoding)); + return Base64.getEncoder().encodeToString(data); + } + + public static String stringToHex(String s, String encoding) throws Exception { + if (encoding == null || encoding.isEmpty()) encoding = "UTF-8"; + byte[] data = s.getBytes(Charset.forName(encoding)); + StringBuilder sb = new StringBuilder(); + for (byte b : data) sb.append(String.format("%02X", b)); + return sb.toString(); + } + + public static String substringAfterLast(String s, String after) { + int idx = s.lastIndexOf(after); + if (idx == -1) return ""; + return s.substring(idx + after.length()); + } + + public static String substringBase64(String b64, int start, Integer length) { + byte[] data = Base64.getDecoder().decode(b64); + int s = Math.max(0, start - 1); + int len = length == null ? data.length - s : Math.min(length, data.length - s); + byte[] out = new byte[len]; + System.arraycopy(data, s, out, 0, len); + return Base64.getEncoder().encodeToString(out); + } + + public static String substringBeforeLast(String s, String before) { + int idx = s.lastIndexOf(before); + if (idx == -1) return ""; + return s.substring(0, idx); + } + + public static String translateTimezone(String dt, String zone) { + OffsetDateTime odt = OffsetDateTime.parse(dt); + ZoneOffset off = ZoneOffset.of(zone); + return odt.withOffsetSameInstant(off).toString(); + } + + public static String trimBase64(String b64, int begin, Integer end) { + byte[] data = Base64.getDecoder().decode(b64); + int start = Math.min(begin, data.length); + int endLen = end == null ? 0 : end; + int len = data.length - start - endLen; + if (len < 0) len = 0; + byte[] out = new byte[len]; + System.arraycopy(data, start, out, 0, len); + return Base64.getEncoder().encodeToString(out); + } + + public static boolean validateDateTime(String format, String text) { + try { + DateTimeFormatter f = DateTimeFormatter.ofPattern(format); + LocalDateTime.parse(text, f); + return true; + } catch (Exception e) { + return false; + } + } + + public static boolean xor(boolean a, boolean b) { + return a ^ b; + } +} From f91b12624d64caae116ad0df4d7b77c054853cd8 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Tue, 5 Aug 2025 08:58:07 +0200 Subject: [PATCH 3/5] heuristics check --- backend/src/main.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/main.go b/backend/src/main.go index 0a76bf73..c4ca3de6 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -198,7 +198,13 @@ func main() { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot write parameter"}) return } - cmdArgs = append(cmdArgs, fmt.Sprintf("%s=@%s", k, paramFile)) + // Heuristic check: if value looks like XML (starts with '<' and ends with '>'), treat it as file-based + trimmed := strings.TrimSpace(v) + if strings.HasPrefix(trimmed, "<") || strings.HasPrefix(trimmed, "<") { + cmdArgs = append(cmdArgs, fmt.Sprintf("+%s=%s", k, paramFile)) + } else { + cmdArgs = append(cmdArgs, fmt.Sprintf("%s=%s", k, v)) + } idx++ } From 39d6767ac5375e9f75aef5e6789d46f6da146052 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Tue, 23 Sep 2025 16:09:29 +0200 Subject: [PATCH 4/5] +1 --- backend/Dockerfile | 8 +- backend/ext/com/xsltplayground/Runner.java | 200 +++++++++++++++ .../xsltplayground/ext/CustomFunctions.java | 229 ++++++++++++++++++ backend/src/main.go | 80 +++++- frontend/src/App.jsx | 228 +++++++++++++---- frontend/src/style.css | 80 ++++++ 6 files changed, 773 insertions(+), 52 deletions(-) create mode 100644 backend/ext/com/xsltplayground/Runner.java 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; From de28a305df466cd6043d554273eef18f0390d181 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Sat, 15 Nov 2025 22:26:25 +0100 Subject: [PATCH 5/5] version 0.2.0 --- .gitignore | 1 + .vscode/settings.json | 3 + CHANGELOG.md | 13 + Makefile | 19 +- README.md | 14 +- backend/Dockerfile | 10 +- backend/ext/com/xsltplayground/Runner.java | 2010 ++++++++++++++++- .../xsltplayground/ext/CustomFunctions.java | 8 +- backend/src/main.go | 106 +- backend/src/main_test.go | 94 + docker-compose.local.yml | 1 + frontend/entrypoint.sh | 25 +- frontend/index.html | 1 + frontend/package-lock.json | 1461 +++++++++++- frontend/package.json | 11 +- frontend/public/favicon.ico | Bin 0 -> 4286 bytes frontend/src/App.jsx | 1556 ++++++++++--- frontend/src/App.test.jsx | 53 + frontend/src/BuyMeACoffee.jsx | 31 - frontend/src/components/BuyMeACoffee.jsx | 42 + .../src/components/DataPipelineHeader.jsx | 31 + .../components/DataPipelineHeader.test.jsx | 36 + frontend/src/components/FeedbackWidget.jsx | 249 ++ frontend/src/components/TabsNav.jsx | 64 + frontend/src/components/TabsNav.test.jsx | 54 + frontend/src/logo.svg | 30 +- frontend/src/setupTests.js | 1 + frontend/src/style.css | 805 ++++++- frontend/vite.config.js | 12 + 29 files changed, 6201 insertions(+), 540 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 backend/src/main_test.go create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/src/App.test.jsx delete mode 100644 frontend/src/BuyMeACoffee.jsx create mode 100644 frontend/src/components/BuyMeACoffee.jsx create mode 100644 frontend/src/components/DataPipelineHeader.jsx create mode 100644 frontend/src/components/DataPipelineHeader.test.jsx create mode 100644 frontend/src/components/FeedbackWidget.jsx create mode 100644 frontend/src/components/TabsNav.jsx create mode 100644 frontend/src/components/TabsNav.test.jsx create mode 100644 frontend/src/setupTests.js diff --git a/.gitignore b/.gitignore index cda135de..41229b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -196,5 +196,6 @@ cython_debug/ frontend/node_modules frontend/dist backend/server +backend/src/.gocache /config/credentials.json credentials.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..082b1943 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a343a669 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## v0.2.0 + +- Expand the transformation up to three independent workspaces, persist each workspace state, and add import/export controls for sharing setups. +- Surface the GitHub Pages news/blog link directly in the app header and show the running UI version with a deep link to this changelog. +- Introduce workspace JSON export/import plus per-workspace trace, error and result retention. +- New look & feel to provide a better experience + + +## v0.1.0 + +- Initial release. diff --git a/Makefile b/Makefile index 29ec1171..c906780b 100644 --- a/Makefile +++ b/Makefile @@ -4,16 +4,24 @@ BACKEND_IMAGE=xslt-playground-backend FRONTEND_IMAGE=xslt-playground-frontend -.PHONY: all backend-build frontend-build backend-image frontend-image compose-up compose-down +.PHONY: all backend-build frontend-build backend-image frontend-image compose-up compose-down clean backend-test frontend-test test -all: backend-build frontend-build backend-image frontend-image compose-up +all: backend-test frontend-test backend-build frontend-build backend-image frontend-image compose-up backend-build: cd $(BACKEND_DIR)/src && go mod tidy && go build -o ../server +backend-test: + cd $(BACKEND_DIR)/src && GOCACHE=$$(pwd)/.gocache go test ./... + frontend-build: cd $(FRONTEND_DIR) && npm install && npm run build +frontend-test: + cd $(FRONTEND_DIR) && npm install && npm run test + +test: backend-test frontend-test + backend-image: docker build --platform linux/amd64 -t $(BACKEND_IMAGE) $(BACKEND_DIR) docker tag $(BACKEND_IMAGE):latest ghcr.io/alexandrev/$(BACKEND_IMAGE):latest @@ -27,3 +35,10 @@ compose-up: compose-down: docker compose -f docker-compose.local.yml down + +clean: + -docker compose -f docker-compose.local.yml down --remove-orphans --rmi local + @if docker image inspect $(BACKEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f $(BACKEND_IMAGE):latest; fi + @if docker image inspect $(FRONTEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f $(FRONTEND_IMAGE):latest; fi + @if docker image inspect ghcr.io/alexandrev/$(BACKEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f ghcr.io/alexandrev/$(BACKEND_IMAGE):latest; fi + @if docker image inspect ghcr.io/alexandrev/$(FRONTEND_IMAGE):latest >/dev/null 2>&1; then docker image rm -f ghcr.io/alexandrev/$(FRONTEND_IMAGE):latest; fi diff --git a/README.md b/README.md index f0cb053b..9f43091a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Your Lab for XSLT Transformation. +## News & Releases + +- Follow updates on the GitHub Pages blog: [alexandrev.github.io/xslt-lab](https://alexandrev.github.io/xslt-lab/). +- Review detailed changes in [CHANGELOG.md](https://github.com/alexandrev/xslt-lab/blob/main/CHANGELOG.md). + ## Frontend The React/Vite frontend lives in `frontend/`. Use `npm install` inside that folder and run: @@ -25,11 +30,15 @@ containerized version the URL is now read at **runtime** from environment variables so you can configure it directly in the pod. When `VITE_GO_PRO=true` the UI exposes additional features like Google -authentication and multiple transformation tabs. For authentication you must +authentication. For authentication you must provide Firebase configuration via `VITE_FIREBASE_CONFIG` containing the JSON object used by `initializeApp`. Set `VITE_GA_ID` to enable Google Analytics tracking. +The playground keeps up to three independent workspaces (tabs). Each workspace +persists its own inputs, trace output, errors and results, and you can export or +import them as JSON files to share setups easily. + ### Docker To build a container with the compiled frontend run: @@ -70,7 +79,7 @@ its dependencies from `/opt/saxon/*`. The backend image also builds a small jar with custom Saxon extension functions. It gets copied to `/opt/saxon/custom-functions.jar` during the Docker build. You can call these from XSLT using the namespace -`xmlns:tib="java:com.xsltplayground.ext.CustomFunctions"`. The jar exposes +`xmlns:tib="http://www.tibco.com/bw/xslt/custom-functions"`. The jar exposes many helper functions such as `tib:uuid()`, `tib:timestamp()` and `tib:addToDate()`. @@ -148,4 +157,3 @@ helm install xslt charts/xslt-playground # helm install xslt charts/xslt-playground --set firebase.enabled=false # helm install xslt charts/xslt-playground --set storage.enabled=false ``` - diff --git a/backend/Dockerfile b/backend/Dockerfile index c8c7e99a..88d84a3e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,19 +10,15 @@ WORKDIR /app/src # Copiar los archivos COPY src/ . -RUN rm -rf go.mod go.sum - # Compilar el binario estático -RUN go mod init xslt-playground && \ - go get github.com/gin-gonic/gin && \ - go mod tidy && \ +RUN go mod tidy && \ go build -o server . # Build extension functions jar WORKDIR /app/ext COPY ext/ . 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 && \ + curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \ javac -cp /tmp/saxon/saxon-he.jar com/xsltplayground/ext/CustomFunctions.java com/xsltplayground/Runner.java && \ jar cf custom-functions.jar com @@ -43,7 +39,7 @@ COPY app.config . # Crear carpeta para Saxon y descargarlo RUN mkdir -p /opt/saxon && \ - curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/11.6/Saxon-HE-11.6.jar && \ + curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \ curl -L -o /opt/saxon/xmlresolver.jar https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar # Extension functions jar diff --git a/backend/ext/com/xsltplayground/Runner.java b/backend/ext/com/xsltplayground/Runner.java index 2f26b508..61f2d430 100644 --- a/backend/ext/com/xsltplayground/Runner.java +++ b/backend/ext/com/xsltplayground/Runner.java @@ -1,16 +1,71 @@ package com.xsltplayground; import com.xsltplayground.ext.CustomFunctions; +import net.sf.saxon.Controller; +import net.sf.saxon.lib.ErrorReporter; +import net.sf.saxon.om.GroundedValue; +import net.sf.saxon.om.Item; +import net.sf.saxon.om.NodeInfo; +import net.sf.saxon.om.Sequence; +import net.sf.saxon.om.SequenceIterator; +import net.sf.saxon.om.StandardNames; +import net.sf.saxon.om.StructuredQName; import net.sf.saxon.s9api.*; +import net.sf.saxon.lib.FeatureKeys; +import net.sf.saxon.s9api.QName; +import net.sf.saxon.s9api.XmlProcessingError; +import net.sf.saxon.value.EmptySequence; +import net.sf.saxon.value.SequenceExtent; import javax.xml.transform.Source; import javax.xml.transform.stream.StreamSource; import java.io.File; +import java.io.PrintStream; +import java.io.StringWriter; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.IdentityHashMap; +import java.util.concurrent.ConcurrentHashMap; public class Runner { + private static final boolean TRACE_DEBUG = + Boolean.parseBoolean(System.getProperty("xslt.trace.debug", "false")) || + "true".equalsIgnoreCase(System.getenv("XSLT_TRACE_DEBUG")); + + private static final Set loggedMapIds = ConcurrentHashMap.newKeySet(); + + private static void diag(String message) { + if (TRACE_DEBUG && message != null) { + System.err.println("TRACE_DIAG: " + message); + } + } + + private static Set newIdentitySet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } + + private static String preview(String value) { + if (value == null) { + return "null"; + } + String singleLine = value.replace('\n', ' ').replace('\r', ' '); + if (singleLine.length() > 180) { + return singleLine.substring(0, 177) + "..."; + } + return singleLine; + } + public static void main(String[] args) { try { Map rawParams = new LinkedHashMap<>(); @@ -57,69 +112,37 @@ public class Runner { } Processor proc = new Processor(false); + if (trace) { + proc.setConfigurationProperty(FeatureKeys.OPTIMIZATION_LEVEL, "0"); + } CustomFunctions.registerAll(proc); XsltCompiler compiler = proc.newXsltCompiler(); - XsltExecutable exec; + boolean instrumentationEnabled = false; 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" + - " " + - " " + - ""; + ErrorReporter baseReporter = compiler.getErrorReporter(); + compiler.setErrorReporter(new DeduplicatingErrorReporter(baseReporter)); + instrumentationEnabled = enableCompileWithTracing(compiler); + if (TRACE_DEBUG) { + System.err.println("TRACE_DEBUG instrumentation requested, enableCompileWithTracing=" + instrumentationEnabled); + } + } - 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 { + XsltExecutable exec; + try { exec = compiler.compile(new StreamSource(new File(xslPath))); + } catch (SaxonApiException e) { + if (trace && instrumentationEnabled) { + System.err.println("Warning: trace instrumentation failed; recompiling without tracing. " + e.getMessage()); + instrumentationEnabled = false; + compiler = proc.newXsltCompiler(); + exec = compiler.compile(new StreamSource(new File(xslPath))); + if (TRACE_DEBUG) { + System.err.println("TRACE_DEBUG recompilation without instrumentation"); + } + } else { + throw e; + } } XsltTransformer transformer = exec.load(); @@ -150,6 +173,14 @@ public class Runner { } } + boolean traceActive = trace && instrumentationEnabled; + if (TRACE_DEBUG) { + System.err.println("TRACE_DEBUG traceActive=" + traceActive); + } + if (traceActive) { + attachTraceListener(proc, transformer, System.err); + } + if (sourcePath != null && !sourcePath.isEmpty()) { Source src = new StreamSource(new File(sourcePath)); XdmNode doc = proc.newDocumentBuilder().build(src); @@ -197,4 +228,1869 @@ public class Runner { System.exit(1); } } + + private static boolean enableCompileWithTracing(XsltCompiler compiler) { + try { + compiler.setCompileWithTracing(true); + return true; + } catch (Throwable primary) { + try { + Method m = compiler.getClass().getMethod("setCompileWithTracing", boolean.class); + m.setAccessible(true); + m.invoke(compiler, true); + return true; + } catch (Exception ignored) { + return false; + } + } + } + + private static final class DeduplicatingErrorReporter implements ErrorReporter { + private final ErrorReporter downstream; + private final Set seen = ConcurrentHashMap.newKeySet(); + + DeduplicatingErrorReporter(ErrorReporter downstream) { + this.downstream = downstream; + } + + @Override + public void report(XmlProcessingError error) { + if (error == null) { + return; + } + if (error.isWarning()) { + if (shouldSuppress(error)) { + return; + } + String key = buildKey(error); + if (!seen.add(key)) { + return; + } + } + if (downstream != null) { + downstream.report(error); + } else if (error.getMessage() != null) { + System.err.println(error.getMessage()); + } + } + + private boolean shouldSuppress(XmlProcessingError error) { + QName code = error.getErrorCode(); + String local = code != null ? code.getLocalName() : null; + return "SXWN9026".equals(local); + } + + private String buildKey(XmlProcessingError error) { + StringBuilder sb = new StringBuilder(); + QName code = error.getErrorCode(); + if (code != null) { + sb.append(code.toString()); + } + sb.append('|'); + if (error.getLocation() != null) { + sb.append(error.getLocation().toString()); + } + sb.append('|'); + String message = error.getMessage(); + if (message != null) { + sb.append(message); + } + return sb.toString(); + } + } + + private static void attachTraceListener(Processor processor, XsltTransformer transformer, PrintStream sink) { + try { + Controller controller = transformer.getUnderlyingController(); + if (controller == null) { + return; + } + ClassLoader loader = controller.getClass().getClassLoader(); + Class traceListenerClass = Class.forName("net.sf.saxon.lib.TraceListener", false, loader); + InvocationHandler handler = new VariableTraceListenerProxy(processor, transformer, sink); + Object listener = Proxy.newProxyInstance(loader, new Class[]{traceListenerClass}, handler); + + if (!invokeTraceHook(controller, "addTraceListener", traceListenerClass, listener)) { + invokeTraceHook(controller, "setTraceListener", traceListenerClass, listener); + } + } catch (Throwable ignored) { + // If tracing cannot be attached we simply carry on without trace output. + } + } + + private static boolean invokeTraceHook(Controller controller, String methodName, Class listenerClass, Object listener) { + try { + Method m = controller.getClass().getMethod(methodName, listenerClass); + m.setAccessible(true); + m.invoke(controller, listener); + return true; + } catch (Exception ignored) { + return false; + } + } + + private static final class VariableTraceListenerProxy implements InvocationHandler { + private final Processor processor; + private final PrintStream out; + private final XsltTransformer transformer; + private final Deque stack = new ArrayDeque<>(); + private int debugCounter = 0; + + VariableTraceListenerProxy(Processor processor, XsltTransformer transformer, PrintStream out) { + this.processor = processor; + this.transformer = transformer; + this.out = out; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + String name = method.getName(); + if ("close".equals(name)) { + stack.clear(); + return null; + } + if ("enter".equals(name)) { + Object info = args != null && args.length > 0 ? args[0] : null; + Object properties = args != null && args.length > 1 ? args[1] : null; + Object context = args != null && args.length > 2 ? args[2] : null; + debugEvent("enter", info); + handleEnter(info, properties, context); + return null; + } + if ("leave".equals(name)) { + Object info = args != null && args.length > 0 ? args[0] : null; + Object properties = args != null && args.length > 1 ? args[1] : null; + Object context = args != null && args.length > 2 ? args[2] : null; + debugEvent("leave", info); + handleLeave(info, properties, context); + return null; + } + if ("open".equals(name) || "startCurrentItem".equals(name) || "endCurrentItem".equals(name)) { + return null; + } + return null; + } + + private void handleEnter(Object instructionInfo, Object properties, Object context) { + if (!isVariable(instructionInfo)) { + return; + } + Frame frame = new Frame(); + frame.instruction = instructionInfo; + frame.name = getVariableName(instructionInfo); + frame.context = context; + frame.properties = properties; + stack.push(frame); + printDebug("enter", frame.name); + } + + private void handleLeave(Object instructionInfo, Object properties, Object context) { + if (!isVariable(instructionInfo)) { + return; + } + Frame frame = stack.isEmpty() ? null : stack.pop(); + if (frame == null) { + frame = new Frame(); + frame.instruction = instructionInfo; + } + if (frame.name == null) { + frame.name = getVariableName(instructionInfo); + } + Object effectiveProperties = properties != null ? properties : frame.properties; + if (context == null) { + context = frame.context; + } + if (context == null && effectiveProperties instanceof Map) { + context = extractContextFromProperties((Map) effectiveProperties); + } + if (context != null) { + Runner.diag("handleLeave context class=" + context.getClass().getName()); + } else { + Runner.diag("handleLeave context is null"); + } + if (effectiveProperties instanceof Map) { + logContextMap((Map) effectiveProperties); + } + StructuredQName name = frame.name; + if (name == null) { + return; + } + + TraceCapture capture = extractTraceValue(instructionInfo, context, effectiveProperties, name); + String displayValue = capture.sequence != null ? formatSequence(capture.sequence) : null; + if ((displayValue == null || displayValue.isEmpty()) && capture.fallback != null) { + displayValue = capture.fallback; + } + if (displayValue == null) { + displayValue = ""; + } + + Runner.diag("emit variable " + name.getDisplayName() + " value length=" + displayValue.length()); + + out.println("TRACE_VAR_START|" + name.getDisplayName()); + if (!displayValue.isEmpty()) { + out.println(displayValue); + } + out.println("TRACE_VAR_END"); + printDebug("leave", name); + } + + private TraceCapture extractTraceValue(Object instructionInfo, Object context, Object properties, StructuredQName name) { + TraceCapture capture = new TraceCapture(); + String displayName = name != null ? name.getDisplayName() : "(unknown)"; + Object candidate = firstCandidate(instructionInfo); + if (candidate != null) { + TraceCapture candidateCapture = captureFromResult(candidate, context); + if (candidateCapture.sequence != null) { + Runner.diag("firstCandidate sequence for " + displayName + " via " + candidate.getClass().getName()); + capture.sequence = materialize(candidateCapture.sequence); + return capture; + } + if (candidateCapture.fallback != null) { + Runner.diag("firstCandidate fallback for " + displayName + " = " + preview(candidateCapture.fallback)); + capture.fallback = candidateCapture.fallback; + } + } + + Integer slot = extractSlot(instructionInfo); + if (slot != null) { + Sequence seq = evaluateSlot(context, slot.intValue()); + if (seq != null) { + Runner.diag("evaluateSlot(" + slot + ") sequence for " + displayName); + capture.sequence = materialize(seq); + return capture; + } else { + Runner.diag("evaluateSlot(" + slot + ") returned null for " + displayName); + } + } + + if (name != null) { + TraceCapture paramCapture = captureParameterValue(name); + if (paramCapture.sequence != null) { + Runner.diag("transformer parameter hit for " + name.getDisplayName()); + capture.sequence = materialize(paramCapture.sequence); + return capture; + } + if (capture.fallback == null && paramCapture.fallback != null) { + Runner.diag("transformer parameter fallback for " + name.getDisplayName() + " = " + preview(paramCapture.fallback)); + capture.fallback = paramCapture.fallback; + } + TraceCapture byName = evaluateByName(context, properties, name); + if (byName.sequence != null) { + Runner.diag("evaluateByName sequence for " + displayName); + capture.sequence = materialize(byName.sequence); + return capture; + } + if (capture.fallback == null && byName.fallback != null) { + Runner.diag("evaluateByName fallback for " + displayName + " = " + preview(byName.fallback)); + capture.fallback = byName.fallback; + } + TraceCapture controllerCapture = evaluateFromController(name, context); + if (controllerCapture.sequence != null) { + Runner.diag("controller lookup sequence for " + displayName); + capture.sequence = materialize(controllerCapture.sequence); + return capture; + } + if (capture.fallback == null && controllerCapture.fallback != null) { + Runner.diag("controller lookup fallback for " + displayName + " = " + preview(controllerCapture.fallback)); + capture.fallback = controllerCapture.fallback; + } + } + + Object binding = extractBinding(instructionInfo); + if (binding != null) { + TraceCapture fromBinding = evaluateBinding(context, binding, newIdentitySet()); + if (fromBinding.sequence != null) { + Runner.diag("evaluateBinding sequence for " + displayName + " using " + binding.getClass().getName()); + capture.sequence = materialize(fromBinding.sequence); + return capture; + } + if (fromBinding.fallback != null) { + Runner.diag("evaluateBinding fallback for " + displayName + " = " + preview(fromBinding.fallback)); + } + mergeCapture(capture, fromBinding); + } + + TraceCapture direct = evaluateDirect(instructionInfo, context); + if (direct.sequence != null) { + Runner.diag("evaluateDirect sequence for " + displayName); + capture.sequence = materialize(direct.sequence); + return capture; + } + if (capture.fallback == null && direct.fallback != null) { + Runner.diag("evaluateDirect fallback for " + displayName + " = " + preview(direct.fallback)); + capture.fallback = direct.fallback; + } + + if (capture.sequence == null && capture.fallback == null && candidate != null) { + capture.fallback = candidate.toString(); + Runner.diag("final fallback uses candidate.toString for " + displayName + " = " + preview(capture.fallback)); + } + Runner.diag("capture result for " + displayName + " -> sequence=" + + (capture.sequence != null) + " fallback=" + + (capture.fallback != null ? preview(capture.fallback) : "null")); + return capture; + } + + private TraceCapture evaluateDirect(Object instructionInfo, Object context) { + TraceCapture capture = new TraceCapture(); + if (instructionInfo == null) { + return capture; + } + + Sequence evaluatorSeq = evaluateEvaluatorObject(getProperty(instructionInfo, "evaluator"), context); + if (evaluatorSeq != null) { + capture.sequence = evaluatorSeq; + return capture; + } + + Sequence getterSeq = evaluateEvaluatorObject(invoke(instructionInfo, "getEvaluator"), context); + if (getterSeq != null) { + capture.sequence = getterSeq; + return capture; + } + + Object[] params = context != null ? new Object[]{context} : new Object[0]; + String[] methodNames = new String[]{ + "iterate", + "evaluateVariable", + "evaluateLocalVariable", + "evaluate", + "getSelectValue", + "getSelectExpression" + }; + for (String name : methodNames) { + Object result = invoke(instructionInfo, name, params); + if (result == null && context != null) { + result = invoke(instructionInfo, name); + } + Sequence seq = toSequence(result); + if (seq != null) { + capture.sequence = seq; + return capture; + } + if (result instanceof SequenceIterator) { + Sequence materialized = materializeIterator((SequenceIterator) result); + if (materialized != null) { + capture.sequence = materialized; + return capture; + } + } + if (result instanceof Item) { + capture.fallback = formatItem((Item) result); + return capture; + } + Sequence evalSeq = evaluateEvaluatorObject(result, context); + if (evalSeq != null) { + capture.sequence = evalSeq; + return capture; + } + } + + Object single = invoke(instructionInfo, "evaluateItem", params); + if (single == null && context != null) { + single = invoke(instructionInfo, "evaluateItem"); + } + if (single instanceof Item) { + Item item = (Item) single; + Sequence seq = toSequence(item); + if (seq != null) { + capture.sequence = seq; + return capture; + } + capture.fallback = formatItem(item); + return capture; + } + + return capture; + } + + private Sequence evaluateEvaluatorObject(Object evaluator, Object context) { + if (evaluator == null) { + return null; + } + Object[] params = context != null ? new Object[]{context} : new Object[0]; + Object evaluated = invoke(evaluator, "evaluate", params); + Sequence seq = toSequence(evaluated); + if (seq != null) { + return seq; + } + evaluated = invoke(evaluator, "materialize", params); + seq = toSequence(evaluated); + if (seq != null) { + return seq; + } + Object iterator = invoke(evaluator, "iterate", params); + if (iterator instanceof SequenceIterator) { + return materializeIterator((SequenceIterator) iterator); + } + if (iterator != null) { + seq = toSequence(iterator); + if (seq != null) { + return seq; + } + } + return null; + } + + private Sequence evaluateSlot(Object context, int slot) { + Object current = context; + Object value = invoke(current, "evaluateLocalVariable", slot); + if (value == null) { + value = invoke(current, "getLocalVariable", slot); + } + if (value == null) { + value = invoke(current, "getStackFrameValue", slot); + } + if (value == null) { + Object controller = invoke(current, "getController"); + if (controller != null) { + value = invoke(controller, "evaluateLocalVariable", slot); + if (value == null) { + value = invoke(controller, "getLocalVariable", slot); + } + } + } + return toSequence(value); + } + + private TraceCapture evaluateByName(Object context, Object properties, StructuredQName name) { + TraceCapture aggregate = new TraceCapture(); + if (context == null || name == null) { + // Continue scanning property targets even if context is null + if (!(properties instanceof Map)) { + return aggregate; + } + } + Object[] variants = nameVariants(name); + Object[] targets = contextTargets(context, properties); + String[] methodNames = new String[]{ + "evaluateVariable", + "evaluateGlobalVariable", + "getVariable", + "getGlobalVariable", + "getGlobalVariableValue", + "getXPathVariable", + "obtainVariable", + "resolveVariable", + "getParameter", + "get" + }; + for (Object target : targets) { + if (target == null) { + continue; + } + Runner.diag("evaluateByName inspecting target " + target.getClass().getName() + " for " + name.getDisplayName()); + for (String method : methodNames) { + for (Object variant : variants) { + Object result = invoke(target, method, variant); + TraceCapture candidate = captureFromResult(result, context); + if (candidate.sequence != null) { + return candidate; + } + mergeCapture(aggregate, candidate); + } + } + TraceCapture fromCollections = captureFromParameterCollections(target, name, context); + if (fromCollections.sequence != null) { + Runner.diag("evaluateByName collections hit sequence for " + name.getDisplayName()); + return fromCollections; + } + if (fromCollections.fallback != null) { + Runner.diag("evaluateByName collections fallback for " + name.getDisplayName() + " = " + preview(fromCollections.fallback)); + } + mergeCapture(aggregate, fromCollections); + } + return aggregate; + } + + private Object extractBinding(Object instructionInfo) { + if (instructionInfo == null) { + return null; + } + String[] methodNames = new String[]{ + "getBinding", + "getVariableBinding", + "getBindingInformation", + "getBindingNode", + "getBindingObject" + }; + for (String method : methodNames) { + Object binding = invoke(instructionInfo, method); + if (binding != null) { + return binding; + } + } + Object prop = getProperty(instructionInfo, "binding"); + if (prop != null) { + return prop; + } + return null; + } + + private TraceCapture evaluateBinding(Object context, Object binding, Set seen) { + TraceCapture aggregate = new TraceCapture(); + if (binding == null) { + return aggregate; + } + if (seen != null && !seen.add(binding)) { + Runner.diag("evaluateBinding already visited " + binding.getClass().getName()); + return aggregate; + } + Runner.diag("evaluateBinding inspecting " + binding.getClass().getName()); + Object controller = context != null ? invoke(context, "getController") : null; + if (controller == null) { + controller = invoke(binding, "getController"); + } + if (controller == null && transformer != null) { + controller = transformer.getUnderlyingController(); + } + if (controller != null) { + Runner.diag("evaluateBinding using controller " + controller.getClass().getName()); + } else { + Runner.diag("evaluateBinding no controller available for binding " + binding.getClass().getName()); + } + Object packageData = invoke(binding, "getPackageData"); + Object bindery = null; + if (controller != null) { + if (packageData == null) { + Object executable = invoke(controller, "getExecutable"); + packageData = executable != null ? invoke(executable, "getTopLevelPackage") : null; + } + if (packageData != null) { + bindery = invoke(controller, "getBindery", packageData); + } + if (bindery == null) { + bindery = invoke(controller, "getBindery"); + } + } + if (bindery != null) { + TraceCapture fromBindery = captureFromResult(invoke(bindery, "getGlobalVariableValue", binding), context, seen); + if (fromBindery.sequence != null) { + Runner.diag("evaluateBinding bindery provided sequence for " + binding.getClass().getName()); + return fromBindery; + } + if (fromBindery.fallback != null) { + Runner.diag("evaluateBinding bindery fallback " + preview(fromBindery.fallback)); + } + mergeCapture(aggregate, fromBindery); + } + if (controller != null) { + TraceCapture fromController = captureFromResult(invoke(controller, "evaluateGlobalVariable", binding), context, seen); + if (fromController.sequence != null) { + Runner.diag("evaluateBinding controller provided sequence for " + binding.getClass().getName()); + return fromController; + } + if (fromController.fallback != null) { + Runner.diag("evaluateBinding controller fallback " + preview(fromController.fallback)); + } + mergeCapture(aggregate, fromController); + } + TraceCapture bindingValue = captureFromResult(invoke(binding, "getValue"), context, seen); + if (bindingValue.sequence != null) { + Runner.diag("evaluateBinding direct getValue sequence for " + binding.getClass().getName()); + return bindingValue; + } + if (bindingValue.fallback != null) { + Runner.diag("evaluateBinding getValue fallback " + preview(bindingValue.fallback)); + } + mergeCapture(aggregate, bindingValue); + + String[] methodNames = new String[]{ + "evaluate", + "evaluateVariable", + "evaluateLocalVariable", + "getSelectValue", + "call", + "value" + }; + for (String method : methodNames) { + if (context != null) { + TraceCapture candidate = captureFromResult(invoke(binding, method, context), context, seen); + if (candidate.sequence != null) { + return candidate; + } + mergeCapture(aggregate, candidate); + } + TraceCapture candidate = captureFromResult(invoke(binding, method), context, seen); + if (candidate.sequence != null) { + return candidate; + } + mergeCapture(aggregate, candidate); + } + return aggregate; + } + + private Object[] contextTargets(Object context, Object properties) { + LinkedHashSet targets = new LinkedHashSet<>(); + if (context != null) { + targets.add(context); + Object controller = invoke(context, "getController"); + if (controller != null) { + targets.add(controller); + Object executable = invoke(controller, "getExecutable"); + Object packageData = executable != null ? invoke(executable, "getTopLevelPackage") : null; + Object bindery = null; + if (packageData != null) { + bindery = invoke(controller, "getBindery", packageData); + } + if (bindery == null) { + bindery = invoke(controller, "getBindery"); + } + if (bindery != null) { + targets.add(bindery); + } + } + Object major = invoke(context, "getMajorContext"); + if (major != null) { + targets.add(major); + } + Object stackFrame = invoke(context, "getStackFrame"); + if (stackFrame != null) { + targets.add(stackFrame); + } + Object localParams = invoke(context, "getLocalParameters"); + if (localParams != null) { + targets.add(localParams); + } + } + if (properties instanceof Map) { + Map map = (Map) properties; + for (Object value : map.values()) { + if (value != null) { + targets.add(value); + } + } + } + if (transformer != null) { + Object controller = transformer.getUnderlyingController(); + if (controller != null) { + targets.add(controller); + } + } + return targets.toArray(); + } + + private Object[] nameVariants(StructuredQName name) { + if (name == null) { + return new Object[0]; + } + LinkedHashSet variants = new LinkedHashSet<>(); + variants.add(name); + String uri = name.getURI(); + String local = name.getLocalPart(); + String prefix = name.getPrefix(); + if (uri == null) { + uri = ""; + } + if (local == null) { + local = ""; + } + try { + variants.add(new net.sf.saxon.s9api.QName(prefix == null ? "" : prefix, uri, local)); + } catch (Exception ignored) { + // ignore + } + try { + variants.add(new javax.xml.namespace.QName(uri, local)); + } catch (Exception ignored) { + // ignore + } + if (!uri.isEmpty()) { + variants.add("{" + uri + "}" + local); + variants.add("Q{" + uri + "}" + local); + } + String display = name.getDisplayName(); + if (display != null && !display.isEmpty()) { + variants.add(display); + } + if (prefix != null && !prefix.isEmpty() && !local.isEmpty()) { + variants.add(prefix + ":" + local); + } + if (!local.isEmpty()) { + variants.add(local); + } + return variants.toArray(); + } + + private TraceCapture captureFromParameterCollections(Object target, StructuredQName name, Object context) { + TraceCapture aggregate = new TraceCapture(); + if (target == null || name == null) { + return aggregate; + } + Object[] variants = nameVariants(name); + Object[] collections = new Object[]{ + target instanceof Map ? target : null, + invoke(target, "getParameters"), + invoke(target, "getLocalParameters"), + invoke(target, "getGlobalParameters") + }; + for (Object collection : collections) { + if (collection == null) { + continue; + } + if (collection instanceof Map) { + Map map = (Map) collection; + logMapSample(map); + Object introspectedValue = lookupMapValue(map, name, variants); + boolean introspectedConsumed = false; + for (Object variant : variants) { + Runner.diag("captureFromParameterCollections map lookup variant=" + variant + " class=" + map.getClass().getName()); + Object value = map.get(variant); + if (value != null) { + Runner.diag("captureFromParameterCollections map hit class=" + value.getClass().getName()); + } else { + Runner.diag("captureFromParameterCollections map hit null for variant=" + variant); + } + TraceCapture candidate = captureFromResult(value, context); + if (candidate.sequence != null) { + return candidate; + } + mergeCapture(aggregate, candidate); + if (value == null && variant instanceof CharSequence) { + Object alt = map.get(variant.toString()); + if (alt != null) { + Runner.diag("captureFromParameterCollections map alt hit class=" + alt.getClass().getName()); + } else { + Runner.diag("captureFromParameterCollections map alt hit null"); + } + candidate = captureFromResult(alt, context); + if (candidate.sequence != null) { + return candidate; + } + mergeCapture(aggregate, candidate); + } + if (value == null && !introspectedConsumed && introspectedValue != null) { + Runner.diag("captureFromParameterCollections introspection hit class=" + introspectedValue.getClass().getName()); + TraceCapture introspectedCapture = captureFromResult(introspectedValue, context); + if (introspectedCapture.sequence != null) { + return introspectedCapture; + } + mergeCapture(aggregate, introspectedCapture); + introspectedConsumed = true; + } + } + } else { + for (Object variant : variants) { + Object value = invoke(collection, "get", variant); + Runner.diag("captureFromParameterCollections accessor lookup variant=" + variant + " target=" + collection.getClass().getName()); + if (value != null) { + Runner.diag("captureFromParameterCollections accessor hit class=" + value.getClass().getName()); + } else { + Runner.diag("captureFromParameterCollections accessor hit null"); + } + TraceCapture candidate = captureFromResult(value, context); + if (candidate.sequence != null) { + return candidate; + } + mergeCapture(aggregate, candidate); + } + } + } + return aggregate; + } + + private TraceCapture captureFromResult(Object result, Object context) { + return captureFromResult(result, context, newIdentitySet()); + } + + private TraceCapture captureFromResult(Object result, Object context, Set seen) { + TraceCapture capture = new TraceCapture(); + if (result == null) { + return capture; + } + if (result instanceof TraceCapture) { + TraceCapture existing = (TraceCapture) result; + capture.sequence = existing.sequence; + capture.fallback = existing.fallback; + Runner.diag("captureFromResult reused TraceCapture sequence=" + (capture.sequence != null)); + return capture; + } + Sequence seq = evaluateObjectValue(result, context); + if (seq != null) { + Runner.diag("captureFromResult materialized sequence from " + result.getClass().getName()); + capture.sequence = seq; + return capture; + } + if (isInstanceOf(result, "net.sf.saxon.s9api.XdmValue")) { + Object underlying = invoke(result, "getUnderlyingValue"); + Sequence underlyingSeq = evaluateObjectValue(underlying, context, 0, seen != null ? seen : newIdentitySet()); + if (underlyingSeq != null) { + Runner.diag("captureFromResult unwrapped XdmValue to sequence for " + result.getClass().getName()); + capture.sequence = underlyingSeq; + return capture; + } + capture.fallback = result.toString(); + Runner.diag("captureFromResult fallback from XdmValue " + preview(capture.fallback)); + return capture; + } + if (isBinding(result)) { + Runner.diag("captureFromResult evaluating Binding instance " + result.getClass().getName()); + TraceCapture bindingCapture = evaluateBinding(context, result, seen != null ? seen : newIdentitySet()); + mergeCapture(capture, bindingCapture); + return capture; + } + if (result instanceof Item) { + capture.fallback = formatItem((Item) result); + Runner.diag("captureFromResult formatted Item fallback " + preview(capture.fallback)); + return capture; + } + if (result instanceof Iterable) { + Sequence seqFromIterable = sequenceFromIterable((Iterable) result); + if (seqFromIterable != null) { + Runner.diag("captureFromResult built sequence from Iterable " + result.getClass().getName()); + capture.sequence = seqFromIterable; + return capture; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Object o : (Iterable) result) { + TraceCapture nested = captureFromResult(o, context); + String val = nested.sequence != null ? formatSequence(nested.sequence) : nested.fallback; + if (val == null || val.isEmpty()) { + continue; + } + if (!first) { + sb.append('\n'); + } + first = false; + sb.append(val); + } + capture.fallback = sb.toString(); + Runner.diag("captureFromResult fallback aggregated Iterable " + preview(capture.fallback)); + return capture; + } + if (result != null && result.getClass().isArray()) { + Sequence seqFromArray = sequenceFromArray(result); + if (seqFromArray != null) { + Runner.diag("captureFromResult built sequence from array " + result.getClass().getName()); + capture.sequence = seqFromArray; + return capture; + } + } + capture.fallback = result.toString(); + Runner.diag("captureFromResult generic fallback " + preview(capture.fallback)); + return capture; + } + + private final Set loggedParameterNames = ConcurrentHashMap.newKeySet(); + + private void logContextMap(Map map) { + if (!TRACE_DEBUG || map == null) { + return; + } + int id = System.identityHashCode(map); + if (!loggedMapIds.add(id)) { + return; + } + StringBuilder sb = new StringBuilder(); + int count = 0; + int size = map.size(); + for (Map.Entry entry : map.entrySet()) { + if (count++ >= 6) { + break; + } + Object key = entry.getKey(); + Object value = entry.getValue(); + sb.append(describeKey(key)).append('='); + if (value == null) { + sb.append("null"); + } else { + sb.append(value.getClass().getName()); + if (value instanceof CharSequence) { + sb.append("(\"").append(preview(value.toString())).append("\")"); + } + if (value instanceof Map) { + sb.append("[map]"); + } + } + sb.append("; "); + } + Runner.diag("context map size=" + size + " entries=" + sb); + } + + private Object extractContextFromProperties(Map map) { + return extractContextFromProperties(map, newIdentitySet()); + } + + private Object extractContextFromProperties(Map map, Set seen) { + if (map == null) { + return null; + } + if (seen != null && !seen.add(map)) { + return null; + } + Object[] candidateKeys = new Object[]{ + "context", + "xpathContext", + "XPathContext", + "majorContext", + "contextObject" + }; + for (Object key : candidateKeys) { + Object value = map.get(key); + if (isXPathContext(value)) { + return value; + } + } + for (Object value : map.values()) { + if (isXPathContext(value)) { + return value; + } + if (value instanceof Map) { + Object nested = extractContextFromProperties((Map) value, seen); + if (isXPathContext(nested)) { + return nested; + } + } + } + return null; + } + + private boolean isXPathContext(Object value) { + return isInstanceOf(value, "net.sf.saxon.expr.XPathContext"); + } + + private TraceCapture captureParameterValue(StructuredQName name) { + TraceCapture capture = new TraceCapture(); + if (transformer == null || name == null) { + return capture; + } + try { + net.sf.saxon.s9api.QName qName = new net.sf.saxon.s9api.QName( + name.getPrefix() == null ? "" : name.getPrefix(), + name.getURI() == null ? "" : name.getURI(), + name.getLocalPart()); + XdmValue paramValue = transformer.getParameter(qName); + if (paramValue != null) { + Runner.diag("captureParameterValue transformer hit " + qName.getClarkName()); + return captureFromResult(paramValue, null); + } + } catch (Exception e) { + Runner.diag("captureParameterValue error: " + e.getMessage()); + } + return capture; + } + + private TraceCapture evaluateFromController(StructuredQName name, Object context) { + TraceCapture aggregate = new TraceCapture(); + if (transformer == null || name == null) { + return aggregate; + } + Object controller = transformer.getUnderlyingController(); + if (controller == null) { + return aggregate; + } + Runner.diag("evaluateFromController using controller=" + controller.getClass().getName()); + Object executable = invoke(controller, "getExecutable"); + Object packageData = executable != null ? invoke(executable, "getTopLevelPackage") : null; + Object bindery = null; + if (packageData != null) { + bindery = invoke(controller, "getBindery", packageData); + } + if (bindery == null) { + bindery = invoke(controller, "getBindery"); + } + + Object effectiveContext = context; + if (effectiveContext == null) { + effectiveContext = invoke(controller, "newXPathContext"); + } + + if (packageData != null) { + Object globalList = invoke(packageData, "getGlobalVariableList"); + if (globalList instanceof Iterable) { + for (Object varObj : (Iterable) globalList) { + if (varObj == null) { + continue; + } + StructuredQName varName = toStructuredQName(invoke(varObj, "getVariableQName")); + if (varName == null) { + varName = toStructuredQName(invoke(varObj, "getObjectName")); + } + if (varName == null || !varName.equals(name)) { + continue; + } + Runner.diag("evaluateFromController matched global variable class=" + varObj.getClass().getName()); + if (bindery != null) { + TraceCapture byValue = captureFromResult(invoke(bindery, "getGlobalVariableValue", varObj), effectiveContext); + if (byValue.sequence != null) { + Runner.diag("bindery getGlobalVariableValue match for " + name.getDisplayName()); + return byValue; + } + mergeCapture(aggregate, byValue); + + Object slotObj = invoke(varObj, "getBinderySlotNumber"); + if (slotObj instanceof Number) { + int slot = ((Number) slotObj).intValue(); + TraceCapture bySlot = captureFromResult(invoke(bindery, "getGlobalVariable", slot), effectiveContext); + if (bySlot.sequence != null) { + Runner.diag("bindery getGlobalVariable slot=" + slot + " match for " + name.getDisplayName()); + return bySlot; + } + mergeCapture(aggregate, bySlot); + } + } + TraceCapture evaluated = captureFromResult(invoke(varObj, "evaluateVariable", effectiveContext), effectiveContext); + if (evaluated.sequence != null) { + Runner.diag("evaluateFromController evaluateVariable hit for " + name.getDisplayName()); + return evaluated; + } + mergeCapture(aggregate, evaluated); + } + } + } + + if (executable != null) { + Object paramMap = invoke(executable, "getGlobalParameters"); + if (paramMap instanceof Map) { + Map map = (Map) paramMap; + Object candidate = map.get(name); + if (candidate == null) { + candidate = map.get(name.getStructuredQName()); + } + if (candidate == null) { + candidate = map.get(name.getDisplayName()); + } + if (candidate != null) { + Runner.diag("evaluateFromController global parameter class=" + candidate.getClass().getName()); + TraceCapture fromParam = evaluateBinding(effectiveContext, candidate, newIdentitySet()); + if (fromParam.sequence != null) { + return fromParam; + } + mergeCapture(aggregate, fromParam); + } + } + } + + return aggregate; + } + + private void logMapSample(Map map) { + if (!TRACE_DEBUG || map == null) { + return; + } + int id = System.identityHashCode(map); + if (!loggedMapIds.add(id)) { + return; + } + StringBuilder sb = new StringBuilder(); + int count = 0; + for (Map.Entry entry : map.entrySet()) { + if (count++ >= 5) { + break; + } + Object key = entry.getKey(); + Object value = entry.getValue(); + sb.append(describeKey(key)); + sb.append(" -> "); + sb.append(value != null ? value.getClass().getName() : "null"); + if (value instanceof Map) { + sb.append("[map]"); + } + sb.append("; "); + } + Runner.diag("captureFromParameterCollections map sample size=" + map.size() + " keys=" + sb); + } + + private String describeKey(Object key) { + if (key == null) { + return "null"; + } + StructuredQName q = toStructuredQName(key); + if (q != null) { + return "QName(" + q.getDisplayName() + ")"; + } + return key.getClass().getName() + '(' + key.toString() + ')'; + } + + private Object lookupMapValue(Map map, StructuredQName name, Object[] variants) { + if (map == null || name == null) { + return null; + } + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (matchesKey(key, name, variants)) { + Runner.diag("lookupMapValue matched key " + describeKey(key)); + return entry.getValue(); + } + } + return null; + } + + private boolean matchesKey(Object key, StructuredQName target, Object[] variants) { + if (key == null) { + return false; + } + StructuredQName keyQName = toStructuredQName(key); + if (keyQName != null) { + if (keyQName.equals(target)) { + return true; + } + String keyDisplay = keyQName.getDisplayName(); + if (matchesVariantString(keyDisplay, variants, target)) { + return true; + } + } + if (matchesVariantString(key.toString(), variants, target)) { + return true; + } + Object extracted = extractNameProperty(key); + if (extracted instanceof StructuredQName) { + StructuredQName eq = (StructuredQName) extracted; + return eq.equals(target) || matchesVariantString(eq.getDisplayName(), variants, target); + } + if (extracted instanceof javax.xml.namespace.QName) { + StructuredQName converted = toStructuredQName(extracted); + if (converted != null) { + return converted.equals(target) || matchesVariantString(converted.getDisplayName(), variants, target); + } + } + if (extracted instanceof CharSequence) { + return matchesVariantString(extracted.toString(), variants, target); + } + return false; + } + + private boolean matchesVariantString(String candidate, Object[] variants, StructuredQName target) { + if (candidate == null) { + return false; + } + String trimmed = candidate.trim(); + if (trimmed.isEmpty()) { + return false; + } + if (target != null) { + String display = target.getDisplayName(); + if (display != null && trimmed.equals(display)) { + return true; + } + String uri = target.getURI() == null ? "" : target.getURI(); + String local = target.getLocalPart() == null ? "" : target.getLocalPart(); + String expanded = "{" + uri + "}" + local; + String saxonExpanded = "Q{" + uri + "}" + local; + if (trimmed.equals(expanded) || trimmed.equals(saxonExpanded)) { + return true; + } + if (target.getPrefix() != null && !target.getPrefix().isEmpty()) { + String prefixed = target.getPrefix() + ':' + local; + if (trimmed.equals(prefixed)) { + return true; + } + } + if (trimmed.equals(local)) { + return true; + } + } + if (variants != null) { + for (Object variant : variants) { + if (variant == null) { + continue; + } + if (trimmed.equals(variant.toString())) { + return true; + } + } + } + return false; + } + + private Object extractNameProperty(Object key) { + if (key == null) { + return null; + } + String[] props = new String[]{ + "variableQName", + "objectName", + "name", + "qName", + "displayName" + }; + for (String prop : props) { + Object value = getProperty(key, prop); + if (value != null) { + return value; + } + } + String[] methods = new String[]{ + "getVariableQName", + "getObjectName", + "getVariableName", + "getQName", + "getDisplayName", + "getStructuredQName" + }; + for (String method : methods) { + Object value = invoke(key, method); + if (value != null) { + return value; + } + } + return null; + } + + private StructuredQName toStructuredQName(Object value) { + if (value == null) { + return null; + } + if (value instanceof StructuredQName) { + return (StructuredQName) value; + } + if (value instanceof javax.xml.namespace.QName) { + javax.xml.namespace.QName q = (javax.xml.namespace.QName) value; + return new StructuredQName(q.getPrefix() == null ? "" : q.getPrefix(), + q.getNamespaceURI() == null ? "" : q.getNamespaceURI(), + q.getLocalPart()); + } + if (value instanceof net.sf.saxon.s9api.QName) { + net.sf.saxon.s9api.QName q = (net.sf.saxon.s9api.QName) value; + return new StructuredQName(q.getPrefix(), q.getNamespaceURI(), q.getLocalName()); + } + Object extracted = extractNameProperty(value); + if (extracted != null && extracted != value) { + return toStructuredQName(extracted); + } + return null; + } + + private void mergeCapture(TraceCapture base, TraceCapture candidate) { + if (base == null || candidate == null) { + return; + } + if (base.sequence == null && candidate.sequence != null) { + base.sequence = candidate.sequence; + } + if (base.fallback == null && candidate.fallback != null) { + base.fallback = candidate.fallback; + } + } + + private Sequence evaluateObjectValue(Object value, Object context) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + return evaluateObjectValue(value, context, 0, seen); + } + + private Sequence evaluateObjectValue(Object value, Object context, int depth, Set seen) { + if (value == null || depth > 6) { + return null; + } + if (seen != null && !seen.add(value)) { + return null; + } + Runner.diag("evaluateObjectValue depth=" + depth + " type=" + value.getClass().getName()); + Sequence seq = toSequence(value); + if (seq != null) { + Runner.diag("evaluateObjectValue toSequence success " + value.getClass().getName()); + return materialize(seq); + } + if (value instanceof SequenceIterator) { + Runner.diag("evaluateObjectValue materialize SequenceIterator " + value.getClass().getName()); + return materializeIterator((SequenceIterator) value); + } + if (value instanceof Item) { + Runner.diag("evaluateObjectValue wrap single Item " + value.getClass().getName()); + return materializeItem((Item) value); + } + if (isExpression(value)) { + Sequence exprSeq = evaluateExpressionValue(value, context, depth + 1, seen); + if (exprSeq != null) { + Runner.diag("evaluateObjectValue expression produced sequence " + value.getClass().getName()); + return exprSeq; + } + } + if (isInstanceOf(value, "net.sf.saxon.s9api.XdmValue")) { + Object underlying = invoke(value, "getUnderlyingValue"); + Sequence underlyingSeq = evaluateObjectValue(underlying, context, depth + 1, seen); + if (underlyingSeq != null) { + return underlyingSeq; + } + } + String[] methodNames = new String[]{ + "materialize", + "evaluate", + "iterate", + "getValue", + "value", + "call", + "apply", + "asSequence", + "asAtomic", + "reduce", + "expand", + "snapshot", + "makeSequence" + }; + for (String name : methodNames) { + Sequence viaContext = invokeAndMaterialize(value, name, context, depth, seen); + if (viaContext != null) { + return viaContext; + } + Sequence viaEmpty = invokeAndMaterialize(value, name, null, depth, seen); + if (viaEmpty != null) { + return viaEmpty; + } + } + if (value instanceof Iterable) { + Sequence seqFromIterable = sequenceFromIterable((Iterable) value); + if (seqFromIterable != null) { + return seqFromIterable; + } + } + if (value != null && value.getClass().isArray()) { + Sequence seqFromArray = sequenceFromArray(value); + if (seqFromArray != null) { + return seqFromArray; + } + } + return null; + } + + private Sequence invokeAndMaterialize(Object target, String methodName, Object context, int depth, Set seen) { + if (target == null) { + return null; + } + Object result; + if (context != null) { + result = invoke(target, methodName, context); + if (result != null) { + Runner.diag("invokeAndMaterialize " + target.getClass().getName() + "." + methodName + " -> " + result.getClass().getName()); + } + Sequence seq = evaluateObjectValue(result, context, depth + 1, seen); + if (seq != null) { + return seq; + } + } + result = invoke(target, methodName); + if (result != null) { + Runner.diag("invokeAndMaterialize " + target.getClass().getName() + "." + methodName + "() -> " + result.getClass().getName()); + } + return evaluateObjectValue(result, context, depth + 1, seen); + } + + private Sequence materializeItem(Item item) { + if (item == null) { + return null; + } + try { + List items = new ArrayList<>(); + items.add(item); + return SequenceExtent.makeSequenceExtent(items); + } catch (Exception ignored) { + return null; + } + } + + private Sequence sequenceFromIterable(Iterable iterable) { + if (iterable == null) { + return null; + } + List items = new ArrayList<>(); + for (Object element : iterable) { + if (element instanceof Item) { + items.add((Item) element); + } + } + if (!items.isEmpty()) { + try { + return SequenceExtent.makeSequenceExtent(items); + } catch (Exception ignored) { + return null; + } + } + return null; + } + + private Sequence sequenceFromArray(Object array) { + if (array == null || !array.getClass().isArray()) { + return null; + } + int len = java.lang.reflect.Array.getLength(array); + List items = new ArrayList<>(len); + for (int i = 0; i < len; i++) { + Object element = java.lang.reflect.Array.get(array, i); + if (element instanceof Item) { + items.add((Item) element); + } + } + if (!items.isEmpty()) { + try { + return SequenceExtent.makeSequenceExtent(items); + } catch (Exception ignored) { + } + } + return null; + } + + private boolean isExpression(Object value) { + return isInstanceOf(value, "net.sf.saxon.expr.Expression"); + } + + private boolean isBinding(Object value) { + return isInstanceOf(value, "net.sf.saxon.expr.parser.Binding") || + isInstanceOf(value, "net.sf.saxon.expr.instruct.BindingReference"); + } + + private Sequence evaluateExpressionValue(Object expression, Object context, int depth, Set seen) { + if (expression == null) { + return null; + } + Object[] params = context != null ? new Object[]{context} : new Object[0]; + Object iterateResult = invoke(expression, "iterate", params); + Sequence seq = evaluateObjectValue(iterateResult, context, depth + 1, seen); + if (seq != null) { + Runner.diag("evaluateExpressionValue iterate produced sequence for " + expression.getClass().getName()); + return seq; + } + if (context != null) { + iterateResult = invoke(expression, "iterate"); + seq = evaluateObjectValue(iterateResult, context, depth + 1, seen); + if (seq != null) { + Runner.diag("evaluateExpressionValue iterate() produced sequence for " + expression.getClass().getName()); + return seq; + } + } + String[] methodNames = new String[]{ + "evaluateItem", + "evaluateVariable", + "evaluateLocalVariable", + "evaluate", + "call", + "process", + "deliver" + }; + for (String name : methodNames) { + Object result = context != null ? invoke(expression, name, context) : null; + seq = evaluateObjectValue(result, context, depth + 1, seen); + if (seq != null) { + Runner.diag("evaluateExpressionValue " + name + "(context) produced sequence for " + expression.getClass().getName()); + return seq; + } + if (context != null) { + result = invoke(expression, name); + seq = evaluateObjectValue(result, context, depth + 1, seen); + if (seq != null) { + Runner.diag("evaluateExpressionValue " + name + "() produced sequence for " + expression.getClass().getName()); + return seq; + } + } + } + return null; + } + + private boolean isInstanceOf(Object value, String className) { + if (value == null || className == null) { + return false; + } + try { + Class cls = Class.forName(className); + return cls.isInstance(value); + } catch (Exception ignored) { + return false; + } + } + + private Object invoke(Object target, String methodName, Object... params) { + if (target == null) { + return null; + } + try { + Class[] types = new Class[params.length]; + for (int i = 0; i < params.length; i++) { + Object p = params[i]; + types[i] = p != null && p.getClass() == Integer.class ? int.class : (p == null ? Object.class : p.getClass()); + } + Method m = findMethod(target.getClass(), methodName, types); + if (m == null && params.length == 1 && params[0] instanceof Integer) { + m = findMethod(target.getClass(), methodName, new Class[]{int.class}); + } + if (m == null) { + return null; + } + m.setAccessible(true); + return m.invoke(target, params); + } catch (Exception e) { + return null; + } + } + + private Method findMethod(Class type, String name, Class[] parameterTypes) { + try { + return type.getMethod(name, parameterTypes); + } catch (Exception ignored) { + // fall through to compatibility search + } + Method[] methods = type.getMethods(); + for (Method m : methods) { + if (!m.getName().equals(name) || m.getParameterCount() != parameterTypes.length) { + continue; + } + Class[] targetParams = m.getParameterTypes(); + boolean compatible = true; + for (int i = 0; i < targetParams.length; i++) { + Class requested = parameterTypes[i]; + if (!isAssignable(targetParams[i], requested)) { + compatible = false; + break; + } + } + if (compatible) { + return m; + } + } + return null; + } + + private boolean isAssignable(Class targetType, Class requestedType) { + if (targetType == null) { + return false; + } + if (requestedType == null || requestedType == Object.class) { + return true; + } + if (targetType.isPrimitive()) { + targetType = primitiveToWrapper(targetType); + } + if (requestedType.isPrimitive()) { + requestedType = primitiveToWrapper(requestedType); + } + if (targetType == null) { + return false; + } + if (requestedType == null) { + return true; + } + return targetType.isAssignableFrom(requestedType); + } + + private Class primitiveToWrapper(Class primitive) { + if (primitive == null) { + return null; + } + if (!primitive.isPrimitive()) { + return primitive; + } + if (primitive == boolean.class) return Boolean.class; + if (primitive == byte.class) return Byte.class; + if (primitive == short.class) return Short.class; + if (primitive == char.class) return Character.class; + if (primitive == int.class) return Integer.class; + if (primitive == long.class) return Long.class; + if (primitive == float.class) return Float.class; + if (primitive == double.class) return Double.class; + return primitive; + } + + private Object firstCandidate(Object instructionInfo) { + String[] keys = new String[]{"value", "result", "variableValue", "actualValue", "selectValue", "sequence", "selectExpression", "containedValue"}; + for (String key : keys) { + Object val = getProperty(instructionInfo, key); + if (val != null) { + return val; + } + } + return null; + } + + private Integer extractSlot(Object instructionInfo) { + String[] keys = new String[]{"slot", "slot-number", "slotNumber", "slotIndex"}; + for (String key : keys) { + Object val = getProperty(instructionInfo, key); + if (val instanceof Number) { + return ((Number) val).intValue(); + } + } + try { + Method m = instructionInfo.getClass().getMethod("getSlotNumber"); + Object result = m.invoke(instructionInfo); + if (result instanceof Number) { + return ((Number) result).intValue(); + } + } catch (Exception ignored) { + // ignore + } + return null; + } + + private Object getProperty(Object instructionInfo, String key) { + if (instructionInfo == null) { + return null; + } + try { + Method m = instructionInfo.getClass().getMethod("getProperty", String.class); + return m.invoke(instructionInfo, key); + } catch (Exception ignored) { + return null; + } + } + + private boolean isVariable(Object instructionInfo) { + if (instructionInfo == null) { + return false; + } + Integer construct = getConstructType(instructionInfo); + if (construct != null) { + if (construct == StandardNames.XSL_VARIABLE || + construct == StandardNames.XSL_PARAM || + construct == StandardNames.XSL_WITH_PARAM) { + return true; + } + } + StructuredQName name = getVariableName(instructionInfo); + if (name != null) { + return true; + } + String className = instructionInfo.getClass().getName(); + if (className != null) { + if (className.contains(".LetExpression") || + className.contains(".GlobalVariable") || + className.contains(".GlobalParameter") || + className.contains(".LocalVariable") || + className.contains(".Assignation")) { + return true; + } + } + return false; + } + + private Integer getConstructType(Object instructionInfo) { + if (instructionInfo == null) { + return null; + } + try { + Method m = instructionInfo.getClass().getMethod("getConstructType"); + Object val = m.invoke(instructionInfo); + if (val instanceof Number) { + return ((Number) val).intValue(); + } + } catch (Exception ignored) { + // ignore + } + return null; + } + + private StructuredQName getVariableName(Object instructionInfo) { + if (instructionInfo == null) { + return null; + } + try { + Method m = instructionInfo.getClass().getMethod("getObjectName"); + Object result = m.invoke(instructionInfo); + if (result instanceof StructuredQName) { + return (StructuredQName) result; + } + } catch (Exception ignored) { + // ignore + } + try { + Method m = instructionInfo.getClass().getMethod("getVariableQName"); + Object result = m.invoke(instructionInfo); + if (result instanceof StructuredQName) { + return (StructuredQName) result; + } + } catch (Exception ignored) { + // ignore + } + try { + Method m = instructionInfo.getClass().getMethod("getVariableName"); + Object result = m.invoke(instructionInfo); + if (result instanceof StructuredQName) { + return (StructuredQName) result; + } + if (result instanceof String) { + return new StructuredQName("", "", (String) result); + } + } catch (Exception ignored) { + // ignore + } + Object prop = getProperty(instructionInfo, "name"); + if (prop instanceof StructuredQName) { + return (StructuredQName) prop; + } + if (prop instanceof String) { + return new StructuredQName("", "", (String) prop); + } + return null; + } + + private Sequence toSequence(Object value) { + if (value == null) { + return null; + } + if (value instanceof Sequence) { + return (Sequence) value; + } + if (value instanceof GroundedValue) { + return (GroundedValue) value; + } + return null; + } + + private Sequence materialize(Sequence sequence) { + if (sequence instanceof GroundedValue) { + return sequence; + } + try { + Method m = sequence.getClass().getMethod("materialize"); + Object result = m.invoke(sequence); + if (result instanceof Sequence) { + return (Sequence) result; + } + if (result instanceof GroundedValue) { + return (GroundedValue) result; + } + } catch (Exception ignored) { + // ignore + } + return sequence; + } + + private Sequence materializeIterator(SequenceIterator iterator) { + if (iterator == null) { + return null; + } + List items = new ArrayList<>(); + try { + while (true) { + Item item = iterator.next(); + if (item == null) { + break; + } + items.add(item); + } + } catch (Exception ignored) { + return null; + } finally { + try { + iterator.close(); + } catch (Exception ignored) { + // ignore + } + } + try { + return SequenceExtent.makeSequenceExtent(items); + } catch (Exception ignored) { + return null; + } + } + + private GroundedValue toGroundedValue(Sequence sequence) { + if (sequence instanceof GroundedValue) { + return (GroundedValue) sequence; + } + try { + Class tool = Class.forName("net.sf.saxon.om.SequenceTool"); + Method m = tool.getMethod("toGroundedValue", Sequence.class); + Object result = m.invoke(null, sequence); + if (result instanceof GroundedValue) { + return (GroundedValue) result; + } + } catch (Exception ignored) { + // ignore + } + return null; + } + + private String formatSequence(Sequence sequence) { + if (sequence == null) { + return ""; + } + try { + Sequence materialized = materialize(sequence); + GroundedValue grounded = toGroundedValue(materialized); + if (grounded == null) { + return materialized.toString(); + } + if (grounded == EmptySequence.getInstance()) { + return ""; + } + XdmValue xdmValue = XdmValue.wrap(grounded); + if (!xdmValue.iterator().hasNext()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (XdmItem item : xdmValue) { + if (!first) { + sb.append('\n'); + } + first = false; + if (item instanceof XdmNode) { + StringWriter buffer = new StringWriter(); + Serializer serializer = processor.newSerializer(buffer); + serializer.serializeNode((XdmNode) item); + serializer.close(); + sb.append(buffer.toString()); + } else { + sb.append(safeItemString(item)); + } + } + return sb.toString(); + } catch (SaxonApiException e) { + return "(error: " + e.getMessage() + ")"; + } + } + + private String safeItemString(XdmItem item) { + if (item == null) { + return ""; + } + if (item instanceof XdmNode) { + return item.toString(); + } + try { + return item.getStringValue(); + } catch (IllegalStateException | UnsupportedOperationException e) { + return item.toString(); + } + } + + private void printDebug(String phase, StructuredQName name) { + if (!TRACE_DEBUG || name == null) { + return; + } + out.println("TRACE_DEBUG phase=" + phase + " variable=" + name.getDisplayName()); + } + + private void debugEvent(String phase, Object instructionInfo) { + if (!TRACE_DEBUG) { + return; + } + if (debugCounter++ > 50) { + return; + } + StructuredQName name = getVariableName(instructionInfo); + Integer construct = getConstructType(instructionInfo); + out.println("TRACE_DEBUG raw_event phase=" + phase + " construct=" + construct + " name=" + (name != null ? name.getDisplayName() : "null") + " class=" + (instructionInfo != null ? instructionInfo.getClass().getName() : "null")); + } + + private String formatItem(Item item) { + if (item == null) { + return ""; + } + if (item instanceof NodeInfo) { + try { + StringWriter buffer = new StringWriter(); + Serializer serializer = processor.newSerializer(buffer); + serializer.serializeXdmValue(XdmValue.wrap((NodeInfo) item)); + serializer.close(); + return buffer.toString(); + } catch (SaxonApiException e) { + return item.getStringValue(); + } + } + return item.getStringValue(); + } + + private static final class Frame { + StructuredQName name; + Object instruction; + Object context; + Object properties; + } + + private static final class TraceCapture { + Sequence sequence; + String fallback; + } + } } diff --git a/backend/ext/com/xsltplayground/ext/CustomFunctions.java b/backend/ext/com/xsltplayground/ext/CustomFunctions.java index 202442ae..021eaaf8 100644 --- a/backend/ext/com/xsltplayground/ext/CustomFunctions.java +++ b/backend/ext/com/xsltplayground/ext/CustomFunctions.java @@ -16,6 +16,8 @@ import javax.xml.transform.stream.StreamResult; import org.w3c.dom.Node; public class CustomFunctions { + private static final String NAMESPACE_URI = "http://www.tibco.com/bw/xslt/custom-functions"; + public static String uuid() { return UUID.randomUUID().toString(); } @@ -387,12 +389,12 @@ public class CustomFunctions { // 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. + // (e.g., xmlns:tib="http://www.tibco.com/bw/xslt/custom-functions" and + // tib: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(); + final String namespace = NAMESPACE_URI; java.lang.reflect.Method[] methods = CustomFunctions.class.getDeclaredMethods(); for (java.lang.reflect.Method m : methods) { diff --git a/backend/src/main.go b/backend/src/main.go index a2fa0587..a2d5b99b 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -270,55 +270,67 @@ func main() { duration := time.Since(start).Milliseconds() log.Printf("transformation done in %dms", 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() - } + 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) + log.Printf("trace file %s size=%d bytes", tracePath, len(traceText)) + } else { + log.Printf("trace read error: %v", err) + } + } + if traceText == "" { + traceText = stderr.String() + if traceText != "" { + log.Printf("trace fallback from stderr size=%d bytes", len(traceText)) + } + } - // 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]}) - } - } - } - } + // Parse block-based variable traces and legacy single-line ones + lines := strings.Split(traceText, "\n") + filtered := make([]string, 0, len(lines)) + capturing := false + var currName string + var buf []string + for _, l := range lines { + if strings.HasPrefix(l, "TRACE_DEBUG") { + continue + } + filtered = append(filtered, l) + 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]}) + } + } + } + traceText = strings.Join(filtered, "\n") + } - c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText}) + c.JSON(http.StatusOK, TransformResponse{Result: string(result), DurationMs: duration, Trace: traceEntries, TraceText: traceText}) }) r.GET("/", func(c *gin.Context) { diff --git a/backend/src/main_test.go b/backend/src/main_test.go new file mode 100644 index 00000000..f0d4e323 --- /dev/null +++ b/backend/src/main_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestLoadConfigAppliesEnvOverrides(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://env") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/creds.json") + t.Setenv("SAXON_CLASSPATH", "env-classpath") + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "app.config") + payload := `{ + "port": "3000", + "saxon_classpath": "classpath", + "database_url": "postgres://file", + "firebase_credentials": "/tmp/file-creds.json" + }` + if err := os.WriteFile(cfgPath, []byte(payload), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := loadConfig(cfgPath) + if err != nil { + t.Fatalf("loadConfig returned error: %v", err) + } + + if cfg.DatabaseURL != "postgres://env" { + t.Fatalf("expected env database url, got %s", cfg.DatabaseURL) + } + if cfg.FirebaseCredentials != "/tmp/creds.json" { + t.Fatalf("expected env firebase creds, got %s", cfg.FirebaseCredentials) + } + if cfg.SaxonClasspath != "env-classpath" { + t.Fatalf("expected env saxon classpath, got %s", cfg.SaxonClasspath) + } +} + +func TestCorsMiddlewareSetsHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware()) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + headers := rec.Result().Header + if headers.Get("Access-Control-Allow-Origin") != "*" { + t.Fatalf("missing CORS origin header") + } + if headers.Get("Access-Control-Allow-Methods") == "" { + t.Fatalf("missing CORS methods header") + } + if headers.Get("Access-Control-Allow-Headers") == "" { + t.Fatalf("missing CORS headers header") + } +} + +func TestCorsMiddlewareHandlesOptionsRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware()) + + handlerCalled := false + router.Any("/test", func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodOptions, "/test", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d", rec.Code) + } + if handlerCalled { + t.Fatalf("handler should not be called for OPTIONS requests") + } +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d4f8e4a9..782648d9 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -4,6 +4,7 @@ services: build: ./backend environment: VITE_GO_PRO: "false" + XSLT_TRACE_DEBUG: "true" ports: - "8000:8000" frontend: diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index e6cd981c..cb7666cc 100755 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -1,14 +1,27 @@ #!/bin/sh set -e + +escape_js_string() { + # Escape backslashes, double quotes and newlines so runtime env stays valid JS + printf '%s' "$1" | sed ':a;N;$!ba;s/\\/\\\\/g;s/\n/\\n/g;s/"/\\"/g' +} + +BACKEND_URL_ESC=$(escape_js_string "${VITE_BACKEND_URL}") +GO_PRO_ESC=$(escape_js_string "${VITE_GO_PRO}") +ADSENSE_CLIENT_ESC=$(escape_js_string "${VITE_ADSENSE_CLIENT}") +ADSENSE_SLOT_ESC=$(escape_js_string "${VITE_ADSENSE_SLOT}") +FIREBASE_CONFIG_ESC=$(escape_js_string "${VITE_FIREBASE_CONFIG}") +GA_ID_ESC=$(escape_js_string "${VITE_GA_ID}") + # Write runtime environment variables for the frontend cat </usr/share/nginx/html/env.js window.env = { - VITE_BACKEND_URL: "${VITE_BACKEND_URL}", - VITE_GO_PRO: "${VITE_GO_PRO}", - VITE_ADSENSE_CLIENT: "${VITE_ADSENSE_CLIENT}", - VITE_ADSENSE_SLOT: "${VITE_ADSENSE_SLOT}", - VITE_FIREBASE_CONFIG: "${VITE_FIREBASE_CONFIG}", - VITE_GA_ID: "${VITE_GA_ID}" + VITE_BACKEND_URL: "${BACKEND_URL_ESC}", + VITE_GO_PRO: "${GO_PRO_ESC}", + VITE_ADSENSE_CLIENT: "${ADSENSE_CLIENT_ESC}", + VITE_ADSENSE_SLOT: "${ADSENSE_SLOT_ESC}", + VITE_FIREBASE_CONFIG: "${FIREBASE_CONFIG_ESC}", + VITE_GA_ID: "${GA_ID_ESC}" }; EOF diff --git a/frontend/index.html b/frontend/index.html index f0255334..91c509ec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,7 @@ +