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 @@ +