1
0
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:
2025-11-15 22:29:39 +01:00
committed by GitHub
29 changed files with 7193 additions and 392 deletions
+1
View File
@@ -196,5 +196,6 @@ cython_debug/
frontend/node_modules
frontend/dist
backend/server
backend/src/.gocache
/config/credentials.json
credentials.json
+3
View File
@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}
+13
View File
@@ -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.
+17 -2
View File
@@ -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
+17 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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, "&lt;") || 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) {
+94
View File
@@ -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")
}
}
+1
View File
@@ -4,6 +4,7 @@ services:
build: ./backend
environment:
VITE_GO_PRO: "false"
XSLT_TRACE_DEBUG: "true"
ports:
- "8000:8000"
frontend:
+19 -6
View File
@@ -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
+1
View File
@@ -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) {
+1455 -6
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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();
});
});
-31
View File
@@ -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>;
}
+42
View File
@@ -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);
});
});
+249
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+54
View File
@@ -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
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+817 -44
View File
@@ -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;
}
+12
View File
@@ -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",
},
});