Files
xslt-tools/src/App.tsx

750 lines
23 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import Toolbar from './components/Toolbar';
import EditorPanel from './components/EditorPanel';
import ActionDialog, {
type ActionDialogAction,
} from './components/ActionDialog';
import HelpDialog from './components/HelpDialog';
import SnippetToolbox from './components/SnippetToolbox';
import { useWorkbenchState } from './workspace/useWorkbenchState';
import type { WorkbenchDocumentKind } from './workspace/workspaceTypes';
import type { CodeMirrorEditorHandle } from './editor/editorTypes';
import { openTextFile, saveTextFile } from './file/fileService';
import { validateXml, formatXml } from './validation/xmlValidation';
import { validateXslt } from './validation/xsltValidation';
import {
hasErrors,
type DiagnosticMessage,
} 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;
content: React.ReactNode;
actions: ActionDialogAction[];
onCancel?: () => void;
}
const App: React.FC = () => {
const {
state,
setState,
updateDocumentText,
replaceDocument,
markDocumentSaved,
setLastTransformation,
resetWorkspace,
} = useWorkbenchState();
const xmlInputEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
const xsltEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
const outputEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
const [dialog, setDialog] = useState<DialogState | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [runtimeDiagnostics, setRuntimeDiagnostics] = useState<
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'),
[state.xmlInput.text]
);
const xsltDiagnostics = useMemo(
() => validateXslt(state.xsltCode.text, state.selectedEngine),
[state.xsltCode.text, state.selectedEngine]
);
const outputDiagnostics = useMemo(() => {
if (!state.xmlOutput.text.trim()) return [];
return validateXml(state.xmlOutput.text, 'XML output');
}, [state.xmlOutput.text]);
const closeDialog = useCallback(() => {
dialog?.onCancel?.();
setDialog(null);
}, [dialog]);
const askForConfirmation = useCallback(
(options: {
title: string;
content: React.ReactNode;
proceedLabel?: string;
danger?: boolean;
}): Promise<boolean> => {
return new Promise((resolve) => {
const finish = (confirmed: boolean) => {
setDialog(null);
resolve(confirmed);
};
setDialog({
title: options.title,
content: options.content,
onCancel: () => resolve(false),
actions: [
{
label: 'Cancel',
variant: 'secondary',
autoFocus: true,
onClick: () => finish(false),
},
{
label: options.proceedLabel ?? 'Proceed',
variant: options.danger ? 'danger' : 'primary',
onClick: () => finish(true),
},
],
});
});
},
[]
);
const confirmOverwrite = useCallback(
async (kind: WorkbenchDocumentKind, reason: string): Promise<boolean> => {
const documentState = state[kind];
if (!documentState.text.trim() && !documentState.dirty) return true;
return askForConfirmation({
title: `Overwrite ${documentState.label}?`,
content: (
<p>
{reason} This will replace the current contents of{' '}
<strong>{documentState.label}</strong>.
</p>
),
proceedLabel: 'Overwrite',
danger: true,
});
},
[askForConfirmation, state]
);
const handleOpenHelp = useCallback(() => {
setHelpOpen(true);
}, []);
const handleCloseHelp = useCallback(() => {
setHelpOpen(false);
}, []);
const handleOpenFile = async (kind: WorkbenchDocumentKind) => {
try {
const opened = await openTextFile(kind);
if (!opened) return;
const documentState = state[kind];
if (documentState.dirty) {
const mayProceed = await askForConfirmation({
title: `Overwrite ${documentState.label}?`,
content: (
<p>
Opening <strong>{opened.name}</strong> will replace the current
unsaved contents of <strong>{documentState.label}</strong>.
</p>
),
proceedLabel: 'Overwrite',
danger: true,
});
if (!mayProceed) return;
}
replaceDocument(kind, opened.text, opened.name, false);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Loaded ${opened.name}.`);
} catch (error) {
if (isAbortError(error)) return;
setStatusMessage(
error instanceof Error ? error.message : 'Failed to open file.'
);
}
};
const handleSaveFile = async (kind: WorkbenchDocumentKind) => {
try {
const documentState = state[kind];
const suggestedName = documentState.fileName ?? defaultFileName(kind);
const savedName = await saveTextFile({
suggestedName,
text: documentState.text,
kind,
});
if (savedName) {
markDocumentSaved(kind, savedName);
setStatusMessage(`Saved ${savedName}.`);
}
} catch (error) {
if (isAbortError(error)) return;
setStatusMessage(
error instanceof Error ? error.message : 'Failed to save file.'
);
}
};
const handleApplyTransformation = async () => {
setValidationTouched(true);
setRuntimeDiagnostics([]);
const diagnostics = [...xmlDiagnostics, ...xsltDiagnostics];
if (hasErrors(diagnostics)) {
setRuntimeDiagnostics(diagnostics);
setStatusMessage('Please fix validation errors before transforming.');
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',
'Applying the transformation writes a new result.'
);
if (!mayOverwriteOutput) return;
}
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: currentState.xmlInput.text,
xsltText: currentState.xsltCode.text,
engine: currentState.selectedEngine,
};
const result = await runTransformation(request, {
trustedStylesheet,
currentRevision,
});
setRuntimeDiagnostics(result.diagnostics);
if (hasErrors(result.diagnostics)) {
setStatusMessage(
'Transformation failed because the input or stylesheet is invalid.'
);
return;
}
const output = currentState.options.prettifyOutputAfterTransform
? tryFormatXml(result.output)
: result.output;
replaceDocument(
'xmlOutput',
output,
currentState.xmlOutput.fileName,
true
);
const run = await createTransformationRun(request, output);
setLastTransformation(run);
setStatusMessage(`Transformation completed with ${result.engine}.`);
} catch (error) {
setRuntimeDiagnostics([
{
severity: 'error',
source: 'Transformation engine',
message:
error instanceof Error ? error.message : 'Transformation failed.',
},
]);
setStatusMessage('Transformation failed.');
} finally {
setBusy(false);
}
};
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.');
return;
}
const mayProceed = await confirmOverwrite(
'xmlInput',
'Moving output to input is useful for chained transformations.'
);
if (!mayProceed) return;
replaceDocument('xmlInput', state.xmlOutput.text, 'from-output.xml', true);
setStatusMessage('Moved output to XML input.');
};
const handleFormat = async (kind: WorkbenchDocumentKind) => {
const documentState = state[kind];
try {
const formatted = formatXml(documentState.text);
if (formatted === documentState.text) {
setStatusMessage(`${documentState.label} is already formatted.`);
return;
}
const mayProceed = await askForConfirmation({
title: `Format ${documentState.label}?`,
content: (
<p>
Formatting rewrites whitespace in{' '}
<strong>{documentState.label}</strong>.
</p>
),
proceedLabel: 'Format',
});
if (!mayProceed) return;
replaceDocument(kind, formatted, documentState.fileName, true);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Formatted ${documentState.label}.`);
} catch (error) {
setStatusMessage(
error instanceof Error ? error.message : 'Could not format XML.'
);
}
};
const handleValidate = () => {
setValidationTouched(true);
const allDiagnostics = [
...xmlDiagnostics,
...xsltDiagnostics,
...outputDiagnostics,
];
setRuntimeDiagnostics(allDiagnostics);
if (allDiagnostics.length === 0) {
setStatusMessage('All current documents are well-formed.');
return;
}
setStatusMessage(
hasErrors(allDiagnostics)
? 'Validation found errors.'
: 'Validation completed with warnings.'
);
};
const handleReset = async () => {
const mayProceed = await askForConfirmation({
title: 'Reset workspace?',
content: (
<p>
This restores the example XML and XSLT, clears the output, and removes
the saved LocalStorage workspace.
</p>
),
proceedLabel: 'Reset workspace',
danger: true,
});
if (!mayProceed) return;
resetWorkspace();
invalidateStylesheetTrust();
setRuntimeDiagnostics([]);
setValidationTouched(false);
setStatusMessage('Workspace reset.');
};
const handleEngineChange = (engine: TransformEngineId) => {
setState((current) => ({ ...current, selectedEngine: engine }));
};
const handlePrettifyOutputAfterTransformChange = (enabled: boolean) => {
setState((current) => ({
...current,
options: {
...current.options,
prettifyOutputAfterTransform: enabled,
},
}));
};
const handleAskBeforeOverwritingOutputChange = (enabled: boolean) => {
setState((current) => ({
...current,
options: {
...current.options,
askBeforeOverwritingOutput: enabled,
},
}));
};
const traceItems = useMemo(() => {
if (!state.xmlOutput.text.trim()) return [];
return createApproximateTrace(
state.xmlInput.text,
state.xsltCode.text,
state.xmlOutput.text
);
}, [state.xmlInput.text, state.xsltCode.text, state.xmlOutput.text]);
const displayedRuntimeDiagnostics = validationTouched
? runtimeDiagnostics
: [];
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.isComposing) {
return;
}
if (event.key === 'F1') {
event.preventDefault();
event.stopPropagation();
handleOpenHelp();
return;
}
const isApplyShortcut =
event.key === 'Enter' &&
event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!event.metaKey;
if (isApplyShortcut) {
event.preventDefault();
event.stopPropagation();
if (!busy) {
if (trustedStylesheet) void handleApplyTransformation();
else void handleTrustStylesheet();
}
}
};
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown, true);
};
});
return (
<AppShell
app={toolboxApp}
className="xslt-app-shell"
helpAction={{
onClick: handleOpenHelp,
label: 'Help',
title: 'Open help and keyboard shortcuts (F1)',
}}
>
<div className="workspace-grid">
<EditorPanel
ref={xmlInputEditorRef}
title="XML input"
value={state.xmlInput.text}
fileName={state.xmlInput.fileName}
dirty={state.xmlInput.dirty}
diagnostics={xmlDiagnostics}
onChange={(value) => updateDocumentText('xmlInput', value)}
onOpen={() => void handleOpenFile('xmlInput')}
onSave={() => void handleSaveFile('xmlInput')}
onUndo={() => xmlInputEditorRef.current?.undo()}
onRedo={() => xmlInputEditorRef.current?.redo()}
onCopy={() => void xmlInputEditorRef.current?.copySelection()}
onCut={() => void xmlInputEditorRef.current?.cutSelection()}
onPaste={() => void xmlInputEditorRef.current?.pasteText()}
onPrettify={() => void handleFormat('xmlInput')}
placeholderText="Paste or open XML here."
/>
<EditorPanel
ref={xsltEditorRef}
title="XSL transformation code"
value={state.xsltCode.text}
fileName={state.xsltCode.fileName}
dirty={state.xsltCode.dirty}
diagnostics={xsltDiagnostics}
onChange={(value) => {
invalidateStylesheetTrust();
updateDocumentText('xsltCode', value);
}}
onOpen={() => void handleOpenFile('xsltCode')}
onSave={() => void handleSaveFile('xsltCode')}
onUndo={() => xsltEditorRef.current?.undo()}
onRedo={() => xsltEditorRef.current?.redo()}
onCopy={() => void xsltEditorRef.current?.copySelection()}
onCut={() => void xsltEditorRef.current?.cutSelection()}
onPaste={() => void xsltEditorRef.current?.pasteText()}
onPrettify={() => void handleFormat('xsltCode')}
placeholderText="Write or open XSLT here."
actions={
<SnippetToolbox
onInsertSnippet={(snippet) =>
xsltEditorRef.current?.insertText(snippet)
}
/>
}
/>
<EditorPanel
ref={outputEditorRef}
title="XML output"
value={state.xmlOutput.text}
fileName={state.xmlOutput.fileName}
dirty={state.xmlOutput.dirty}
readOnly={false}
diagnostics={outputDiagnostics}
onChange={(value) => updateDocumentText('xmlOutput', value)}
onOpen={null}
onSave={() => void handleSaveFile('xmlOutput')}
onUndo={() => outputEditorRef.current?.undo()}
onRedo={() => outputEditorRef.current?.redo()}
onCopy={() => void outputEditorRef.current?.copySelection()}
onCut={() => void outputEditorRef.current?.cutSelection()}
onPaste={() => void outputEditorRef.current?.pasteText()}
onPrettify={() => void handleFormat('xmlOutput')}
placeholderText="Transformation output appears here."
/>
</div>
<Toolbar
selectedEngine={state.selectedEngine}
onEngineChange={handleEngineChange}
onApplyTransformation={() => void handleApplyTransformation()}
onTrustStylesheet={() => void handleTrustStylesheet()}
onMoveOutputToInput={() => void handleMoveOutputToInput()}
onValidate={handleValidate}
onReset={() => void handleReset()}
prettifyOutputAfterTransform={
state.options.prettifyOutputAfterTransform
}
onPrettifyOutputAfterTransformChange={
handlePrettifyOutputAfterTransformChange
}
askBeforeOverwritingOutput={state.options.askBeforeOverwritingOutput}
onAskBeforeOverwritingOutputChange={
handleAskBeforeOverwritingOutputChange
}
busy={busy}
stylesheetTrusted={trustedStylesheet !== null}
/>
{statusMessage && <div className="status-banner">{statusMessage}</div>}
{displayedRuntimeDiagnostics.length > 0 && (
<section className="card runtime-diagnostics">
<h2>Validation / runtime messages</h2>
<ul className="diagnostics-list">
{displayedRuntimeDiagnostics.map((diagnostic, index) => (
<li
key={`${diagnostic.source}-${diagnostic.message}-${index}`}
className={`diagnostic diagnostic-${diagnostic.severity}`}
>
<strong>{diagnostic.source}</strong>
<span>{diagnostic.message}</span>
</li>
))}
</ul>
</section>
)}
<section className="trace-panel card">
<div className="trace-header">
<div>
<h2>Explain transformation</h2>
<p>
MVP approximation: result nodes are compared with simple
stylesheet template matches. Full execution tracing requires an
instrumented engine later.
</p>
</div>
{state.lastTransformation && (
<small>
Last run:{' '}
{new Date(
state.lastTransformation.transformedAt
).toLocaleString()}
</small>
)}
</div>
{traceItems.length === 0 ? (
<p className="diagnostics-empty">
Run a transformation to see approximate result explanations.
</p>
) : (
<div className="trace-table-wrap">
<table className="trace-table">
<thead>
<tr>
<th>Result</th>
<th>Likely source</th>
<th>Likely template</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
{traceItems.map((item) => (
<tr key={item.resultPath}>
<td>
<code>{item.resultPath}</code>
</td>
<td>
<code>{item.likelySourcePath ?? '—'}</code>
</td>
<td>
<code>{item.likelyTemplate ?? '—'}</code>
</td>
<td>{item.confidence}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<ActionDialog
open={Boolean(dialog)}
title={dialog?.title ?? ''}
actions={dialog?.actions ?? []}
onClose={closeDialog}
>
{dialog?.content}
</ActionDialog>
{helpOpen && <HelpDialog open={helpOpen} onClose={handleCloseHelp} />}
</AppShell>
);
};
function tryFormatXml(text: string): string {
try {
return text.trim() ? formatXml(text) : text;
} catch {
return text;
}
}
function defaultFileName(kind: WorkbenchDocumentKind): string {
switch (kind) {
case 'xmlInput':
return 'input.xml';
case 'xsltCode':
return 'transform.xsl';
case 'xmlOutput':
return 'output.xml';
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
export default App;