1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-16 18:23:16 +00:00
Files
xslt-lab/frontend/src/App.jsx
T
alexandrev-tibco a333d175f4 SEO improvements and 4 new blog articles
- Update index.html title, description, keywords to include xslt 3.0, validator, saxon
- Update JSON-LD schema with full feature list and XSLT 3.0
- Change footer link from "News" to "Blog & Tutorials", update URL to blog.xsltplayground.com
- New articles: online editor guide, validator guide, string functions, xsl:for-each-group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:55:30 +02:00

1982 lines
65 KiB
React

import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react";
import logo from "./logo.svg";
import TabsNav from "./components/TabsNav";
import DataPipelineHeader from "./components/DataPipelineHeader";
import Icon from "./components/Icon";
import {
parseErrorLines,
stripParamBlock,
injectParamBlock,
addParams,
extractParamNames,
setStylesheetVersion,
} from "./lib/workspaceUtils";
/* global __APP_VERSION__ */
const MonacoEditor = lazy(() => import("@monaco-editor/react"));
const FeedbackWidget = lazy(() => import("./components/FeedbackWidget"));
const BuyMeACoffee = lazy(() => import("./components/BuyMeACoffee"));
function runWhenIdle(callback, timeout = 2000) {
if (typeof window === "undefined") {
return () => {};
}
if ("requestIdleCallback" in window) {
const id = window.requestIdleCallback(callback, { timeout });
return () => window.cancelIdleCallback?.(id);
}
const id = window.setTimeout(callback, timeout);
return () => window.clearTimeout(id);
}
function Editor({ height, ...props }) {
const fallbackStyle = height ? { height } : undefined;
return (
<Suspense
fallback={
<div
className="editor-loading"
role="status"
aria-live="polite"
style={fallbackStyle}
>
Loading editor...
</div>
}
>
<MonacoEditor height={height} {...props} />
</Suspense>
);
}
function debounce(fn, delay) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
const env = window.env || import.meta.env;
const goPro = env.VITE_GO_PRO === "true";
const adsenseClient = env.VITE_ADSENSE_CLIENT;
const adsenseSlot = env.VITE_ADSENSE_SLOT;
const ethicalAdsPublisher = env.VITE_ETHICALADS_PUBLISHER || "xsltplaygroundcom";
const defaultRepoUrl = "https://github.com/alexandrev/xslt-lab";
const repoUrl = env.VITE_REPO_URL || defaultRepoUrl;
const newsUrl = env.VITE_NEWS_URL || "https://blog.xsltplayground.com/";
const resolvedVersion =
typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__
? __APP_VERSION__
: env.VITE_APP_VERSION || "";
const changelogUrl = `${repoUrl}/blob/main/CHANGELOG.md${
resolvedVersion ? `#${changelogAnchor(resolvedVersion)}` : ""
}`;
function defaultTab(overrides = {}) {
const base = {
id: Date.now() + Math.random(),
params: [{ name: "input1", value: "<root/>", open: true }],
xslt: `<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">\n<xsl:template match="/">\n<root/>\n</xsl:template>\n</xsl:stylesheet>`,
version: "1.0",
name: "",
};
const merged = { ...base, ...overrides };
return {
...merged,
id: overrides.id ?? base.id,
params: Array.isArray(merged.params) ? merged.params : base.params,
xslt: typeof merged.xslt === "string" ? merged.xslt : base.xslt,
version: merged.version || base.version,
name: typeof merged.name === "string" ? merged.name : base.name,
};
}
const MAX_WORKSPACES = 3;
const WORKSPACE_EXPORT_VERSION = 1;
const RESULT_HEIGHT_KEY = "resultPaneHeight";
const DEFAULT_RESULT_RATIO = 0.4;
const MIN_RESULT_HEIGHT = 180;
const MIN_MAIN_HEIGHT = 320;
const PARAM_WIDTH_KEY = "paramsPaneWidth";
const DEFAULT_PARAM_WIDTH = 320;
const MIN_PARAM_WIDTH = 220;
const MIN_EDITOR_WIDTH = 360;
const ETHICAL_AD_COMPACT_BREAKPOINT = 1024;
const ETHICAL_AD_TEXT_BREAKPOINT = 720;
const THEME_STORAGE_KEY = "themeMode";
const THEME_DARK = "dark";
const THEME_LIGHT = "light";
function defaultWorkspaceStatus() {
return {
result: "",
duration: null,
error: "",
errorLines: [],
traceEntries: [],
traceText: "",
showRawTrace: false,
resultView: "source",
};
}
function looksLikeHtml(text) {
if (!text || typeof text !== "string") return false;
const trimmed = text.trim();
if (!trimmed.startsWith("<")) return false;
const lowered = trimmed.slice(0, 200).toLowerCase();
return (
lowered.startsWith("<!doctype html") ||
/^<html\b/.test(lowered) ||
/^<body\b/.test(lowered)
);
}
function normalizeWorkspaceImport(payload) {
if (!payload || typeof payload !== "object") {
throw new Error("Workspace file is empty or invalid.");
}
if (
payload.schemaVersion &&
payload.schemaVersion !== WORKSPACE_EXPORT_VERSION
) {
throw new Error(
`Unsupported workspace format version ${payload.schemaVersion}.`,
);
}
const workspace = payload.workspace || payload;
if (!workspace.xslt || typeof workspace.xslt !== "string") {
throw new Error("Workspace file is missing the XSLT content.");
}
const baseParams = defaultTab().params;
const params = Array.isArray(workspace.params)
? workspace.params.map((p, idx) => ({
name: typeof p?.name === "string" ? p.name : `param${idx + 1}`,
value: typeof p?.value === "string" ? p.value : "",
open: Boolean(p?.open),
}))
: baseParams;
const status =
payload.status && typeof payload.status === "object"
? { ...defaultWorkspaceStatus(), ...payload.status }
: defaultWorkspaceStatus();
return {
workspace: {
params,
xslt: workspace.xslt,
version: workspace.version || "1.0",
},
status,
};
}
function changelogAnchor(version) {
if (!version) return "";
return `v${version}`.replace(/[^0-9a-zA-Z]+/g, "").toLowerCase();
}
export default function App() {
// Load persisted workspace from localStorage (if present)
let initialTabs = [defaultTab()];
try {
const stored = localStorage.getItem("tabs");
if (stored) initialTabs = JSON.parse(stored);
} catch {}
if (!Array.isArray(initialTabs)) {
initialTabs = [defaultTab()];
}
initialTabs = initialTabs.map((tab) => {
if (!tab || typeof tab !== "object") return defaultTab();
return defaultTab({
id: tab.id,
params: tab.params,
xslt: tab.xslt,
version: tab.version,
name: tab.name,
});
});
if (initialTabs.length > MAX_WORKSPACES) {
initialTabs = initialTabs.slice(0, MAX_WORKSPACES);
}
let initialActive = initialTabs[0]?.id;
try {
const sAct = localStorage.getItem("active");
if (sAct) initialActive = JSON.parse(sAct);
} catch {}
const readStoredWorkspaceStatus = () => {
try {
const stored = localStorage.getItem("workspaceStatus");
if (stored) {
return JSON.parse(stored);
}
} catch {}
return {};
};
const [tabs, setTabs] = useState(initialTabs);
const [active, setActive] = useState(initialActive);
const [workspaceStatus, setWorkspaceStatus] = useState(() => {
const stored = readStoredWorkspaceStatus();
const initialStatus = {};
initialTabs.forEach((tab) => {
initialStatus[tab.id] = stored[tab.id]
? { ...defaultWorkspaceStatus(), ...stored[tab.id] }
: defaultWorkspaceStatus();
});
return initialStatus;
});
const [editorFocused, setEditorFocused] = useState(false);
const [user, setUser] = useState(null);
const [auth, setAuth] = useState(null);
const [theme, setTheme] = useState(() => {
if (typeof window === "undefined") return THEME_LIGHT;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === THEME_DARK || stored === THEME_LIGHT) return stored;
} catch {}
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? THEME_DARK
: THEME_LIGHT;
} catch {}
return THEME_LIGHT;
});
const isDarkTheme = theme === THEME_DARK;
const editorTheme = isDarkTheme ? "vs-dark" : "light";
const resultEditorRef = useRef(null);
const traceHoverTimeoutRef = useRef(null);
const traceTableWrapRef = useRef(null);
const traceNameRefs = useRef([]);
const tabsRef = useRef(tabs);
const [traceEnabled, setTraceEnabled] = useState(() => {
try { return JSON.parse(localStorage.getItem("traceEnabled") || "false"); } catch { return false; }
});
const [traceCollapsed, setTraceCollapsed] = useState(false);
const [traceHover, setTraceHover] = useState(null);
const [traceNameWidth, setTraceNameWidth] = useState(240);
const [traceScrollLeft, setTraceScrollLeft] = useState(0);
const [paramsCollapsed, setParamsCollapsed] = useState(false);
const [errorCollapsed, setErrorCollapsed] = useState(false);
const [ethicalAdsReady, setEthicalAdsReady] = useState(false);
const workspaceImportRef = useRef(null);
const resultResizeState = useRef({ startY: 0, startHeight: MIN_RESULT_HEIGHT });
const paramResizeState = useRef({ startX: 0, startWidth: DEFAULT_PARAM_WIDTH });
const [resultHeight, setResultHeight] = useState(() => {
try {
const stored = localStorage.getItem(RESULT_HEIGHT_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === "number" && Number.isFinite(parsed)) {
return parsed;
}
}
} catch {}
return null;
});
const [paramWidth, setParamWidth] = useState(() => {
if (typeof window === "undefined") return DEFAULT_PARAM_WIDTH;
try {
const stored = localStorage.getItem(PARAM_WIDTH_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === "number" && Number.isFinite(parsed)) {
return Math.max(parsed, MIN_PARAM_WIDTH);
}
}
} catch {}
return DEFAULT_PARAM_WIDTH;
});
const [isResizingResult, setIsResizingResult] = useState(false);
const [isResizingParams, setIsResizingParams] = useState(false);
const [viewportHeight, setViewportHeight] = useState(() =>
typeof window !== "undefined" ? window.innerHeight : 0,
);
const [viewportWidth, setViewportWidth] = useState(() =>
typeof window !== "undefined" ? window.innerWidth : 0,
);
const [userInteracted, setUserInteracted] = useState(false);
const [widgetsReady, setWidgetsReady] = useState(false);
const [autoRunReady, setAutoRunReady] = useState(false);
const ethicalSlotRef = useRef(null);
const isLocalhost =
typeof window !== "undefined" &&
/^(localhost|127(?:\\.[0-9]+){3}|mac)$/i.test(window.location.hostname);
const ethicalAdsEnabled =
Boolean(ethicalAdsPublisher) &&
(!isLocalhost || env.VITE_ETHICALADS_DEV === "true");
const isCompactEthicalAd =
!viewportWidth || viewportWidth < ETHICAL_AD_COMPACT_BREAKPOINT;
const ethicalAdType =
viewportWidth && viewportWidth < ETHICAL_AD_TEXT_BREAKPOINT
? "text"
: "image";
const ethicalAdStyle = isCompactEthicalAd ? undefined : "fixedheader";
const ethicalAdHeight = isCompactEthicalAd ? "80px" : "50px";
const ethicalAdVariant = `${isCompactEthicalAd ? "compact" : "wide"}-${ethicalAdType}`;
const backendBase = (env.VITE_BACKEND_URL || "").replace(/\/$/, "");
useEffect(() => {
const gaId = env.VITE_GA_ID;
if (!gaId || !userInteracted) return;
let cancelled = false;
const cancelIdle = runWhenIdle(async () => {
if (cancelled) return;
try {
const { default: GA4React } = await import("ga-4-react");
if (cancelled) return;
const ga4react = new GA4React(gaId);
ga4react.initialize().catch((err) => console.error(err));
} catch (err) {
console.error(err);
}
}, 2500);
return () => {
cancelled = true;
cancelIdle?.();
};
}, [userInteracted]);
useEffect(() => {
const enable = () => {
setUserInteracted(true);
setAutoRunReady(true);
setWidgetsReady(true);
};
window.addEventListener("pointerdown", enable, { once: true });
window.addEventListener("keydown", enable, { once: true });
return () => {
window.removeEventListener("pointerdown", enable);
window.removeEventListener("keydown", enable);
};
}, []);
// Persist workspace on change
useEffect(() => {
try {
localStorage.setItem("tabs", JSON.stringify(tabs));
localStorage.setItem("active", JSON.stringify(active));
} catch {}
}, [tabs, active]);
useEffect(() => {
try {
localStorage.setItem("workspaceStatus", JSON.stringify(workspaceStatus));
} catch {}
}, [workspaceStatus]);
useEffect(() => {
try { localStorage.setItem("traceEnabled", JSON.stringify(traceEnabled)); } catch {}
}, [traceEnabled]);
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.dataset.theme = theme;
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {}
}, [theme]);
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 {
if (resultHeight === null) {
localStorage.removeItem(RESULT_HEIGHT_KEY);
} else {
localStorage.setItem(RESULT_HEIGHT_KEY, JSON.stringify(resultHeight));
}
} catch {}
}, [resultHeight]);
useEffect(() => {
try {
localStorage.setItem(PARAM_WIDTH_KEY, JSON.stringify(paramWidth));
} catch {}
}, [paramWidth]);
useEffect(() => {
if (traceCollapsed) {
setTraceHover(null);
setTraceScrollLeft(0);
}
}, [traceCollapsed]);
useEffect(() => {
setWorkspaceStatus((prev) => {
let changed = false;
const next = {};
tabs.forEach((tab) => {
if (prev[tab.id]) {
next[tab.id] = prev[tab.id];
} else {
next[tab.id] = defaultWorkspaceStatus();
changed = true;
}
});
if (Object.keys(prev).length !== tabs.length) {
changed = true;
}
return changed ? next : prev;
});
}, [tabs]);
useEffect(() => {
if (tabs.length === 0) {
return;
}
if (!tabs.some((t) => t.id === active)) {
setActive(tabs[0].id);
}
}, [tabs, active]);
useEffect(() => {
if (!traceEnabled) {
setTraceHover(null);
setTraceCollapsed(false);
}
}, [traceEnabled]);
useEffect(() => {
const closeOnChange = () => setTraceHover(null);
window.addEventListener("scroll", closeOnChange, true);
window.addEventListener("resize", closeOnChange);
return () => {
window.removeEventListener("scroll", closeOnChange, true);
window.removeEventListener("resize", closeOnChange);
};
}, []);
const activeTab = tabs.find((t) => t.id === active) || tabs[0];
const activeStatus = activeTab
? (workspaceStatus[activeTab.id] || defaultWorkspaceStatus())
: defaultWorkspaceStatus();
const {
result,
duration,
error,
errorLines,
traceEntries,
traceText,
showRawTrace,
resultView,
} = activeStatus;
const MAX_ERROR_LINES = 3;
const limitedErrorLines = (errorLines || []).slice(0, MAX_ERROR_LINES);
const hasHiddenErrors = (errorLines || []).length > MAX_ERROR_LINES;
const canCopyErrors = Boolean((errorLines && errorLines.length) || error);
const showResultPane = !error;
const canRenderHtml = useMemo(() => looksLikeHtml(result), [result]);
const effectiveResultView = canRenderHtml ? resultView || "source" : "source";
const TRACE_NAME_LIMIT = 80;
const TRACE_VALUE_LIMIT = 200;
const EMPTY_SYMBOL = "(empty)";
const TRACE_NAME_MIN_WIDTH = 120;
const TRACE_NAME_PADDING = 40;
const clampResultPaneHeight = useCallback(
(value) => {
const numericValue =
typeof value === "number" && Number.isFinite(value)
? value
: MIN_RESULT_HEIGHT;
if (!viewportHeight) {
return Math.max(MIN_RESULT_HEIGHT, Math.round(numericValue));
}
const maxHeight = Math.max(
MIN_RESULT_HEIGHT,
viewportHeight - MIN_MAIN_HEIGHT,
);
return Math.min(
Math.max(Math.round(numericValue), MIN_RESULT_HEIGHT),
maxHeight,
);
},
[viewportHeight],
);
const clampParamPaneWidth = useCallback(
(value) => {
const numericValue =
typeof value === "number" && Number.isFinite(value)
? value
: DEFAULT_PARAM_WIDTH;
const viewportLimit =
viewportWidth && viewportWidth > 0
? Math.max(MIN_PARAM_WIDTH, viewportWidth - MIN_EDITOR_WIDTH)
: Number.POSITIVE_INFINITY;
const maxWidth = Number.isFinite(viewportLimit)
? viewportLimit
: Math.max(MIN_PARAM_WIDTH, numericValue);
return Math.min(
Math.max(Math.round(numericValue), MIN_PARAM_WIDTH),
maxWidth,
);
},
[viewportWidth],
);
const fallbackViewportHeight = viewportHeight || 800;
const resolvedResultHeight = useMemo(
() =>
clampResultPaneHeight(
resultHeight ??
Math.round(fallbackViewportHeight * DEFAULT_RESULT_RATIO),
),
[resultHeight, fallbackViewportHeight, clampResultPaneHeight],
);
const isCustomResultHeight = resultHeight !== null;
useEffect(() => {
setParamWidth((prev) => clampParamPaneWidth(prev));
}, [clampParamPaneWidth]);
useEffect(() => {
if (!error) {
setErrorCollapsed(false);
}
}, [error]);
const clampTraceNameWidth = useCallback(() => {
const container = traceTableWrapRef.current;
if (!container) {
return;
}
const maxWidth = Math.max(
TRACE_NAME_MIN_WIDTH,
container.clientWidth - 160,
);
setTraceNameWidth((prev) =>
Math.min(Math.max(prev, TRACE_NAME_MIN_WIDTH), maxWidth),
);
}, []);
if (traceNameRefs.current.length !== traceEntries.length) {
traceNameRefs.current.length = traceEntries.length;
}
useEffect(() => {
clampTraceNameWidth();
}, [traceCollapsed, traceEntries.length, clampTraceNameWidth]);
useEffect(() => {
const handleResize = () => clampTraceNameWidth();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [clampTraceNameWidth]);
useEffect(() => {
setTraceScrollLeft(0);
}, [traceEntries]);
useEffect(() => {
setResultHeight((prev) => {
if (prev === null) return prev;
const clamped = clampResultPaneHeight(prev);
return clamped === prev ? prev : clamped;
});
}, [clampResultPaneHeight]);
useEffect(() => {
if (!isResizingResult) return;
const handleMove = (event) => {
event.preventDefault();
const delta = event.clientY - resultResizeState.current.startY;
const nextHeight = clampResultPaneHeight(
resultResizeState.current.startHeight - delta,
);
setResultHeight(nextHeight);
};
const stop = () => setIsResizingResult(false);
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", stop);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", stop);
};
}, [isResizingResult, clampResultPaneHeight]);
useEffect(() => {
if (typeof document === "undefined") return undefined;
if (!isResizingResult) return undefined;
const { style } = document.body;
const previousCursor = style.cursor;
const previousSelect = style.userSelect;
style.cursor = "row-resize";
style.userSelect = "none";
return () => {
style.cursor = previousCursor;
style.userSelect = previousSelect;
};
}, [isResizingResult]);
useEffect(() => {
if (!isResizingParams) return;
const handleMove = (event) => {
event.preventDefault();
const delta = event.clientX - paramResizeState.current.startX;
const nextWidth = clampParamPaneWidth(
paramResizeState.current.startWidth + delta,
);
setParamWidth(nextWidth);
};
const stop = () => setIsResizingParams(false);
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", stop);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", stop);
};
}, [isResizingParams, clampParamPaneWidth]);
useEffect(() => {
if (typeof document === "undefined") return undefined;
if (!isResizingParams) return undefined;
const { style } = document.body;
const previousCursor = style.cursor;
const previousSelect = style.userSelect;
style.cursor = "col-resize";
style.userSelect = "none";
return () => {
style.cursor = previousCursor;
style.userSelect = previousSelect;
};
}, [isResizingParams]);
useEffect(() => {
if (!paramsCollapsed) return;
setIsResizingParams(false);
}, [paramsCollapsed]);
useEffect(() => {
if (!goPro) return;
let cancelled = false;
const cancelIdle = runWhenIdle(async () => {
if (cancelled) return;
try {
const cfg = env.VITE_FIREBASE_CONFIG;
if (!cfg) return;
const [{ initializeApp }, { getAuth }] = await Promise.all([
import("firebase/app"),
import("firebase/auth"),
]);
if (cancelled) return;
const app = initializeApp(JSON.parse(cfg));
const a = getAuth(app);
setAuth(a);
a.onAuthStateChanged((u) => setUser(u));
} catch {}
}, 2500);
return () => {
cancelled = true;
cancelIdle?.();
};
}, [goPro]);
useEffect(() => {
tabsRef.current = tabs;
}, [tabs]);
const updateWorkspaceStatus = useCallback((tabId, updates) => {
if (!tabId) return;
if (!tabsRef.current.some((t) => t.id === tabId)) {
return;
}
setWorkspaceStatus((prev) => {
const current = prev[tabId]
? { ...defaultWorkspaceStatus(), ...prev[tabId] }
: defaultWorkspaceStatus();
const next =
typeof updates === "function"
? updates(current)
: { ...current, ...updates };
return { ...prev, [tabId]: next };
});
}, []);
useEffect(() => {
if (!activeTab) return;
if (!canRenderHtml && resultView === "render") {
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
resultView: "source",
}));
}
}, [activeTab, canRenderHtml, resultView, updateWorkspaceStatus]);
const truncateText = useCallback((text, limit) => {
if (!text) {
return "";
}
return text.length > limit ? `${text.slice(0, limit)}` : text;
}, []);
const cancelTraceTooltipHide = useCallback(() => {
if (traceHoverTimeoutRef.current) {
clearTimeout(traceHoverTimeoutRef.current);
traceHoverTimeoutRef.current = null;
}
}, []);
const showTraceTooltip = useCallback((event, text) => {
cancelTraceTooltipHide();
const target = event.currentTarget;
if (!target) {
return;
}
const rect = target.getBoundingClientRect();
const padding = 16;
const pointerX = event.clientX;
const pointerY = event.clientY;
const desiredWidth = Math.max(rect.width, 280);
const maxWidth = Math.min(desiredWidth, window.innerWidth - padding * 2);
const left = Math.min(
Math.max(pointerX + 12, padding),
window.innerWidth - maxWidth - padding
);
const top = Math.min(pointerY + 16, window.innerHeight - padding - 40);
setTraceHover({
text: text || EMPTY_SYMBOL,
x: left,
y: top,
width: maxWidth
});
}, [cancelTraceTooltipHide]);
const hideTraceTooltip = useCallback(() => {
cancelTraceTooltipHide();
traceHoverTimeoutRef.current = window.setTimeout(() => {
setTraceHover(null);
traceHoverTimeoutRef.current = null;
}, 120);
}, [cancelTraceTooltipHide]);
const copyTraceHover = useCallback(() => {
if (!traceHover?.text) {
return;
}
try {
navigator.clipboard?.writeText(traceHover.text);
} catch (err) {
console.error("Clipboard copy failed", err);
}
}, [traceHover]);
const copyErrors = useCallback(() => {
const allLines = (errorLines && errorLines.length ? errorLines : error ? [error] : []);
const payload = allLines.join("\n").trim();
if (!payload) return;
try {
navigator.clipboard?.writeText(payload);
} catch (err) {
console.error("Copy errors failed", err);
}
}, [errorLines, error]);
const registerTraceNameRef = useCallback(
(index) => (node) => {
traceNameRefs.current[index] = node;
},
[]
);
const autoSizeTraceNameColumn = useCallback(() => {
const container = traceTableWrapRef.current;
if (!container) {
return;
}
const maxAllowed = Math.max(TRACE_NAME_MIN_WIDTH, container.clientWidth - 160);
let maxWidth = TRACE_NAME_MIN_WIDTH;
traceNameRefs.current.forEach((el) => {
if (el) {
maxWidth = Math.max(maxWidth, el.scrollWidth + TRACE_NAME_PADDING);
}
});
setTraceNameWidth(Math.min(Math.max(maxWidth, TRACE_NAME_MIN_WIDTH), maxAllowed));
}, []);
const handleTraceDividerMouseDown = useCallback((event) => {
if (event.detail === 2) {
event.preventDefault();
autoSizeTraceNameColumn();
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
const startX = event.clientX;
const startWidth = traceNameWidth;
const container = traceTableWrapRef.current;
const maxAllowed = container ? Math.max(TRACE_NAME_MIN_WIDTH, container.clientWidth - 160) : startWidth + 400;
const onMove = (moveEvent) => {
const delta = moveEvent.clientX - startX;
const newWidth = Math.min(Math.max(startWidth + delta, TRACE_NAME_MIN_WIDTH), maxAllowed);
setTraceNameWidth(newWidth);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [autoSizeTraceNameColumn, traceNameWidth]);
const handleCopyAllTrace = useCallback(() => {
const chunks = traceEntries.map((entry) => {
const name = (entry?.name ?? "").toString();
const value = (entry?.value ?? "").toString();
return `${name}\n${value}`.trim();
}).filter(Boolean);
let combined = chunks.join("\n\n");
if (showRawTrace && traceText) {
combined = combined
? `${combined}\n\n--- Raw Trace ---\n${traceText}`
: traceText;
}
if (!combined && traceText) {
combined = traceText;
}
if (!combined) {
return;
}
try {
navigator.clipboard?.writeText(combined);
} catch (err) {
console.error("Copy all trace failed", err);
}
}, [traceEntries, traceText, showRawTrace]);
const syncParams = useCallback(() => {
const names = extractParamNames(injectParamBlock(activeTab.xslt, activeTab.params));
setTabs((tabs) =>
tabs.map((t) => {
if (t.id !== active) return t;
let params = [...t.params];
let changed = false;
names.forEach((n) => {
if (!params.some((p) => p.name === n)) {
params.push({ name: n, value: "", open: false });
changed = true;
}
});
const filtered = params.filter((p) => names.includes(p.name) || p.value);
if (filtered.length !== params.length) {
params = filtered;
changed = true;
}
return changed ? { ...t, params } : t;
}),
);
}, [active, activeTab.xslt]);
const handleAddWorkspace = useCallback(() => {
setTabs((current) => {
if (current.length >= MAX_WORKSPACES) {
window.alert(`You can only open up to ${MAX_WORKSPACES} workspaces.`);
return current;
}
const nextTab = defaultTab();
setWorkspaceStatus((prev) => ({
...prev,
[nextTab.id]: defaultWorkspaceStatus(),
}));
setActive(nextTab.id);
return [...current, nextTab];
});
}, [setActive, setWorkspaceStatus]);
const handleRemoveWorkspace = useCallback(
(id) => {
setTabs((current) => {
if (current.length <= 1) {
return current;
}
const filtered = current.filter((t) => t.id !== id);
if (!filtered.length) {
return current;
}
const nextActiveId = id === active ? filtered[0].id : active;
setActive(nextActiveId);
setWorkspaceStatus((prev) => {
const next = { ...prev };
delete next[id];
if (nextActiveId && !next[nextActiveId]) {
next[nextActiveId] = defaultWorkspaceStatus();
}
return next;
});
return filtered;
});
},
[active, setActive, setWorkspaceStatus],
);
const handleClearWorkspace = useCallback(
(tabData) => {
const targetId = typeof tabData === "object" ? tabData?.id : tabData;
if (!targetId) return;
if (
!window.confirm(
"¿Limpiar este workspace? Se perderán los cambios no guardados.",
)
) {
return;
}
setTabs((current) =>
current.map((t) => {
if (t.id !== targetId) return t;
const cleared = {
...defaultTab({ id: t.id, name: t.name }),
params: [],
};
return cleared;
}),
);
setWorkspaceStatus((prev) => ({
...prev,
[targetId]: defaultWorkspaceStatus(),
}));
},
[setTabs, setWorkspaceStatus],
);
const runTransform = debounce(async (xsltText, ver, p, tabId) => {
const paramObj = {};
p.forEach((pr) => {
if (pr.name) paramObj[pr.name] = pr.value;
});
try {
const res = await fetch(`${backendBase}/transform`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
xslt: xsltText,
version: ver,
parameters: paramObj,
trace: traceEnabled,
}),
});
if (!res.ok) {
let txt = "";
try {
// Prefer JSON to decode escaped newlines (\n)
const j = await res.json();
if (j && typeof j.error === "string") {
txt = j.error;
} else {
txt = JSON.stringify(j);
}
} catch {
// Fallback to raw text
txt = await res.text();
}
const lines = parseErrorLines(txt || res.statusText || "");
updateWorkspaceStatus(tabId, {
error: txt || res.statusText,
errorLines: lines,
duration: null,
result: "",
traceEntries: [],
traceText: "",
showRawTrace: false,
resultView: "source",
});
return;
}
const data = await res.json();
const defaultView = looksLikeHtml(data.result) ? "render" : "source";
updateWorkspaceStatus(tabId, {
result: data.result,
duration: data.duration_ms,
error: "",
errorLines: [],
showRawTrace: false,
resultView: defaultView,
});
const newEntries = traceEnabled ? (data.trace || []) : [];
updateWorkspaceStatus(tabId, (prev) => ({
...prev,
traceEntries: newEntries,
traceText: traceEnabled ? (data.trace_text || "") : "",
}));
requestAnimationFrame(() => {
clampTraceNameWidth();
});
} catch (e) {
const txt = String(e);
updateWorkspaceStatus(tabId, {
error: txt,
errorLines: parseErrorLines(txt),
result: "",
duration: null,
traceEntries: [],
traceText: "",
showRawTrace: false,
resultView: "source",
});
}
}, 500);
useEffect(() => {
if (!activeTab || !autoRunReady) return;
runTransform(
injectParamBlock(activeTab.xslt, activeTab.params),
activeTab.version,
activeTab.params,
activeTab.id,
);
}, [activeTab, traceEnabled, autoRunReady]);
useEffect(() => {
syncParams();
}, [active, syncParams]);
useEffect(() => {
if (!editorFocused) syncParams();
}, [editorFocused, syncParams]);
const updateParam = (index, field, value) => {
setTabs((tabs) =>
tabs.map((t) => {
if (t.id !== active) return t;
const params = [...t.params];
if (field === "open" && value) {
params.forEach((p, i) => {
if (i !== index) p.open = false;
});
}
params[index] = { ...params[index], [field]: value };
return { ...t, params };
}),
);
};
const addParam = () => {
setTabs((tabs) =>
tabs.map((t) =>
t.id === active ? { ...t, params: [...t.params, { name: "", value: "", open: false }] } : t,
),
);
};
const removeParam = (index) => {
setTabs((tabs) =>
tabs.map((t) =>
t.id === active ? { ...t, params: t.params.filter((_, i) => i !== index) } : t,
),
);
};
const loadFile = (e, setter, prep = (t) => t) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setter(prep(reader.result));
reader.readAsText(file);
};
const handleDrop = (e, setter) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setter(reader.result);
reader.readAsText(file);
};
const handleDropNewParam = (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const name = file.name.replace(/\.[^.]+$/, "");
setTabs((tabs) =>
tabs.map((t) =>
t.id === active
? {
...t,
params: [
...t.params,
{ name, value: reader.result, open: false },
],
}
: t,
),
);
};
reader.readAsText(file);
};
const handleResultResizeStart = (event) => {
event.preventDefault();
resultResizeState.current = {
startY: event.clientY,
startHeight: resolvedResultHeight,
};
setIsResizingResult(true);
};
const handleParamResizeStart = (event) => {
if (paramsCollapsed) return;
event.preventDefault();
paramResizeState.current = {
startX: event.clientX,
startWidth: paramWidth || DEFAULT_PARAM_WIDTH,
};
setIsResizingParams(true);
};
const handleResetResultHeight = () => {
setResultHeight(null);
};
const handleRenameWorkspace = useCallback((id, name) => {
setTabs((current) =>
current.map((tab) =>
tab.id === id
? { ...tab, name: typeof name === "string" ? name.trim() : "" }
: tab,
),
);
}, []);
const download = useCallback((data, filename, mimeType = "text/xml") => {
const blob = new Blob([data], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}, []);
const handleExportWorkspace = useCallback(
(targetTab) => {
const tab = targetTab || activeTab;
if (!tab) return;
const statusSnapshot =
workspaceStatus[tab.id] || defaultWorkspaceStatus();
const payload = {
schemaVersion: WORKSPACE_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
workspace: {
params: tab.params,
xslt: tab.xslt,
version: tab.version,
},
status: statusSnapshot,
};
download(
JSON.stringify(payload, null, 2),
`workspace-${tab.id}.json`,
"application/json",
);
},
[activeTab, download, workspaceStatus],
);
const handleWorkspaceImport = useCallback(
(event) => {
const input = event.target;
const file = input?.files?.[0];
if (!file) {
if (input) input.value = "";
return;
}
if (tabsRef.current.length >= MAX_WORKSPACES) {
window.alert(
`You can only keep up to ${MAX_WORKSPACES} workspaces simultaneously.`,
);
if (input) input.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(reader.result || "{}");
const normalized = normalizeWorkspaceImport(parsed);
setTabs((current) => {
if (current.length >= MAX_WORKSPACES) {
window.alert(
`You can only keep up to ${MAX_WORKSPACES} workspaces simultaneously.`,
);
return current;
}
const nextTab = defaultTab({
params: normalized.workspace.params,
xslt: normalized.workspace.xslt,
version: normalized.workspace.version,
});
setWorkspaceStatus((prev) => ({
...prev,
[nextTab.id]: normalized.status,
}));
setActive(nextTab.id);
return [...current, nextTab];
});
} catch (err) {
console.error(err);
window.alert(
typeof err?.message === "string"
? err.message
: "Failed to import workspace file.",
);
} finally {
if (input) input.value = "";
}
};
reader.onerror = () => {
window.alert("Unable to read the workspace file.");
if (input) input.value = "";
};
reader.readAsText(file);
},
[setTabs, setActive, setWorkspaceStatus],
);
useEffect(() => {
if (!adsenseClient || !adsenseSlot) return;
const cancelIdle = runWhenIdle(() => {
if (!window.adsbygoogle) return;
try {
window.adsbygoogle.push({});
} catch {}
}, 2000);
return () => cancelIdle?.();
}, []);
useEffect(() => {
if (!ethicalAdsEnabled) {
setEthicalAdsReady(false);
return;
}
if (window.ethicalads) {
setEthicalAdsReady(true);
return;
}
let cancelled = false;
let script = null;
let fallback = null;
let onLoad = null;
let onError = null;
const loadEthicalAds = () => {
if (cancelled) return;
const existing = document.querySelector("script[data-ethicalads]");
script = existing || document.createElement("script");
if (!existing) {
script.src = "https://media.ethicalads.io/media/client/ethicalads.min.js";
script.async = true;
script.dataset.ethicalads = "true";
document.body.appendChild(script);
}
onLoad = () => setEthicalAdsReady(Boolean(window.ethicalads));
onError = () => setEthicalAdsReady(false);
script.addEventListener("load", onLoad);
script.addEventListener("error", onError);
fallback = window.setTimeout(() => {
if (!window.ethicalads) setEthicalAdsReady(false);
}, 3500);
};
const cancelIdle = runWhenIdle(loadEthicalAds, 2000);
return () => {
cancelled = true;
cancelIdle?.();
if (script && onLoad) script.removeEventListener("load", onLoad);
if (script && onError) script.removeEventListener("error", onError);
if (fallback) window.clearTimeout(fallback);
};
}, [ethicalAdsEnabled]);
useEffect(() => {
if (!ethicalAdsEnabled || !ethicalAdsReady || !ethicalSlotRef.current) return;
try {
ethicalSlotRef.current.innerHTML = "";
window.ethicalads?.load(ethicalSlotRef.current);
} catch {}
}, [ethicalAdsEnabled, ethicalAdsReady, ethicalAdVariant]);
return (
<div className="app-container">
<h1 className="sr-only">XSLT Playground - Online XSLT Editor and Tester</h1>
{ethicalAdsEnabled && (
<div
ref={ethicalSlotRef}
className="ethical-ad-placeholder"
data-ea-publisher={ethicalAdsPublisher}
data-ea-type={ethicalAdType}
data-ea-style={ethicalAdStyle}
style={{ height: ethicalAdHeight }}
aria-label="Advertisement"
/>
)}
<div className="tabs">
<TabsNav
tabs={tabs}
activeId={active}
onSelect={setActive}
onClose={handleRemoveWorkspace}
onExport={handleExportWorkspace}
onClear={handleClearWorkspace}
onRename={handleRenameWorkspace}
/>
<div className="tabs-right">
<button
type="button"
className="icon-button tab-add"
onClick={handleAddWorkspace}
disabled={tabs.length >= MAX_WORKSPACES}
aria-label="Add workspace"
title={
tabs.length >= MAX_WORKSPACES
? "Maximum number of workspaces reached"
: "Add workspace"
}
>
<Icon name="plus" />
</button>
<button
type="button"
className="icon-button tab-import"
onClick={() => workspaceImportRef.current?.click()}
title="Import workspace"
aria-label="Import workspace"
>
<Icon name="import" />
</button>
<input
ref={workspaceImportRef}
type="file"
accept="application/json,.json"
className="file-input"
onChange={handleWorkspaceImport}
aria-label="Import workspace file"
/>
</div>
</div>
<div className="main">
{paramsCollapsed ? (
<div className="params-collapsed">
<button
type="button"
className="icon-button"
title="Show data pipeline"
aria-label="Show data pipeline"
onClick={() => setParamsCollapsed(false)}
>
<Icon name="chevron-right" />
</button>
</div>
) : (
<>
<div
className="params"
style={{
width: `${paramWidth}px`,
flexBasis: `${paramWidth}px`,
}}
>
<DataPipelineHeader
collapsed={paramsCollapsed}
onToggleCollapsed={() => setParamsCollapsed((v) => !v)}
onAddParam={addParam}
/>
<div
className="params-body"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDropNewParam}
>
{activeTab.params.map((p, i) => (
<div key={i} className={`param-card${p.open ? " open" : ""}`}>
<div className="param-header-row">
<div className="param-name-wrap">
<button
type="button"
className={`icon-button param-toggle${p.open ? " open" : ""}`}
aria-label={p.open ? "Collapse parameter details" : "Expand parameter details"}
onClick={() => updateParam(i, "open", !p.open)}
>
<Icon name={p.open ? "chevron-down" : "chevron-right"} />
</button>
<input
className="param-name-input"
placeholder="Parameter name"
value={p.name}
onChange={(e) => updateParam(i, "name", e.target.value)}
aria-label="Parameter name"
/>
</div>
<button
type="button"
className="icon-button param-remove"
aria-label="Remove parameter"
onClick={() => removeParam(i)}
>
<Icon name="close" />
</button>
</div>
{p.open && (
<div
className="param-content"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.stopPropagation();
handleDrop(e, (t) => updateParam(i, "value", t));
}}
>
<div className="param-editor">
<Editor
height="150px"
language="xml"
theme={editorTheme}
value={p.value}
onChange={(v) => updateParam(i, "value", v || "")}
options={{
minimap: { enabled: false },
automaticLayout: true,
lineNumbers: "off",
}}
/>
</div>
<div className="param-footer">
<label
className="icon-button file-label param-upload"
aria-label="Upload parameter value"
title="Upload parameter value"
>
<Icon name="upload" />
<input
type="file"
accept=".xml"
className="file-input"
onChange={(e) => loadFile(e, (t) => updateParam(i, "value", t))}
aria-label="Upload parameter value file"
/>
</label>
<button
type="button"
className="icon-button param-download"
aria-label="Download parameter value"
onClick={() => download(p.value, `${p.name || "param"}.xml`)}
>
<Icon name="download" />
</button>
</div>
</div>
)}
</div>
))}
<div className="drop-hint">Drop your input XML files here..</div>
</div>
</div>
<div
className={`pane-divider${isResizingParams ? " dragging" : ""}`}
onMouseDown={handleParamResizeStart}
role="separator"
aria-label="Resize data pipeline width"
aria-orientation="vertical"
>
<span />
</div>
</>
)}
<div className="editor">
<div className="toggle">
<select
className="version-select"
value={activeTab.version}
onChange={(e) =>
setTabs((tabs) =>
tabs.map((t) =>
t.id === active
? {
...t,
version: e.target.value,
xslt: setStylesheetVersion(t.xslt, e.target.value),
}
: t,
),
)
}
aria-label="XSLT version"
>
<option value="1.0">XSLT 1.0</option>
<option value="2.0">XSLT 2.0</option>
</select>
<label className="trace-toggle">
<input
type="checkbox"
checked={traceEnabled}
onChange={(e) => setTraceEnabled(e.target.checked)}
/>
<span className="trace-toggle-box" aria-hidden="true" />
<span className="trace-toggle-label">Enable Internal Variables</span>
</label>
<div className="right-actions">
<label
className="icon-button file-label"
aria-label="Upload stylesheet"
title="Upload stylesheet"
>
<Icon name="upload" />
<input
type="file"
accept=".xsl,.xslt"
className="file-input"
aria-label="Upload stylesheet file"
onChange={(e) =>
loadFile(e, (t) =>
setTabs((tabs) =>
tabs.map((tab) =>
tab.id === active
? { ...tab, xslt: stripParamBlock(t), params: addParams(t,tab) }
: tab,
),
)
)
}
/>
</label>
<button
className="icon-button"
aria-label="Download stylesheet"
title="Download stylesheet"
onClick={() =>
download(
injectParamBlock(activeTab.xslt, activeTab.params),
"transform.xsl",
)
}
>
<Icon name="download" />
</button>
</div>
</div>
<div className="editor-split">
<div className="xslt-editor-wrap">
<Editor
height="100%"
language="xml"
theme={editorTheme}
wrapperProps={{
onDragOver: (e) => e.preventDefault(),
onDrop: (e) =>
handleDrop(e, (t) =>
setTabs((tabs) =>
tabs.map((tab) =>
tab.id === active ? { ...tab, xslt: stripParamBlock(t), params: addParams(t,tab) } : tab,
),
),
),
}}
value={editorFocused ? activeTab.xslt : injectParamBlock(activeTab.xslt, activeTab.params)}
onChange={(v) =>
setTabs((tabs) =>
tabs.map((tab) =>
tab.id === active ? { ...tab, xslt: stripParamBlock(v || ""), params: addParams(v,tab) } : tab,
),
)
}
onFocus={() => setEditorFocused(true)}
onBlur={() => {
setEditorFocused(false);
syncParams();
}}
options={{ minimap: { enabled: false }, automaticLayout: true }}
/>
</div>
{traceEnabled && (
<div className="trace-panel" style={{ width: traceCollapsed ? '2rem' : '30%' }}>
<div className="trace-header" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className="icon-button"
title={traceCollapsed ? 'Show trace' : 'Hide trace'}
onClick={() => setTraceCollapsed(v => !v)}
aria-label={traceCollapsed ? 'Show trace panel' : 'Hide trace panel'}
>
<Icon name={traceCollapsed ? "chevron-right" : "chevron-down"} />
</button>
{!traceCollapsed && (
<>
<span style={{ fontWeight: 'bold' }}>
Trace Variables {traceEntries.length ? `(${traceEntries.length})` : ''}
</span>
<div className="trace-header-actions">
<button
className="icon-button"
title="Copy all trace variables"
onClick={handleCopyAllTrace}
type="button"
disabled={!traceEntries.length && !traceText}
aria-label="Copy all trace variables"
>
<Icon name="copy" />
</button>
{traceText && (
<button
className="icon-button"
title={showRawTrace ? "Hide raw trace output" : "Show raw trace output"}
onClick={() => {
if (activeTab) {
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
showRawTrace: !prev.showRawTrace,
}));
}
}}
type="button"
aria-label={showRawTrace ? "Hide raw trace output" : "Show raw trace output"}
>
<Icon name={showRawTrace ? "terminal-off" : "terminal"} />
</button>
)}
</div>
</>
)}
</div>
{!traceCollapsed && (
<div className="trace-content">
{traceEntries.length > 0 && (
<div
className="trace-table-wrap"
ref={traceTableWrapRef}
onScroll={(e) => setTraceScrollLeft(e.currentTarget.scrollLeft)}
>
<table className="trace-table">
<colgroup>
<col style={{ width: `${traceNameWidth}px` }} />
<col />
</colgroup>
<tbody>
{traceEntries.map((t, i) => {
const rawName = (t?.name ?? "").toString();
const rawValue = (t?.value ?? "").toString();
const namePreview = truncateText(rawName, TRACE_NAME_LIMIT);
const valuePreview = truncateText(rawValue, TRACE_VALUE_LIMIT);
return (
<tr key={`${rawName}-${i}`}>
<td className="trace-name">
<div
className="trace-cell"
onMouseEnter={(e) => showTraceTooltip(e, rawName)}
onMouseLeave={hideTraceTooltip}
>
<pre
className="trace-preview trace-name-preview"
ref={registerTraceNameRef(i)}
>
{namePreview || EMPTY_SYMBOL}
</pre>
</div>
</td>
<td className="trace-value">
<div
className="trace-cell"
onMouseEnter={(e) => showTraceTooltip(e, rawValue)}
onMouseLeave={hideTraceTooltip}
>
<pre className="trace-preview trace-value-preview">{valuePreview || EMPTY_SYMBOL}</pre>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div
className="trace-divider"
style={{ left: `${traceNameWidth - 3 - traceScrollLeft}px` }}
onMouseDown={handleTraceDividerMouseDown}
role="separator"
aria-orientation="vertical"
/>
</div>
)}
{showRawTrace && traceText && (
<pre className="trace-raw-block">{traceText}</pre>
)}
{!traceEntries.length && !traceText && (
<div className="trace-empty">Trace output is empty.</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
<div
className={`result-resizer${isResizingResult ? " dragging" : ""}`}
onMouseDown={handleResultResizeStart}
role="separator"
aria-label="Resize result pane"
aria-orientation="horizontal"
>
<span />
</div>
<div
className="result"
style={{
height: `${resolvedResultHeight}px`,
minHeight: `${MIN_RESULT_HEIGHT}px`,
}}
>
{error && !errorCollapsed && (
<div className="error-box" role="alert" aria-live="assertive">
<div className="error-box-header">
<span>Errors</span>
<div className="error-box-actions">
<button
type="button"
className="icon-button"
onClick={copyErrors}
disabled={!canCopyErrors}
title="Copy all errors"
aria-label="Copy errors"
>
<Icon name="copy" />
</button>
<button
type="button"
className="icon-button"
onClick={() => setErrorCollapsed(true)}
title="Hide errors"
aria-label="Hide errors"
>
<Icon name="chevron-up" />
</button>
</div>
</div>
{limitedErrorLines.length > 0 ? (
<table className="error-table">
<tbody>
{limitedErrorLines.map((l, i) => (
<tr key={i} className="error-row">
<td className="error-icon" aria-hidden>
<Icon name="alert" />
</td>
<td className="error-text" title={l}>
{l}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="error-line">
<span className="error-icon" aria-hidden>
<Icon name="alert" />
</span>
<span className="error-text" title={error || ""}>
{error}
</span>
</div>
)}
{hasHiddenErrors && (
<div className="error-more">
+{(errorLines || []).length - MAX_ERROR_LINES} more
</div>
)}
</div>
)}
{error && errorCollapsed && (
<button
type="button"
className="icon-button error-expand-button"
onClick={() => setErrorCollapsed(false)}
title="Show errors"
aria-label="Show errors"
>
<Icon name="alert" />
</button>
)}
{showResultPane && (
<>
{duration !== null && (
<div className="success-box" role="status" aria-live="polite">
Success in {duration} ms
</div>
)}
<div className="result-actions">
{canRenderHtml && (
<button
type="button"
className={`icon-button result-view-toggle${effectiveResultView === "render" ? " active" : ""}`}
onClick={() => {
if (!activeTab) return;
const next = effectiveResultView === "render" ? "source" : "render";
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
resultView: next,
}));
}}
title={
effectiveResultView === "render"
? "Show source instead of rendered HTML"
: "Render HTML output"
}
aria-label={
effectiveResultView === "render"
? "Show source instead of rendered HTML"
: "Render HTML output"
}
>
<Icon name={effectiveResultView === "render" ? "code" : "globe"} />
</button>
)}
<button
className="icon-button result-format-button"
disabled={effectiveResultView !== "source"}
onClick={async () => {
if (effectiveResultView !== "source") return;
try {
const { default: formatXML } = await import("xml-formatter");
const formatted = formatXML(result);
if (activeTab) {
updateWorkspaceStatus(activeTab.id, (prev) => ({
...prev,
result: formatted,
}));
}
} catch {}
}}
title="Format result as pretty XML"
aria-label="Format result as pretty XML"
>
<Icon name="sparkles" />
</button>
<button
type="button"
className={`icon-button result-reset-button${isCustomResultHeight ? " active" : ""}`}
onClick={handleResetResultHeight}
title="Reset result pane height"
aria-label="Reset result pane height"
>
<Icon name="refresh" />
</button>
</div>
<div className="result-editor-wrap">
{effectiveResultView === "render" && canRenderHtml ? (
<div className="result-render">
<iframe
title="Rendered HTML output"
srcDoc={result || "<!-- empty -->"}
sandbox=""
loading="lazy"
/>
</div>
) : (
<Editor
height="100%"
language="xml"
theme={editorTheme}
value={result}
onMount={(editor) => (resultEditorRef.current = editor)}
options={{
readOnly: true,
minimap: { enabled: false },
automaticLayout: true,
wordWrap: "bounded",
wordWrapBreakAfterCharacters: ' \t})]?|>'
}}
/>
)}
</div>
</>
)}
</div>
<div className="footer">
<div className="footer-left">
<img src={logo} alt="XSLT Playground logo" className="logo" />
<strong>xsltplayground.com</strong>
<span className="footer-tagline">Free XSLT Editor &amp; Tester</span>
<a
className="news-link"
href={newsUrl}
target="_blank"
rel="noopener noreferrer"
>
Blog &amp; Tutorials
</a>
{resolvedVersion && (
<a
className="version-pill"
href={changelogUrl}
target="_blank"
rel="noopener noreferrer"
title="View CHANGELOG"
>
v{resolvedVersion}
</a>
)}
</div>
<div className="footer-right">
<span>© 2025 Alexandre Vazquez. All rights reserved.</span>
<a
href="https://alexandre-vazquez.com"
target="_blank"
rel="noopener noreferrer"
>
alexandre-vazquez.com
</a>
<button
type="button"
className={`icon-button footer-theme-toggle${isDarkTheme ? " active" : ""}`}
onClick={() => setTheme(isDarkTheme ? THEME_LIGHT : THEME_DARK)}
title="Toggle dark mode"
aria-label="Toggle dark mode"
>
<Icon name="moon" />
</button>
</div>
</div>
{traceHover && (
<div
className="trace-hover-tooltip"
style={{ top: traceHover.y, left: traceHover.x, maxWidth: traceHover.width }}
onMouseEnter={cancelTraceTooltipHide}
onMouseLeave={() => {
cancelTraceTooltipHide();
setTraceHover(null);
}}
>
<div className="trace-hover-actions">
<button
type="button"
className="icon-button"
onClick={copyTraceHover}
title="Copy value"
aria-label="Copy value"
>
<Icon name="copy" />
</button>
</div>
<pre>{traceHover.text}</pre>
</div>
)}
{widgetsReady && (
<Suspense fallback={null}>
<BuyMeACoffee />
<FeedbackWidget />
</Suspense>
)}
</div>
);
}
/* Optional Firebase Auth example
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
// const firebaseConfig = { ... };
// const app = initializeApp(firebaseConfig);
// const auth = getAuth(app);
// const provider = new GoogleAuthProvider();
// function login() { signInWithPopup(auth, provider); }
*/