mirror of
https://github.com/alexandrev/xslt-lab.git
synced 2026-09-20 04:23:16 +00:00
Merge pull request #33 from alexandrev/codex/fix-xml-parameter-type-issue
Fix XML parameter passing
This commit is contained in:
@@ -196,5 +196,6 @@ cython_debug/
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/server
|
||||
backend/src/.gocache
|
||||
/config/credentials.json
|
||||
credentials.json
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -67,6 +76,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="http://www.tibco.com/bw/xslt/custom-functions"`. 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
|
||||
@@ -141,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
|
||||
```
|
||||
|
||||
|
||||
+16
-7
@@ -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 curl
|
||||
|
||||
# Establecer directorio de trabajo
|
||||
WORKDIR /app/src
|
||||
@@ -10,14 +10,20 @@ 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/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
|
||||
|
||||
WORKDIR /app/src
|
||||
|
||||
# Etapa final (runtime)
|
||||
FROM alpine:latest
|
||||
|
||||
@@ -33,9 +39,12 @@ 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
|
||||
COPY --from=builder /app/ext/custom-functions.jar /opt/saxon/
|
||||
|
||||
|
||||
# Exponer el puerto por defecto y ejecutar el binario
|
||||
EXPOSE 8000
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,616 @@
|
||||
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 {
|
||||
private static final String NAMESPACE_URI = "http://www.tibco.com/bw/xslt/custom-functions";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Saxon HE integration
|
||||
// ---------------------------------------------------------------------
|
||||
// Saxon-HE doesn't support reflexive java: calls. To keep XSLT unchanged
|
||||
// (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 = NAMESPACE_URI;
|
||||
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
-6
@@ -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,20 +192,51 @@ func main() {
|
||||
cmdArgs = append(cmdArgs,
|
||||
"-cp",
|
||||
config.SaxonClasspath,
|
||||
"net.sf.saxon.Transform",
|
||||
"com.xsltplayground.Runner",
|
||||
"-s:"+inputPath,
|
||||
"-xsl:"+xsltPath,
|
||||
"-o:"+outputPath,
|
||||
)
|
||||
for k, v := range req.Parameters {
|
||||
cmdArgs = append(cmdArgs, fmt.Sprintf("%s=%s", k, v))
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
// 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++
|
||||
}
|
||||
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("+++++++++++++ msg %s", strings.Join(cmdArgs, "\n"))
|
||||
|
||||
cmd := exec.Command("java", "@"+argsPath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
@@ -230,7 +269,68 @@ 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)
|
||||
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")
|
||||
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})
|
||||
})
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
build: ./backend
|
||||
environment:
|
||||
VITE_GO_PRO: "false"
|
||||
XSLT_TRACE_DEBUG: "true"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
frontend:
|
||||
|
||||
+19
-6
@@ -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 <<EOF >/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
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<meta property="og:url" content="https://xsltplayground.com/" />
|
||||
<meta property="og:image" content="/logo.svg" />
|
||||
<link rel="canonical" href="https://xsltplayground.com/" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script type="module" src="/env.js"></script>
|
||||
<script>
|
||||
if (window.env && window.env.VITE_ADSENSE_CLIENT) {
|
||||
|
||||
Generated
+1455
-6
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "xslt-playground",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -17,7 +18,11 @@
|
||||
"xml-formatter": "^3.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+1344
-282
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import App from "./App";
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
default: () => <div data-testid="monaco-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("ga-4-react", () => {
|
||||
return {
|
||||
default: class {
|
||||
initialize() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ result: "<root/>", duration_ms: 5 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.env = {
|
||||
VITE_BACKEND_URL: "",
|
||||
VITE_GA_ID: "",
|
||||
VITE_GO_PRO: "false",
|
||||
VITE_APP_VERSION: "test",
|
||||
VITE_NEWS_URL: "https://example.com/news",
|
||||
VITE_REPO_URL: "https://example.com/repo",
|
||||
};
|
||||
window.adsbygoogle = [];
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
delete window.env;
|
||||
});
|
||||
|
||||
describe("App bootstrap", () => {
|
||||
it("renders without crashing", async () => {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(screen.getByText(/xsltplayground\.com/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export default function Buymeacoffee() {
|
||||
useEffect(() => {
|
||||
const script = document.createElement("script");
|
||||
const div = document.getElementById("supportByBMC");
|
||||
script.setAttribute("data-name", "BMC-Widget");
|
||||
script.src = "https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js";
|
||||
script.setAttribute("data-id", "alexandrev");
|
||||
script.setAttribute("data-description", "Support me on Buy me a coffee!");
|
||||
script.setAttribute(
|
||||
"data-message",
|
||||
"Thank you for visiting my website. If this app has helped you in anyway, consider buying us a coffee. ✨😎",
|
||||
);
|
||||
script.setAttribute("data-color", "#FFDD00");
|
||||
script.setAttribute("data-position", "Right");
|
||||
script.setAttribute("data-x_margin", "18");
|
||||
script.setAttribute("data-y_margin", "18");
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
script.onload = function () {
|
||||
var evt = document.createEvent("Event");
|
||||
evt.initEvent("DOMContentLoaded", false, false);
|
||||
window.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
div.appendChild(script);
|
||||
}, []);
|
||||
|
||||
return <div id="supportByBMC"></div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const SCRIPT_ID = "bmc-widget";
|
||||
|
||||
export default function BuyMeACoffee() {
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.setAttribute("data-name", "BMC-Widget");
|
||||
script.src = "https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js";
|
||||
script.setAttribute("data-id", "alexandrev");
|
||||
script.setAttribute("data-description", "Support me on Buy me a coffee!");
|
||||
script.setAttribute(
|
||||
"data-message",
|
||||
"Thanks for using XSLT Playground. If it helped you, feel free to buy me a coffee ☕",
|
||||
);
|
||||
script.setAttribute("data-color", "#FFDD00");
|
||||
script.setAttribute("data-position", "Right");
|
||||
script.setAttribute("data-x_margin", "24");
|
||||
script.setAttribute("data-y_margin", "24");
|
||||
script.async = true;
|
||||
containerRef.current.appendChild(script);
|
||||
|
||||
const handleLoad = () => {
|
||||
const evt = document.createEvent("Event");
|
||||
evt.initEvent("DOMContentLoaded", false, false);
|
||||
window.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
script.addEventListener("load", handleLoad);
|
||||
return () => {
|
||||
script.removeEventListener("load", handleLoad);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div id="supportByBMC" ref={containerRef} />;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export default function DataPipelineHeader({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
onAddParam,
|
||||
}) {
|
||||
return (
|
||||
<div className="params-header">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button params-collapse"
|
||||
title={collapsed ? "Show data pipeline" : "Hide data pipeline"}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? "Show data pipeline" : "Hide data pipeline"}
|
||||
>
|
||||
{collapsed ? "▶" : "▼"}
|
||||
</button>
|
||||
<div className="title">Data Pipeline</div>
|
||||
<div className="params-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onAddParam}
|
||||
title="Add new parameter"
|
||||
aria-label="Add new parameter"
|
||||
>
|
||||
➕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import DataPipelineHeader from "./DataPipelineHeader";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
describe("DataPipelineHeader", () => {
|
||||
it("calls toggle when collapse button clicked", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(
|
||||
<DataPipelineHeader
|
||||
collapsed={false}
|
||||
onToggleCollapsed={onToggle}
|
||||
onAddParam={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /hide data pipeline/i }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls add handler when add button pressed", () => {
|
||||
const onAdd = vi.fn();
|
||||
render(
|
||||
<DataPipelineHeader
|
||||
collapsed
|
||||
onToggleCollapsed={vi.fn()}
|
||||
onAddParam={onAdd}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add new parameter/i }));
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const FEEDBACK_MAIL = "xsltplayground@alexandre-vazquez.cloud";
|
||||
const STORAGE_KEY = "feedbackPos";
|
||||
const MIN_MARGIN = 10;
|
||||
const DEFAULT_MARGIN = 24;
|
||||
const FALLBACK_WIDTH = 220;
|
||||
|
||||
export default function FeedbackWidget() {
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("feedbackCollapsed") || "false");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const widgetRef = useRef(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
);
|
||||
const [viewportWidth, setViewportWidth] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
);
|
||||
const [position, setPosition] = useState(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (
|
||||
typeof parsed?.x === "number" &&
|
||||
typeof parsed?.y === "number"
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
if (
|
||||
typeof parsed?.right === "number" &&
|
||||
typeof parsed?.top === "number"
|
||||
) {
|
||||
return {
|
||||
x: Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerWidth - parsed.right - FALLBACK_WIDTH,
|
||||
),
|
||||
y: Math.max(MIN_MARGIN, parsed.top),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
});
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [opensUp, setOpensUp] = useState(false);
|
||||
const resolvedPosition = useMemo(
|
||||
() => {
|
||||
if (position) return position;
|
||||
const fallbackHeight =
|
||||
viewportHeight || (typeof window !== "undefined" ? window.innerHeight : 0);
|
||||
return {
|
||||
x: DEFAULT_MARGIN,
|
||||
y: Math.max(DEFAULT_MARGIN, fallbackHeight - 220),
|
||||
};
|
||||
},
|
||||
[position, viewportHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handleResize = () => {
|
||||
setViewportHeight(window.innerHeight);
|
||||
setViewportWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("feedbackCollapsed", JSON.stringify(collapsed));
|
||||
} catch {}
|
||||
}, [collapsed]);
|
||||
|
||||
const clampWithinViewport = useCallback(
|
||||
(pos) => {
|
||||
if (
|
||||
!pos ||
|
||||
typeof window === "undefined" ||
|
||||
!widgetRef.current
|
||||
) {
|
||||
return pos;
|
||||
}
|
||||
const rect = widgetRef.current.getBoundingClientRect();
|
||||
const maxX = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerWidth - rect.width - MIN_MARGIN,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerHeight - rect.height - MIN_MARGIN,
|
||||
);
|
||||
return {
|
||||
x: Math.min(Math.max(MIN_MARGIN, pos.x), maxX),
|
||||
y: Math.min(Math.max(MIN_MARGIN, pos.y), maxY),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!position) return;
|
||||
const next = clampWithinViewport(position);
|
||||
if (!next) return;
|
||||
if (next.x !== position.x || next.y !== position.y) {
|
||||
setPosition(next);
|
||||
}
|
||||
}, [position, clampWithinViewport, viewportHeight, viewportWidth, collapsed]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
position ||
|
||||
typeof window === "undefined" ||
|
||||
typeof document === "undefined"
|
||||
)
|
||||
return;
|
||||
const widget = widgetRef.current;
|
||||
if (!widget) return;
|
||||
const footer = document.querySelector(".footer");
|
||||
const margin = DEFAULT_MARGIN;
|
||||
const viewport = window.innerHeight;
|
||||
const widgetRect = widget.getBoundingClientRect();
|
||||
let y = Math.max(margin, viewport - widgetRect.height - margin);
|
||||
|
||||
if (footer) {
|
||||
const footerRect = footer.getBoundingClientRect();
|
||||
if (footerRect.top < viewport) {
|
||||
y = Math.max(margin, footerRect.top - widgetRect.height - margin);
|
||||
}
|
||||
}
|
||||
|
||||
setPosition({ x: margin, y });
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const handleMove = (event) => {
|
||||
event.preventDefault();
|
||||
const widget = widgetRef.current;
|
||||
const widgetRect = widget?.getBoundingClientRect();
|
||||
const maxX = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerWidth -
|
||||
(widgetRect?.width ?? 0) -
|
||||
MIN_MARGIN,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
MIN_MARGIN,
|
||||
window.innerHeight -
|
||||
(widgetRect?.height ?? 0) -
|
||||
MIN_MARGIN,
|
||||
);
|
||||
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
Math.max(MIN_MARGIN, event.clientX - offset.x),
|
||||
maxX,
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(MIN_MARGIN, event.clientY - offset.y),
|
||||
maxY,
|
||||
),
|
||||
});
|
||||
};
|
||||
const stop = () => setDragging(false);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", stop);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", stop);
|
||||
};
|
||||
}, [dragging, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging || !position) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(position));
|
||||
} catch {}
|
||||
}, [dragging, position]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!widgetRef.current || !viewportHeight) return;
|
||||
const rect = widgetRef.current.getBoundingClientRect();
|
||||
setOpensUp(rect.top + rect.height / 2 > viewportHeight / 2);
|
||||
}, [position, collapsed, viewportHeight]);
|
||||
|
||||
const startDrag = (event) => {
|
||||
event.preventDefault();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setOffset({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const mailLink = `mailto:${FEEDBACK_MAIL}?subject=${encodeURIComponent(
|
||||
"xsltplayground feedback",
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={widgetRef}
|
||||
className={`feedback-widget ${collapsed ? "collapsed" : ""} ${opensUp ? "opens-up" : ""}`}
|
||||
style={{ left: resolvedPosition.x, top: resolvedPosition.y }}
|
||||
>
|
||||
<div
|
||||
className="feedback-header"
|
||||
onMouseDown={startDrag}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>Feedback</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
aria-label={collapsed ? "Show feedback panel" : "Hide feedback panel"}
|
||||
>
|
||||
{collapsed ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="feedback-body">
|
||||
<p>Have an idea or found a glitch? I would love to hear from you.</p>
|
||||
<a className="feedback-link" href={mailLink}>
|
||||
✉️ Send feedback
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export default function TabsNav({
|
||||
tabs,
|
||||
activeId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onExport,
|
||||
onClear,
|
||||
}) {
|
||||
return (
|
||||
<div className="tabs-left">
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab ${isActive ? "active" : ""}`}
|
||||
>
|
||||
<div className="tab-tools">
|
||||
<button
|
||||
type="button"
|
||||
className="tab-icon"
|
||||
onClick={() => onExport?.(tab)}
|
||||
title="Export workspace"
|
||||
aria-label={`Export workspace ${index + 1}`}
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-icon"
|
||||
onClick={() => onClear?.(tab)}
|
||||
title="Clear workspace"
|
||||
aria-label={`Clear workspace ${index + 1}`}
|
||||
>
|
||||
🧹
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-button"
|
||||
onClick={() => onSelect?.(tab.id)}
|
||||
>
|
||||
{`Workspace ${index + 1}`}
|
||||
</button>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose?.(tab.id);
|
||||
}}
|
||||
title="Close workspace"
|
||||
aria-label={`Close workspace ${index + 1}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import TabsNav from "./TabsNav";
|
||||
|
||||
const sampleTabs = [
|
||||
{ id: "a" },
|
||||
{ id: "b" },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TabsNav", () => {
|
||||
it("invokes export handler for the selected tab button", () => {
|
||||
const onExport = vi.fn();
|
||||
render(
|
||||
<TabsNav
|
||||
tabs={sampleTabs}
|
||||
activeId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onExport={onExport}
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const exportButtons = screen.getAllByRole("button", { name: /Export workspace/ });
|
||||
fireEvent.click(exportButtons[1]);
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
expect(onExport).toHaveBeenCalledWith(sampleTabs[1]);
|
||||
});
|
||||
|
||||
it("invokes clear handler for matching tab", () => {
|
||||
const onClear = vi.fn();
|
||||
render(
|
||||
<TabsNav
|
||||
tabs={sampleTabs}
|
||||
activeId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearButton = screen.getAllByRole("button", { name: /Clear workspace/ })[0];
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onClear).toHaveBeenCalledWith(sampleTabs[0]);
|
||||
});
|
||||
});
|
||||
+27
-3
@@ -1,4 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<rect width="40" height="40" rx="6" fill="#007acc"/>
|
||||
<text x="20" y="26" text-anchor="middle" font-size="20" font-family="Arial" fill="white" font-weight="bold">XSL</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="logo-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#07162f" />
|
||||
<stop offset="50%" stop-color="#0c3264" />
|
||||
<stop offset="100%" stop-color="#1480ff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="logo-flow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe07d" />
|
||||
<stop offset="100%" stop-color="#52f2c5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="48" height="48" rx="10" fill="url(#logo-bg)" />
|
||||
<g stroke="#8edfff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.92">
|
||||
<path d="M15 10 L8.5 18 L15 26" />
|
||||
<path d="M15 22 L8.5 30 L15 38" />
|
||||
<path d="M33 10 L39.5 18 L33 26" />
|
||||
<path d="M33 22 L39.5 30 L33 38" />
|
||||
</g>
|
||||
<path d="M7 14h34" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
|
||||
<path d="M7 34h34" stroke="rgba(255,255,255,0.25)" stroke-width="1" />
|
||||
<path d="M18 15 L27 15 L35 24 L27 33 L18 33 L23 27 L17 27 L13 24 L17 21 L23 21 Z" fill="url(#logo-flow)" />
|
||||
<path d="M18 15 L27 15 L35 24 L27 33 L18 33 L23 27 L17 27 L13 24 L17 21 L23 21 Z" fill="none" stroke="rgba(0, 0, 0, 0.3)" stroke-width="0.8" stroke-linejoin="round" />
|
||||
<path d="M23 18 V30" stroke="#fff3d4" stroke-width="1.5" stroke-linecap="round" />
|
||||
<circle cx="23" cy="18" r="1.5" fill="#fffcf1" />
|
||||
<circle cx="23" cy="30" r="1.5" fill="#fffcf1" />
|
||||
<circle cx="23" cy="24" r="2.3" fill="#051c38" opacity="0.45" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
+817
-44
@@ -7,13 +7,27 @@ body,
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #ddd;
|
||||
.news-link {
|
||||
color: #007acc;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.version-pill {
|
||||
border: 1px solid #007acc;
|
||||
color: #007acc;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.version-pill:hover {
|
||||
background: #e6f2fb;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -23,30 +37,110 @@ body,
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background: #fafafa;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
.tabs-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-end;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tabs-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-bottom: 2px solid #007acc;
|
||||
font-weight: bold;
|
||||
.tab.active {
|
||||
background: #fff;
|
||||
border-color: #ddd;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab.active .tab-button {
|
||||
color: #007acc;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
border: 1px dashed #bbb;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.tab-import {
|
||||
border: 1px dashed #bbb;
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.tab-tools {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-icon:hover,
|
||||
.tab.active .tab-icon {
|
||||
color: #007acc;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle button,
|
||||
.toggle select {
|
||||
margin-right: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle .right-actions {
|
||||
@@ -55,10 +149,135 @@ body,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.2rem 0.8rem 0.2rem 0.45rem;
|
||||
border: 1px solid #cfdcf4;
|
||||
border-radius: 999px;
|
||||
background: #f0f6ff;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trace-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.trace-toggle-box {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #4a85ff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
color: transparent;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.trace-toggle-box::after {
|
||||
content: "✔";
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trace-toggle input:checked + .trace-toggle-box {
|
||||
background: #4a85ff;
|
||||
color: #fff;
|
||||
border-color: #4a85ff;
|
||||
}
|
||||
|
||||
.trace-toggle input:focus-visible + .trace-toggle-box {
|
||||
box-shadow: 0 0 0 2px rgba(74, 133, 255, 0.2);
|
||||
}
|
||||
|
||||
.trace-toggle-label {
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: #21426c;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: #fee;
|
||||
color: #900;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #f3c2c2;
|
||||
border-left: 4px solid #d00;
|
||||
max-height: 7rem;
|
||||
overflow: auto;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.error-box-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: bold;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.error-box-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.error-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
padding-right: 0.25rem;
|
||||
color: #d00;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-more {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #b55;
|
||||
}
|
||||
|
||||
.error-expand-button {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0.25rem;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
@@ -78,40 +297,254 @@ body,
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.params {
|
||||
width: 30%;
|
||||
overflow-y: auto;
|
||||
flex: 0 0 auto;
|
||||
width: 320px;
|
||||
min-width: 220px;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.params-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
background: linear-gradient(180deg, #f9fbff 0%, #f1f4ff 100%);
|
||||
}
|
||||
|
||||
.param-card {
|
||||
border: 1px solid #dfe5fb;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f6f8ff 100%);
|
||||
box-shadow: 0 8px 22px rgba(15, 40, 94, 0.07);
|
||||
padding: 0.55rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.param-card.open {
|
||||
border-color: #c4d4ff;
|
||||
box-shadow: 0 14px 34px rgba(15, 40, 94, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.param-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.param-name-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid #d5dff7;
|
||||
border-radius: 999px;
|
||||
background: #f0f5ff;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.param-name-wrap:focus-within {
|
||||
border-color: #4a85ff;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 133, 255, 0.15);
|
||||
}
|
||||
|
||||
.param-toggle {
|
||||
flex: 0 0 2.2rem;
|
||||
border: none;
|
||||
border-right: 1px solid #d5dff7;
|
||||
background: transparent;
|
||||
color: #1f3a63;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.param-toggle.open {
|
||||
color: #21426c;
|
||||
background: #e1e9ff;
|
||||
}
|
||||
|
||||
.param-toggle:hover {
|
||||
color: #4a85ff;
|
||||
}
|
||||
|
||||
.param-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.25rem 0.85rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #1d3770;
|
||||
}
|
||||
|
||||
.param-name-input::placeholder {
|
||||
color: #6a7da8;
|
||||
}
|
||||
|
||||
.param-name-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.param-remove {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #f8bcbc !important;
|
||||
background: #fff5f5;
|
||||
color: #b03a3a;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 2px 6px rgba(176, 58, 58, 0.15);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.param-remove:hover {
|
||||
background: #ffe4e4;
|
||||
border-color: #f19999 !important;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.param-content {
|
||||
border-top: 1px solid #ecf0fb;
|
||||
padding-top: 0.45rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.param-editor {
|
||||
border: 1px solid #dfe5fb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(15, 40, 94, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.param-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.param-footer .icon-button {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #dfe5fb;
|
||||
background: #f7f9ff;
|
||||
padding: 0.3rem 0.65rem;
|
||||
box-shadow: 0 4px 10px rgba(15, 40, 94, 0.08);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.param-footer .icon-button:hover {
|
||||
background: #eaf0ff;
|
||||
border-color: #c7d6ff;
|
||||
}
|
||||
|
||||
.param-upload {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.params-header {
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f7f9fc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.params-header .title {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.params-header button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
.params-header-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.params-collapse {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.params-collapsed {
|
||||
flex: 0 0 2.5rem;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: #fafafa;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 0.5rem;
|
||||
color: #536084;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px dashed #c4d4ff;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pane-divider {
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
margin: 0 0.35rem;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.pane-divider span {
|
||||
width: 4px;
|
||||
height: 60%;
|
||||
margin: 0 auto;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 66, 108, 0.2);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.pane-divider:hover span,
|
||||
.pane-divider.dragging span {
|
||||
background: #4a85ff;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 70%;
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
padding: 0.5rem;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
@@ -120,25 +553,249 @@ body,
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.result {
|
||||
height: 40vh;
|
||||
border-top: 1px solid #ddd;
|
||||
.editor-split {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.banner {
|
||||
text-align: center;
|
||||
.xslt-editor-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* allow editor to shrink */
|
||||
}
|
||||
|
||||
.trace-panel {
|
||||
width: 30%;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trace-header {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-weight: bold;
|
||||
background: #fafafa;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.trace-header-actions {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.trace-content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trace-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.trace-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
width: auto;
|
||||
vertical-align: top;
|
||||
font-family: monospace;
|
||||
color: #333;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.trace-value {
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.trace-cell {
|
||||
position: relative;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.trace-preview {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow: auto;
|
||||
max-height: 6rem;
|
||||
}
|
||||
|
||||
.trace-name-preview {
|
||||
max-height: 4.5rem;
|
||||
}
|
||||
|
||||
.trace-value-preview {
|
||||
max-height: 9rem;
|
||||
}
|
||||
|
||||
.trace-hover-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
padding: 0.75rem 0.75rem 0.5rem;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
color: #f6f6f6;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.trace-hover-tooltip pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.trace-hover-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.trace-hover-tooltip .icon-button {
|
||||
color: #f6f6f6;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.2rem 0.35rem;
|
||||
}
|
||||
|
||||
.trace-hover-tooltip .icon-button:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.trace-raw-block {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: #f0f0f0;
|
||||
background: #f7f7f9;
|
||||
border: 1px solid #e2e2e4;
|
||||
border-radius: 4px;
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
overflow: auto;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.trace-empty {
|
||||
padding: 0.5rem;
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.trace-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.trace-divider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.trace-divider:hover::after {
|
||||
background: rgba(0, 122, 204, 0.6);
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
|
||||
.result {
|
||||
border-top: 1px solid #ddd;
|
||||
background: #fff;
|
||||
padding: 1.75rem 0.5rem 0.75rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.result-editor-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.result-resizer {
|
||||
flex: 0 0 auto;
|
||||
height: 0.75rem;
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f6f8fb;
|
||||
border-top: 1px solid #e1e7f0;
|
||||
border-bottom: 1px solid #dfe5ef;
|
||||
}
|
||||
|
||||
.result-resizer span {
|
||||
width: 3rem;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: #c2ccdc;
|
||||
}
|
||||
|
||||
.result-resizer.dragging {
|
||||
background: #e0ecfc;
|
||||
}
|
||||
|
||||
.result-resizer.dragging span {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@@ -149,6 +806,40 @@ body,
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 1px solid #cfdcf4;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 1.9rem 0.35rem 0.85rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
background-color: #f0f6ff;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2321426C' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-position: calc(100% - 0.95rem) center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 12px 8px;
|
||||
color: #21426c;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.version-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a85ff;
|
||||
box-shadow: 0 0 0 3px rgba(74, 133, 255, 0.15);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.version-select:hover {
|
||||
border-color: #adc5ff;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
@@ -158,8 +849,90 @@ body,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-format-button {
|
||||
.result-format-button,
|
||||
.result-reset-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: 0.25rem;
|
||||
}
|
||||
|
||||
.result-format-button {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.result-reset-button {
|
||||
right: 3rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.result-reset-button.active {
|
||||
color: #007acc;
|
||||
}
|
||||
|
||||
.feedback-widget {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(6px);
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feedback-widget.opens-up {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.feedback-widget.collapsed {
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.feedback-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #007acc;
|
||||
color: white;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 10px 10px 0 0;
|
||||
cursor: grab;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.feedback-widget.opens-up .feedback-header {
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feedback-body p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.feedback-link {
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
background: #fff1c1;
|
||||
color: #805d00;
|
||||
border: 1px solid #ffd970;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback-link:hover {
|
||||
background: #ffe189;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setupTests.js",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user