v0.3.1 with Saxon fully working
This commit is contained in:
622
src/App.tsx
Normal file
622
src/App.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Layout from './components/Layout';
|
||||
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 type { TransformEngineId } from './transform/transformTypes';
|
||||
import { createApproximateTrace } from './transform/traceAnalyzer';
|
||||
|
||||
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 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);
|
||||
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 (state.options.askBeforeOverwritingOutput) {
|
||||
const mayOverwriteOutput = await confirmOverwrite(
|
||||
'xmlOutput',
|
||||
'Applying the transformation writes a new result.'
|
||||
);
|
||||
if (!mayOverwriteOutput) return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const request = {
|
||||
xmlText: state.xmlInput.text,
|
||||
xsltText: state.xsltCode.text,
|
||||
engine: state.selectedEngine,
|
||||
};
|
||||
|
||||
const result = await runTransformation(request);
|
||||
setRuntimeDiagnostics(result.diagnostics);
|
||||
|
||||
if (hasErrors(result.diagnostics)) {
|
||||
setStatusMessage(
|
||||
'Transformation failed because the input or stylesheet is invalid.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const output = state.options.prettifyOutputAfterTransform
|
||||
? tryFormatXml(result.output)
|
||||
: result.output;
|
||||
|
||||
replaceDocument('xmlOutput', output, state.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 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);
|
||||
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();
|
||||
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) {
|
||||
void handleApplyTransformation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
};
|
||||
}, [busy, handleApplyTransformation, handleOpenHelp]);
|
||||
|
||||
return (
|
||||
<Layout onOpenHelp={() => handleOpenHelp()}>
|
||||
<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) => 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()}
|
||||
onMoveOutputToInput={() => void handleMoveOutputToInput()}
|
||||
onValidate={handleValidate}
|
||||
onReset={() => void handleReset()}
|
||||
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
|
||||
onPrettifyOutputAfterTransformChange={
|
||||
handlePrettifyOutputAfterTransformChange
|
||||
}
|
||||
askBeforeOverwritingOutput={state.options.askBeforeOverwritingOutput}
|
||||
onAskBeforeOverwritingOutputChange={
|
||||
handleAskBeforeOverwritingOutputChange
|
||||
}
|
||||
busy={busy}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user