feat: integrate XSLT Tools with toolbox portal

This commit is contained in:
2026-07-20 18:16:18 +02:00
parent 3193cc395c
commit 8f8a59e76c
36 changed files with 1737 additions and 243 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Layout from './components/Layout';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import Toolbar from './components/Toolbar';
import EditorPanel from './components/EditorPanel';
import ActionDialog, {
@@ -19,8 +19,14 @@ import {
} from './validation/validationTypes';
import { runTransformation } from './transform/transformService';
import { createTransformationRun } from './transform/nativeXsltEngine';
import { createSha256Hash } from './transform/hash';
import {
isStylesheetExecutionTrusted,
type TrustedStylesheet,
} from './transform/stylesheetTrust';
import type { TransformEngineId } from './transform/transformTypes';
import { createApproximateTrace } from './transform/traceAnalyzer';
import { toolboxApp } from './toolboxApp';
interface DialogState {
title: string;
@@ -52,6 +58,19 @@ const App: React.FC = () => {
DiagnosticMessage[]
>([]);
const [validationTouched, setValidationTouched] = useState(false);
const [trustedStylesheet, setTrustedStylesheet] =
useState<TrustedStylesheet | null>(null);
const stylesheetRevisionRef = useRef(0);
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const invalidateStylesheetTrust = () => {
stylesheetRevisionRef.current += 1;
setTrustedStylesheet(null);
};
const xmlDiagnostics = useMemo(
() => validateXml(state.xmlInput.text, 'XML input'),
@@ -161,6 +180,7 @@ const App: React.FC = () => {
}
replaceDocument(kind, opened.text, opened.name, false);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Loaded ${opened.name}.`);
} catch (error) {
if (isAbortError(error)) return;
@@ -203,6 +223,16 @@ const App: React.FC = () => {
return;
}
if (
!trustedStylesheet ||
trustedStylesheet.revision !== stylesheetRevisionRef.current
) {
setStatusMessage(
'Review and trust this exact stylesheet before transforming.'
);
return;
}
if (state.options.askBeforeOverwritingOutput) {
const mayOverwriteOutput = await confirmOverwrite(
'xmlOutput',
@@ -213,13 +243,33 @@ const App: React.FC = () => {
setBusy(true);
try {
const currentRevision = stylesheetRevisionRef.current;
const currentState = stateRef.current;
const currentHash = await createSha256Hash(currentState.xsltCode.text);
if (
currentRevision !== stylesheetRevisionRef.current ||
!isStylesheetExecutionTrusted(
trustedStylesheet,
currentHash,
currentRevision
)
) {
setTrustedStylesheet(null);
setStatusMessage(
'The stylesheet changed. Review and trust it again before transforming.'
);
return;
}
const request = {
xmlText: state.xmlInput.text,
xsltText: state.xsltCode.text,
engine: state.selectedEngine,
xmlText: currentState.xmlInput.text,
xsltText: currentState.xsltCode.text,
engine: currentState.selectedEngine,
};
const result = await runTransformation(request);
const result = await runTransformation(request, {
trustedStylesheet,
currentRevision,
});
setRuntimeDiagnostics(result.diagnostics);
if (hasErrors(result.diagnostics)) {
@@ -229,11 +279,16 @@ const App: React.FC = () => {
return;
}
const output = state.options.prettifyOutputAfterTransform
const output = currentState.options.prettifyOutputAfterTransform
? tryFormatXml(result.output)
: result.output;
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
replaceDocument(
'xmlOutput',
output,
currentState.xmlOutput.fileName,
true
);
const run = await createTransformationRun(request, output);
setLastTransformation(run);
@@ -253,6 +308,66 @@ const App: React.FC = () => {
}
};
const handleTrustStylesheet = async () => {
const revision = stylesheetRevisionRef.current;
const stylesheetText = stateRef.current.xsltCode.text;
try {
const hash = await createSha256Hash(stylesheetText);
const confirmed = await askForConfirmation({
title: 'Trust this executable stylesheet?',
content: (
<>
<p>
XSLT is executable code. Both engines may access browser or
network resources. Only continue if you trust the source of this
exact stylesheet.
</p>
<p>
A trusted SaxonJS stylesheet may access data stored by other
Toolbox apps on this same site, including PDF workspaces and
portal preferences. It may send that data over the network or
navigate this page away.
</p>
<p>
Size limits and a best-effort asynchronous deadline reduce
accidental overload, but the engines do not provide complete
process or network isolation. Synchronous work cannot be forcibly
stopped. Any edit, opened replacement, formatting change, or reset
revokes trust.
</p>
</>
),
proceedLabel: 'Trust this exact stylesheet',
danger: true,
});
if (!confirmed) {
setStatusMessage('Stylesheet remains untrusted.');
return;
}
if (
revision !== stylesheetRevisionRef.current ||
stylesheetText !== stateRef.current.xsltCode.text
) {
setTrustedStylesheet(null);
setStatusMessage(
'The stylesheet changed during review. Review it again.'
);
return;
}
setTrustedStylesheet({ hash, revision });
setStatusMessage(
'This exact stylesheet is trusted for the current session.'
);
} catch (error) {
setTrustedStylesheet(null);
setStatusMessage(
error instanceof Error
? error.message
: 'Could not fingerprint the stylesheet.'
);
}
};
const handleMoveOutputToInput = async () => {
if (!state.xmlOutput.text.trim()) {
setStatusMessage('The output editor is empty. Nothing to move.');
@@ -292,6 +407,7 @@ const App: React.FC = () => {
if (!mayProceed) return;
replaceDocument(kind, formatted, documentState.fileName, true);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Formatted ${documentState.label}.`);
} catch (error) {
setStatusMessage(
@@ -336,6 +452,7 @@ const App: React.FC = () => {
if (!mayProceed) return;
resetWorkspace();
invalidateStylesheetTrust();
setRuntimeDiagnostics([]);
setValidationTouched(false);
setStatusMessage('Workspace reset.');
@@ -402,7 +519,8 @@ const App: React.FC = () => {
event.stopPropagation();
if (!busy) {
void handleApplyTransformation();
if (trustedStylesheet) void handleApplyTransformation();
else void handleTrustStylesheet();
}
}
};
@@ -412,10 +530,25 @@ const App: React.FC = () => {
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [busy, handleApplyTransformation, handleOpenHelp]);
});
return (
<Layout onOpenHelp={() => handleOpenHelp()}>
<AppShell
app={toolboxApp}
className="xslt-app-shell"
appActions={
<button
type="button"
className="app-help-button"
onClick={handleOpenHelp}
aria-haspopup="dialog"
title="Open help and keyboard shortcuts (F1)"
aria-label="Open help"
>
<i className="bi bi-question-circle" aria-hidden="true" /> Help
</button>
}
>
<div className="workspace-grid">
<EditorPanel
ref={xmlInputEditorRef}
@@ -443,7 +576,10 @@ const App: React.FC = () => {
fileName={state.xsltCode.fileName}
dirty={state.xsltCode.dirty}
diagnostics={xsltDiagnostics}
onChange={(value) => updateDocumentText('xsltCode', value)}
onChange={(value) => {
invalidateStylesheetTrust();
updateDocumentText('xsltCode', value);
}}
onOpen={() => void handleOpenFile('xsltCode')}
onSave={() => void handleSaveFile('xsltCode')}
onUndo={() => xsltEditorRef.current?.undo()}
@@ -487,10 +623,13 @@ const App: React.FC = () => {
selectedEngine={state.selectedEngine}
onEngineChange={handleEngineChange}
onApplyTransformation={() => void handleApplyTransformation()}
onTrustStylesheet={() => void handleTrustStylesheet()}
onMoveOutputToInput={() => void handleMoveOutputToInput()}
onValidate={handleValidate}
onReset={() => void handleReset()}
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
prettifyOutputAfterTransform={
state.options.prettifyOutputAfterTransform
}
onPrettifyOutputAfterTransformChange={
handlePrettifyOutputAfterTransformChange
}
@@ -499,6 +638,7 @@ const App: React.FC = () => {
handleAskBeforeOverwritingOutputChange
}
busy={busy}
stylesheetTrusted={trustedStylesheet !== null}
/>
{statusMessage && <div className="status-banner">{statusMessage}</div>}
@@ -520,7 +660,6 @@ const App: React.FC = () => {
</section>
)}
<section className="trace-panel card">
<div className="trace-header">
<div>
@@ -586,13 +725,8 @@ const App: React.FC = () => {
{dialog?.content}
</ActionDialog>
{helpOpen && (
<HelpDialog
open={helpOpen}
onClose={handleCloseHelp}
/>
)}
</Layout>
{helpOpen && <HelpDialog open={helpOpen} onClose={handleCloseHelp} />}
</AppShell>
);
};