v0.3.1 with Saxon fully working

This commit is contained in:
2026-06-05 03:19:04 +02:00
parent fc828363f0
commit 71f2f3d44b
51 changed files with 12683 additions and 1 deletions

622
src/App.tsx Normal file
View 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;

View File

@@ -0,0 +1,88 @@
import React, { useEffect } from 'react';
export interface ActionDialogAction {
label: string;
onClick: () => void | Promise<void>;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
autoFocus?: boolean;
title?: string;
}
interface ActionDialogProps {
open: boolean;
title: string;
children: React.ReactNode;
actions: ActionDialogAction[];
onClose: () => void;
}
const ActionDialog: React.FC<ActionDialogProps> = ({
open,
title,
children,
actions,
onClose,
}) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="action-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="action-dialog-title"
onPointerDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="action-dialog-panel">
<div className="action-dialog-header">
<h2 id="action-dialog-title">{title}</h2>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close dialog"
>
×
</button>
</div>
<div className="action-dialog-body">{children}</div>
<div className="action-dialog-actions">
{actions.map((action) => (
<button
key={action.label}
type="button"
className={`button ${action.variant ?? 'secondary'}`}
onClick={() => void action.onClick()}
disabled={action.disabled}
autoFocus={action.autoFocus}
title={action.title}
>
{action.label}
</button>
))}
</div>
</div>
</div>
);
};
export default ActionDialog;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import type { DiagnosticMessage } from '../validation/validationTypes';
interface DiagnosticsPanelProps {
diagnostics: DiagnosticMessage[];
}
const DiagnosticsPanel: React.FC<DiagnosticsPanelProps> = ({ diagnostics }) => {
if (diagnostics.length === 0) {
return <p className="diagnostics-empty">No diagnostics.</p>;
}
return (
<ul className="diagnostics-list">
{diagnostics.map((diagnostic, index) => (
<li
key={`${diagnostic.source}-${diagnostic.message}-${index}`}
className={`diagnostic diagnostic-${diagnostic.severity}`}
>
<strong>{diagnostic.severity.toUpperCase()}</strong>
<span>{diagnostic.message}</span>
</li>
))}
</ul>
);
};
export default DiagnosticsPanel;

View File

@@ -0,0 +1,189 @@
import React, { forwardRef } from 'react';
import CodeMirrorEditor from '../editor/CodeMirrorEditor';
import type { CodeMirrorEditorHandle } from '../editor/editorTypes';
import { summarizeDiagnostics } from '../editor/diagnostics';
import type { DiagnosticMessage } from '../validation/validationTypes';
import DiagnosticsPanel from './DiagnosticsPanel';
interface EditorPanelProps {
title: string;
value: string;
fileName?: string;
dirty?: boolean;
readOnly?: boolean;
diagnostics: DiagnosticMessage[];
onChange: (value: string) => void;
onOpen?: (() => void) | null;
onSave?: (() => void) | null;
onPrettify?: (() => void) | null;
onUndo?: () => void;
onRedo?: () => void;
onCopy?: () => void;
onCut?: () => void;
onPaste?: () => void;
actions?: React.ReactNode;
placeholderText?: string;
}
const EditorPanel = forwardRef<CodeMirrorEditorHandle, EditorPanelProps>(
(
{
title,
value,
fileName,
dirty = false,
readOnly = false,
diagnostics,
onChange,
onOpen,
onSave,
onUndo,
onRedo,
onCopy,
onCut,
onPaste,
onPrettify,
actions,
placeholderText,
},
ref
) => {
return (
<section className="editor-panel card">
<div className="editor-panel-header">
<div>
<h2>{title}</h2>
<small>
{fileName ?? 'No file'} {dirty ? '• unsaved changes' : ''}
</small>
</div>
<div className="diagnostics-badge">
{summarizeDiagnostics(diagnostics)}
</div>
</div>
<div className="editor-panel-actions">
{onOpen && (
<button
type="button"
className="editor-tool-button"
onClick={onOpen}
title="Open file"
aria-label="Open file"
>
<i className="bi bi-folder2-open" aria-hidden="true" />
</button>
)}
{onSave && (
<button
type="button"
className="editor-tool-button"
onClick={onSave}
title="Save file"
aria-label="Save file"
>
<i className="bi bi-save" aria-hidden="true" />
</button>
)}
{(onOpen || onSave) && (
<span className="editor-toolbar-separator" aria-hidden="true" />
)}
<button
type="button"
className="editor-tool-button"
onClick={onUndo}
disabled={readOnly || !onUndo}
title="Undo"
aria-label="Undo"
>
<i className="bi bi-arrow-counterclockwise" aria-hidden="true" />
</button>
<button
type="button"
className="editor-tool-button"
onClick={onRedo}
disabled={readOnly || !onRedo}
title="Redo"
aria-label="Redo"
>
<i className="bi bi-arrow-clockwise" aria-hidden="true" />
</button>
<span className="editor-toolbar-separator" aria-hidden="true" />
<button
type="button"
className="editor-tool-button"
onClick={onCut}
disabled={readOnly || !onCut}
title="Cut"
aria-label="Cut"
>
<i className="bi bi-scissors" aria-hidden="true" />
</button>
<button
type="button"
className="editor-tool-button"
onClick={onCopy}
disabled={!onCopy}
title="Copy"
aria-label="Copy"
>
<i className="bi bi-copy" aria-hidden="true" />
</button>
<button
type="button"
className="editor-tool-button"
onClick={onPaste}
disabled={readOnly || !onPaste}
title="Paste"
aria-label="Paste"
>
<i className="bi bi-clipboard" aria-hidden="true" />
</button>
{onPrettify && (
<>
<span className="editor-toolbar-separator" aria-hidden="true" />
<button
type="button"
className="editor-tool-button"
onClick={onPrettify}
title="Prettify XML"
aria-label="Prettify XML"
>
<i className="bi bi-magic" aria-hidden="true" />
</button>
</>
)}
{actions && (
<span className="editor-toolbar-separator" aria-hidden="true" />
)}
{actions}
</div>
<CodeMirrorEditor
ref={ref}
value={value}
onChange={onChange}
readOnly={readOnly}
ariaLabel={title}
placeholderText={placeholderText}
/>
<DiagnosticsPanel diagnostics={diagnostics} />
</section>
);
}
);
EditorPanel.displayName = 'EditorPanel';
export default EditorPanel;

View File

@@ -0,0 +1,72 @@
import React from 'react';
interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
if (!open) return null;
return (
<div className="help-dialog-backdrop" role="dialog" aria-modal="true">
<div className="help-dialog-panel">
<div className="help-dialog-header">
<div>
<h2>XSLT tools help</h2>
<p>Local-only XML/XSLT transformation workbench.</p>
</div>
<button type="button" className="icon-button" onClick={onClose}>
×
</button>
</div>
<div className="help-dialog-content">
<h3>What this tool does</h3>
<p>
The app keeps XML input, XSLT code, and XML output in three editor
panels. The current workspace is saved to LocalStorage and files are
opened/saved directly through the browser.
</p>
<h3>Transformation engine</h3>
<p>
The first version used the browser-native <code>XSLTProcessor</code>
, which is suitable for XSLT 1.0. This engine likely is to be retired
in late 2026. For XSLT 3.0, a SaxonJS engine was added that fully runs
in the browser (JS-only).
</p>
<h3>Keyboard shortcuts</h3>
<ul>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Z</kbd>: undo
</li>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>:
redo
</li>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>F</kbd>: find in the active
editor
</li>
<li>
<kbd>Tab</kbd>: indent selection
</li>
<li>
<kbd>Shift</kbd> + <kbd>Tab</kbd>: outdent selection
</li>
</ul>
<h3>Privacy</h3>
<p>
No upload code is included. Files are read locally by the browser
and the workspace is stored in your browser storage.
</p>
</div>
</div>
</div>
);
};
export default HelpDialog;

50
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,50 @@
import React from 'react';
import { APP_VERSION } from '../version';
interface LayoutProps {
children: React.ReactNode;
onOpenHelp?: () => void;
}
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
return (
<div className="app-root">
<header className="app-header">
<div className="app-header-content">
<div className="app-header-title">
<span className="app-logo" aria-hidden="true">
</span>
<div>
<h1>XSLT tools</h1>
<small>All transformations stay in your browser</small>
</div>
</div>
<div className="app-header-actions">
{onOpenHelp && (
<button
type="button"
className="app-help-button"
onClick={onOpenHelp}
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="app-version" title={`Version ${APP_VERSION}`}>
v{APP_VERSION}
</div>
</div>
</div>
</header>
<main className="app-main">{children}</main>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { xsltSnippets } from '../editor/xsltSnippets';
interface SnippetToolboxProps {
onInsertSnippet: (snippet: string) => void;
}
const SnippetToolbox: React.FC<SnippetToolboxProps> = ({ onInsertSnippet }) => {
return (
<details className="snippet-toolbox">
<summary>
<i className="bi bi-braces" aria-hidden="true" />
<span>Snippets</span>
</summary>
<div className="snippet-grid">
{xsltSnippets.map((snippet) => (
<button
key={snippet.id}
type="button"
className="snippet-button"
onClick={() => onInsertSnippet(snippet.text)}
title={snippet.description}
>
{snippet.label}
</button>
))}
</div>
</details>
);
};
export default SnippetToolbox;

115
src/components/Toolbar.tsx Normal file
View File

@@ -0,0 +1,115 @@
import React from 'react';
import type { TransformEngineId } from '../transform/transformTypes';
import { availableTransformEngines } from '../transform/transformService';
interface ToolbarProps {
selectedEngine: TransformEngineId;
onEngineChange: (engine: TransformEngineId) => void;
onApplyTransformation: () => void;
onMoveOutputToInput: () => void;
onValidate: () => void;
onReset: () => void;
prettifyOutputAfterTransform: boolean;
onPrettifyOutputAfterTransformChange: (enabled: boolean) => void;
askBeforeOverwritingOutput: boolean;
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
busy?: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
selectedEngine,
onEngineChange,
onApplyTransformation,
onMoveOutputToInput,
onValidate,
onReset,
prettifyOutputAfterTransform = false,
onPrettifyOutputAfterTransformChange,
askBeforeOverwritingOutput = true,
onAskBeforeOverwritingOutputChange,
busy = false,
}) => {
return (
<section className="toolbar card" aria-label="Workbench actions">
<div className="toolbar-row toolbar-row-main">
<button
type="button"
className="button primary"
onClick={onApplyTransformation}
disabled={busy}
title="Apply transformation (Ctrl/Cmd+Enter)"
>
{busy ? 'Transforming…' : 'Apply transformation'}
</button>
<button
type="button"
className="button secondary"
onClick={onMoveOutputToInput}
disabled={busy}
>
Output Input
</button>
<button type="button" className="button secondary" onClick={onValidate}>
Validate
</button>
<button
type="button"
className="button danger-outline"
onClick={onReset}
>
Reset
</button>
</div>
<div className="toolbar-row toolbar-options">
<label className="toggle-option">
<input
type="checkbox"
checked={prettifyOutputAfterTransform}
onChange={(event) =>
onPrettifyOutputAfterTransformChange(event.target.checked)
}
/>
<span className="toggle-slider" aria-hidden="true" />
<span>Prettify output after transform</span>
</label>
<label className="toggle-option">
<input
type="checkbox"
checked={askBeforeOverwritingOutput}
onChange={(event) =>
onAskBeforeOverwritingOutputChange(event.target.checked)
}
/>
<span className="toggle-slider" aria-hidden="true" />
<span>Ask before overwriting output</span>
</label>
</div>
<div className="toolbar-row toolbar-meta">
<label>
Engine
<select
value={selectedEngine}
onChange={(event) =>
onEngineChange(event.target.value as TransformEngineId)
}
>
{availableTransformEngines.map((engine) => (
<option key={engine.id} value={engine.id}>
{engine.label} ({engine.supportsXsltVersions.join(', ')})
</option>
))}
</select>
</label>
<span>
SaxonJS is the default engine for XSLT 2.0/3.0. Native browser
XSLTProcessor remains available for XSLT 1.0 fallback.
</span>
</div>
</section>
);
};
export default Toolbar;

View File

@@ -0,0 +1,205 @@
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder } from '@codemirror/view';
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
redo as cmRedo,
undo as cmUndo,
} from '@codemirror/commands';
import { xml } from '@codemirror/lang-xml';
import { searchKeymap } from '@codemirror/search';
import type { CodeMirrorEditorHandle } from './editorTypes';
interface CodeMirrorEditorProps {
value: string;
onChange: (nextValue: string) => void;
readOnly?: boolean;
ariaLabel: string;
minHeight?: string;
placeholderText?: string;
}
const editorTheme = EditorView.theme({
'&': {
minHeight: '100%',
height: '100%',
fontSize: '0.9rem',
},
'.cm-scroller': {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
},
'.cm-content': {
minHeight: '100%',
},
'.cm-gutters': {
borderRight: '1px solid #e5e7eb',
},
});
const CodeMirrorEditor = forwardRef<
CodeMirrorEditorHandle,
CodeMirrorEditorProps
>(
(
{
value,
onChange,
readOnly = false,
ariaLabel,
minHeight = '28rem',
placeholderText,
},
ref
) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const syncingExternalValueRef = useRef(false);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
basicSetup,
history(),
xml(),
keymap.of([
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
EditorState.readOnly.of(readOnly),
EditorView.lineWrapping,
editorTheme,
placeholder(placeholderText ?? ''),
EditorView.updateListener.of((update) => {
if (update.docChanged && !syncingExternalValueRef.current) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.contentAttributes.of({
'aria-label': ariaLabel,
spellcheck: 'false',
}),
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// The editor is created once. External value changes are synchronized below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentValue = view.state.doc.toString();
if (currentValue === value) return;
syncingExternalValueRef.current = true;
view.dispatch({
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
});
syncingExternalValueRef.current = false;
}, [value]);
useImperativeHandle(ref, () => ({
focus: () => viewRef.current?.focus(),
undo: () => {
const view = viewRef.current;
return view
? cmUndo({ state: view.state, dispatch: view.dispatch })
: false;
},
redo: () => {
const view = viewRef.current;
return view
? cmRedo({ state: view.state, dispatch: view.dispatch })
: false;
},
insertText: (text: string) => {
const view = viewRef.current;
if (!view || readOnly) return;
view.dispatch(view.state.replaceSelection(text), {
scrollIntoView: true,
});
view.focus();
},
copySelection: async () => {
const view = viewRef.current;
if (!view) return false;
const selectedText = getSelectedText(view);
if (!selectedText) return false;
await navigator.clipboard.writeText(selectedText);
return true;
},
cutSelection: async () => {
const view = viewRef.current;
if (!view || readOnly) return false;
const selectedText = getSelectedText(view);
if (!selectedText) return false;
await navigator.clipboard.writeText(selectedText);
view.dispatch(view.state.replaceSelection(''));
return true;
},
pasteText: async () => {
const view = viewRef.current;
if (!view || readOnly) return false;
const clipboardText = await navigator.clipboard.readText();
if (!clipboardText) return false;
view.dispatch(view.state.replaceSelection(clipboardText));
view.focus();
return true;
},
}));
return (
<div className="editor-host" style={{ minHeight }} ref={containerRef} />
);
}
);
function getSelectedText(view: EditorView): string {
return view.state.selection.ranges
.map((range) => view.state.sliceDoc(range.from, range.to))
.filter(Boolean)
.join('\n');
}
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
export default CodeMirrorEditor;

14
src/editor/diagnostics.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { DiagnosticMessage } from '../validation/validationTypes';
export function summarizeDiagnostics(diagnostics: DiagnosticMessage[]): string {
const errors = diagnostics.filter((item) => item.severity === 'error').length;
const warnings = diagnostics.filter(
(item) => item.severity === 'warning'
).length;
if (errors === 0 && warnings === 0) return 'No diagnostics';
if (errors > 0 && warnings > 0)
return `${errors} error(s), ${warnings} warning(s)`;
if (errors > 0) return `${errors} error(s)`;
return `${warnings} warning(s)`;
}

View File

@@ -0,0 +1,9 @@
export interface CodeMirrorEditorHandle {
focus: () => void;
undo: () => boolean;
redo: () => boolean;
insertText: (text: string) => void;
copySelection: () => Promise<boolean>;
cutSelection: () => Promise<boolean>;
pasteText: () => Promise<boolean>;
}

View File

@@ -0,0 +1,96 @@
export interface XsltSnippet {
id: string;
label: string;
description: string;
text: string;
}
export const xsltSnippets: XsltSnippet[] = [
{
id: 'stylesheet',
label: 'Stylesheet',
description: 'Basic XSLT 1.0 stylesheet skeleton.',
text: `<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
</xsl:template>
</xsl:stylesheet>`,
},
{
id: 'identity',
label: 'Identity transform',
description:
'Copy all nodes and attributes unless a more specific template overrides them.',
text: `<xsl:template match="@*|node()">
<xsl:copy>
<xsl:apply-templates select="@*|node()"/>
</xsl:copy>
</xsl:template>`,
},
{
id: 'template-match',
label: 'Template match',
description: 'Template matching an element name.',
text: `<xsl:template match="element-name">
</xsl:template>`,
},
{
id: 'value-of',
label: 'Value-of',
description: 'Insert the string value of a selected node.',
text: `<xsl:value-of select="."/>`,
},
{
id: 'apply-templates',
label: 'Apply templates',
description: 'Continue processing selected child nodes.',
text: `<xsl:apply-templates select="node()"/>`,
},
{
id: 'for-each',
label: 'For each',
description: 'Loop over a selected node set.',
text: `<xsl:for-each select="items/item">
</xsl:for-each>`,
},
{
id: 'choose',
label: 'Choose/when',
description: 'Conditional branching.',
text: `<xsl:choose>
<xsl:when test="condition">
</xsl:when>
<xsl:otherwise>
</xsl:otherwise>
</xsl:choose>`,
},
{
id: 'attribute',
label: 'Attribute',
description: 'Create an output attribute.',
text: `<xsl:attribute name="name">
<xsl:value-of select="@source"/>
</xsl:attribute>`,
},
{
id: 'param',
label: 'Parameter',
description: 'Declare a stylesheet parameter.',
text: `<xsl:param name="parameterName" select="'default value'"/>`,
},
{
id: 'variable',
label: 'Variable',
description: 'Declare a local or global variable.',
text: `<xsl:variable name="variableName" select="expression"/>`,
},
];

135
src/file/fileService.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { OpenedTextFile, SaveTextFileOptions } from './fileTypes';
import type { WorkbenchDocumentKind } from '../workspace/workspaceTypes';
type FilePickerAcceptType = {
description?: string;
accept: Record<string, string[]>;
};
type WritableFileHandle = {
write(data: Blob): Promise<void> | void;
close(): Promise<void> | void;
};
type LocalFileSystemFileHandle = {
name: string;
getFile(): Promise<File>;
createWritable(): Promise<WritableFileHandle>;
};
type WindowWithFilePicker = Window & {
showOpenFilePicker?: (options?: {
multiple?: boolean;
types?: FilePickerAcceptType[];
excludeAcceptAllOption?: boolean;
}) => Promise<LocalFileSystemFileHandle[]>;
showSaveFilePicker?: (options?: {
suggestedName?: string;
types?: FilePickerAcceptType[];
excludeAcceptAllOption?: boolean;
}) => Promise<LocalFileSystemFileHandle>;
};
const XML_TYPES: FilePickerAcceptType[] = [
{
description: 'XML and XSLT files',
accept: {
'application/xml': ['.xml', '.xsl', '.xslt'],
'text/xml': ['.xml', '.xsl', '.xslt'],
'text/plain': ['.txt'],
},
},
];
export async function openTextFile(
kind?: WorkbenchDocumentKind
): Promise<OpenedTextFile | null> {
const pickerWindow = window as WindowWithFilePicker;
if (pickerWindow.showOpenFilePicker) {
const [handle] = await pickerWindow.showOpenFilePicker({
multiple: false,
types: XML_TYPES,
excludeAcceptAllOption: false,
});
if (!handle) return null;
const file = await handle.getFile();
return { name: file.name, text: await file.text() };
}
return openWithInputFallback(kind);
}
export async function saveTextFile({
suggestedName,
text,
mimeType = 'application/xml;charset=utf-8',
}: SaveTextFileOptions): Promise<string | null> {
const pickerWindow = window as WindowWithFilePicker;
if (pickerWindow.showSaveFilePicker) {
const handle = await pickerWindow.showSaveFilePicker({
suggestedName,
types: XML_TYPES,
excludeAcceptAllOption: false,
});
const writable = await handle.createWritable();
await writable.write(new Blob([text], { type: mimeType }));
await writable.close();
return handle.name;
}
saveViaDownloadFallback(suggestedName, text, mimeType);
return suggestedName;
}
function openWithInputFallback(
kind?: WorkbenchDocumentKind
): Promise<OpenedTextFile | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = kind === 'xsltCode' ? '.xsl,.xslt,.xml,.txt' : '.xml,.txt';
input.style.display = 'none';
input.addEventListener(
'change',
async () => {
const file = input.files?.[0];
input.remove();
if (!file) {
resolve(null);
return;
}
resolve({ name: file.name, text: await file.text() });
},
{ once: true }
);
document.body.appendChild(input);
input.click();
});
}
function saveViaDownloadFallback(
suggestedName: string,
text: string,
mimeType: string
): void {
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = suggestedName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 500);
}

13
src/file/fileTypes.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { WorkbenchDocumentKind } from '../workspace/workspaceTypes';
export interface OpenedTextFile {
name: string;
text: string;
}
export interface SaveTextFileOptions {
suggestedName: string;
text: string;
mimeType?: string;
kind?: WorkbenchDocumentKind;
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import 'bootstrap-icons/font/bootstrap-icons.css';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

670
src/styles.css Normal file
View File

@@ -0,0 +1,670 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
color-scheme: light;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background: #f3f4f6;
color: #111827;
}
body {
margin: 0;
min-width: 1180px;
background: #f3f4f6;
}
button,
select,
input {
font: inherit;
}
code,
kbd {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
monospace;
}
.app-root {
min-height: 100vh;
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
background: #111827;
color: #e5e7eb;
padding: 0.65rem 1rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4);
}
.app-header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.app-header-title {
display: flex;
align-items: center;
gap: 0.65rem;
}
.app-logo {
font-size: 1.35rem;
}
.app-header h1 {
font-size: 1.05rem;
margin: 0;
}
.app-header small {
color: #9ca3af;
font-size: 0.8rem;
}
.app-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-version {
border-radius: 999px;
background: #374151;
color: #d1d5db;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.app-help-button {
border: 1px solid #4b5563;
border-radius: 999px;
background: transparent;
color: #e5e7eb;
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
line-height: 1;
}
.app-help-button:hover,
.app-help-button:focus-visible {
background: #374151;
outline: none;
}
.app-main {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
width: 100%;
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
}
.button,
.mini-button,
.snippet-button,
.icon-button,
.editor-tool-button {
border: none;
cursor: pointer;
}
.button:disabled,
.mini-button:disabled,
.snippet-button:disabled,
.editor-tool-button:disabled {
opacity: 0.55;
cursor: default;
}
.button {
border-radius: 0.5rem;
padding: 0.45rem 0.85rem;
font-size: 0.9rem;
font-weight: 600;
}
.button.primary {
background: #2563eb;
color: #ffffff;
}
.button.secondary {
background: #e5e7eb;
color: #111827;
}
.button.danger {
background: #dc2626;
color: #ffffff;
}
.button.danger-outline {
border: 1px solid #fecaca;
background: #fff1f2;
color: #991b1b;
}
.icon-button {
width: 1.9rem;
height: 1.9rem;
border-radius: 999px;
background: #e5e7eb;
color: #111827;
font-size: 1.2rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.toolbar {
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-row-main {
padding-top: 0;
}
.toolbar-options {
align-items: center;
color: #374151;
font-size: 0.85rem;
}
.toggle-option {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.toggle-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggle-slider {
position: relative;
width: 2.35rem;
height: 1.25rem;
border-radius: 999px;
background: #d1d5db;
transition:
background 0.15s ease,
box-shadow 0.15s ease;
}
.toggle-slider::before {
content: '';
position: absolute;
top: 0.17rem;
left: 0.17rem;
width: 0.91rem;
height: 0.91rem;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.25);
transition: transform 0.15s ease;
}
.toggle-option input:checked + .toggle-slider {
background: #2563eb;
}
.toggle-option input:checked + .toggle-slider::before {
transform: translateX(1.1rem);
}
.toggle-option input:focus-visible + .toggle-slider {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.22);
}
.toolbar-meta {
justify-content: space-between;
color: #6b7280;
font-size: 0.85rem;
}
.toolbar-meta label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #374151;
}
.toolbar-meta select {
min-width: 18rem;
padding: 0.35rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: #ffffff;
}
.status-banner {
border-left: 4px solid #2563eb;
border-radius: 0.65rem;
background: #eff6ff;
color: #1e3a8a;
padding: 0.65rem 0.8rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.runtime-diagnostics {
padding: 0.85rem;
}
.runtime-diagnostics h2 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.workspace-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
align-items: stretch;
}
.editor-panel {
min-width: 0;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.editor-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.editor-panel h2 {
margin: 0;
font-size: 0.98rem;
}
.editor-panel small {
color: #6b7280;
font-size: 0.78rem;
}
.diagnostics-badge {
flex: 0 0 auto;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
padding: 0.3rem 0.55rem;
font-size: 0.75rem;
font-weight: 700;
}
.editor-panel-actions {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
align-items: center;
padding: 0.25rem;
border: 1px solid #e5e7eb;
border-radius: 0.55rem;
background: #f9fafb;
}
.editor-tool-button {
width: 1.9rem;
height: 1.9rem;
border-radius: 0.4rem;
background: transparent;
color: #374151;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
line-height: 1;
}
.editor-tool-button i {
pointer-events: none;
}
.editor-tool-button:hover:not(:disabled),
.editor-tool-button:focus-visible {
background: #e5e7eb;
outline: none;
}
.editor-toolbar-separator {
width: 1px;
align-self: stretch;
min-height: 1.45rem;
margin: 0 0.2rem;
background: #d1d5db;
}
.mini-button,
.snippet-button {
border-radius: 0.4rem;
background: #f3f4f6;
color: #374151;
padding: 0.28rem 0.48rem;
font-size: 0.78rem;
font-weight: 600;
}
.mini-button:hover,
.snippet-button:hover {
background: #e5e7eb;
}
.editor-host {
overflow: hidden;
border: 1px solid #d1d5db;
border-radius: 0.65rem;
background: #ffffff;
}
.editor-host .cm-editor {
min-height: 28rem;
height: 28rem;
}
.editor-host .cm-focused {
outline: 2px solid #bfdbfe;
outline-offset: -2px;
}
.snippet-toolbox {
position: relative;
}
.snippet-toolbox summary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.snippet-toolbox summary::-webkit-details-marker {
display: none;
}
.snippet-grid {
position: absolute;
z-index: 5;
right: 0;
top: 1.8rem;
width: 18rem;
max-height: 18rem;
overflow: auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.55rem;
border: 1px solid #c7d2fe;
border-radius: 0.65rem;
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.22);
}
.diagnostics-empty {
margin: 0;
color: #6b7280;
font-size: 0.82rem;
}
.diagnostics-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.diagnostic {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem;
align-items: start;
border-radius: 0.5rem;
padding: 0.45rem 0.55rem;
font-size: 0.8rem;
}
.diagnostic strong {
font-size: 0.72rem;
}
.diagnostic-error {
background: #fef2f2;
color: #991b1b;
}
.diagnostic-warning {
background: #fffbeb;
color: #92400e;
}
.diagnostic-info {
background: #eff6ff;
color: #1e40af;
}
.trace-panel {
padding: 0.85rem;
}
.trace-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.trace-header h2 {
margin: 0;
font-size: 0.98rem;
}
.trace-header p {
margin: 0.2rem 0 0;
color: #6b7280;
font-size: 0.86rem;
}
.trace-header small {
color: #6b7280;
white-space: nowrap;
}
.trace-table-wrap {
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 0.65rem;
}
.trace-table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
.trace-table th,
.trace-table td {
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
text-align: left;
vertical-align: top;
}
.trace-table th {
background: #f9fafb;
color: #374151;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.action-dialog-backdrop,
.help-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.action-dialog-panel {
width: min(440px, 100%);
background: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.action-dialog-header,
.help-dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.action-dialog-header h2,
.help-dialog-header h2 {
margin: 0;
font-size: 1rem;
}
.action-dialog-body {
color: #4b5563;
font-size: 0.9rem;
line-height: 1.45;
}
.action-dialog-body p {
margin: 0;
}
.action-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.help-dialog-backdrop {
z-index: 80;
}
.help-dialog-panel {
width: min(900px, 100%);
max-height: min(88vh, 760px);
overflow: auto;
background: #ffffff;
border-radius: 0.9rem;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
}
.help-dialog-header {
position: sticky;
top: 0;
z-index: 1;
padding: 1rem;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
.help-dialog-header p {
margin: 0.2rem 0 0;
color: #6b7280;
}
.help-dialog-content {
padding: 1rem;
color: #374151;
line-height: 1.5;
}
.help-dialog-content h3 {
margin: 1rem 0 0.35rem;
}
.help-dialog-content h3:first-child {
margin-top: 0;
}
kbd {
border: 1px solid #d1d5db;
border-bottom-width: 2px;
border-radius: 0.3rem;
background: #f9fafb;
padding: 0.05rem 0.25rem;
font-size: 0.82em;
}
@media (max-width: 1280px) {
body {
min-width: 0;
}
.workspace-grid {
grid-template-columns: 1fr;
}
.editor-host .cm-editor {
height: 24rem;
}
.toolbar-meta {
align-items: flex-start;
flex-direction: column;
}
}

20
src/transform/hash.ts Normal file
View File

@@ -0,0 +1,20 @@
export async function createSha256Hash(text: string): Promise<string> {
if (!window.crypto?.subtle) {
return createFallbackHash(text);
}
const data = new TextEncoder().encode(text);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function createFallbackHash(text: string): string {
let hash = 0;
for (let index = 0; index < text.length; index += 1) {
hash = (hash << 5) - hash + text.charCodeAt(index);
hash |= 0;
}
return `fallback-${Math.abs(hash).toString(16)}`;
}

View File

@@ -0,0 +1,69 @@
import { createSha256Hash } from './hash';
import { serializeResultDocument } from './serialization';
import type {
TransformEngine,
TransformRequest,
TransformResult,
TransformationRun,
} from './transformTypes';
import { parseXmlDocument } from '../validation/xmlValidation';
import { validateXslt } from '../validation/xsltValidation';
import { hasErrors } from '../validation/validationTypes';
export const nativeXsltEngine: TransformEngine = {
id: 'native-xsltprocessor',
label: 'Native browser XSLTProcessor',
supportsXsltVersions: ['1.0'],
async transform(request: TransformRequest): Promise<TransformResult> {
const xmlParse = parseXmlDocument(request.xmlText, 'XML input');
const xsltDiagnostics = validateXslt(request.xsltText, request.engine);
const diagnostics = [...xmlParse.diagnostics, ...xsltDiagnostics];
if (hasErrors(diagnostics)) {
return {
output: '',
diagnostics,
engine: request.engine,
transformedAt: new Date().toISOString(),
};
}
const stylesheetParse = parseXmlDocument(
request.xsltText,
'XSLT stylesheet'
);
const processor = new XSLTProcessor();
processor.importStylesheet(stylesheetParse.document);
const resultDocument = processor.transformToDocument(xmlParse.document);
const output = serializeResultDocument(resultDocument);
return {
output,
diagnostics,
engine: request.engine,
transformedAt: new Date().toISOString(),
};
},
};
export async function createTransformationRun(
request: TransformRequest,
output: string
): Promise<TransformationRun> {
const [xmlInputHash, xsltHash, outputHash] = await Promise.all([
createSha256Hash(request.xmlText),
createSha256Hash(request.xsltText),
createSha256Hash(output),
]);
return {
engine: request.engine,
transformedAt: new Date().toISOString(),
xmlInputHash,
xsltHash,
outputHash,
outputLength: output.length,
};
}

View File

@@ -0,0 +1,309 @@
type SefNode = Record<string, unknown> & {
N?: string;
C?: SefNode[];
firstChild?: unknown;
parentNode?: SefNode;
};
type SaxonXdmMap = {
inSituPut: (key: unknown, value: unknown[]) => void;
};
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
let saxonLoadPromise: Promise<void> | null = null;
type SaxonPrivateRuntime = {
getPlatform?: () => {
resource?: (name: string) => unknown;
parseXmlFromString?: (text: string) => unknown;
};
checkOptions?: (options: Record<string, unknown>) => Record<string, unknown>;
internalTransform?: (
stylesheetInternal: unknown,
source: unknown,
checkedOptions: Record<string, unknown>
) => void;
getResource?: (options: {
file?: string;
location?: string;
text?: string;
type?: 'xml' | 'json' | string;
}) => Promise<unknown>;
XPath?: {
sefToJSON?: (node: unknown, keepDebug: boolean) => unknown;
};
XS?: {
QName?: {
fromParts: (prefix: string, uri: string, local: string) => unknown;
};
};
XdmMap?: new () => SaxonXdmMap;
transform?: (
options: Record<string, unknown>,
execution?: 'sync' | 'async'
) => Promise<{ principalResult?: unknown }>;
};
async function getSaxon(): Promise<SaxonPrivateRuntime> {
await ensureSaxonLoaded();
const saxon = window.SaxonJS as SaxonPrivateRuntime | undefined;
if (!saxon) {
throw new Error('window.SaxonJS is not loaded.');
}
return saxon;
}
async function ensureSaxonLoaded(): Promise<void> {
if (window.SaxonJS) return;
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${SAXON_SCRIPT_URL}"]`
);
if (existingScript) {
existingScript.addEventListener('load', () => resolve(), { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('Failed to load SaxonJS2.js.')),
{ once: true }
);
return;
}
const script = document.createElement('script');
script.src = SAXON_SCRIPT_URL;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));
document.head.appendChild(script);
});
await saxonLoadPromise;
}
function addParentPointers(node: SefNode): void {
node.C?.forEach((child) => {
child.parentNode = node;
addParentPointers(child);
});
}
function checksum(sef: SefNode): void {
function hashString(value: string, seed: number): number {
let current = seed << 8;
for (let index = 0; index < value.length; index += 1) {
current = (current << 1) + value.charCodeAt(index);
}
return current;
}
function hashPair(name: string, uri: string, seed: number): number {
return hashString(name, seed) ^ hashString(uri, seed);
}
let hash = 0;
let counter = 0;
function visit(node: SefNode): void {
hash ^= hashPair(
String(node.N ?? ''),
'http://ns.saxonica.com/xslt/export',
counter++
);
for (const [key, value] of Object.entries(node)) {
if (key !== 'N' && key !== 'C' && key !== String.fromCharCode(931)) {
hash ^= hashPair(key, '', counter);
hash ^= hashString(String(value), counter);
}
}
node.C?.forEach((child) => visit(child));
hash ^= 1;
}
visit(sef);
sef[String.fromCharCode(931)] = (
hash < 0 ? 4294967295 + hash + 1 : hash
).toString(16);
}
function getFirstPrincipalNode(principalResult: unknown): SefNode {
const value = Array.isArray(principalResult)
? principalResult[0]
: principalResult;
if (!value) {
throw new Error('The SaxonJS compiler returned no principal result.');
}
return value as SefNode;
}
function getSyntheticStylesheetBaseUri(): string {
return new URL(
'/__xsl-tools__/in-memory-stylesheet.xsl',
window.location.href
).href;
}
function serializeSaxonResult(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value instanceof XMLDocument || value instanceof Document) {
return new XMLSerializer().serializeToString(value);
}
if (value instanceof Element) {
return new XMLSerializer().serializeToString(value);
}
if (value instanceof DocumentFragment) {
const container = document.createElement('div');
container.append(
...Array.from(value.childNodes).map((node) => node.cloneNode(true))
);
return container.innerHTML;
}
return String(value ?? '');
}
async function parseXmlForSaxon(
saxon: SaxonPrivateRuntime,
xmlText: string
): Promise<Record<string, unknown>> {
const baseUri = getSyntheticStylesheetBaseUri();
if (saxon.getResource) {
try {
const parsed = (await saxon.getResource({
text: xmlText,
type: 'xml',
})) as Record<string, unknown>;
parsed._saxonBaseUri = baseUri;
parsed._saxonDocUri = baseUri;
return parsed;
} catch {
// Some browser/runtime combinations only support parsing through the platform adapter.
}
}
const parsed = saxon.getPlatform?.().parseXmlFromString?.(xmlText) as
| Record<string, unknown>
| undefined;
if (!parsed) {
throw new Error(
'Could not parse XML for SaxonJS: neither getResource({ text }) nor platform.parseXmlFromString(...) is available.'
);
}
parsed._saxonBaseUri = baseUri;
parsed._saxonDocUri = baseUri;
return parsed;
}
export async function compileXsltTextToSefJson(
xsltText: string,
compilerOptions: Record<string, unknown> = {}
): Promise<string> {
const saxon = await getSaxon();
const platform = saxon.getPlatform?.();
if (!platform?.resource) {
throw new Error('SaxonJS platform.resource(...) is not available.');
}
if (!saxon.checkOptions || !saxon.internalTransform) {
throw new Error(
'SaxonJS internal compiler APIs are not available: checkOptions/internalTransform missing.'
);
}
if (!saxon.XPath?.sefToJSON) {
throw new Error('SaxonJS.XPath.sefToJSON(...) is not available.');
}
if (!saxon.XdmMap || !saxon.XS?.QName?.fromParts) {
throw new Error('SaxonJS XdmMap/QName APIs are not available.');
}
const compiler = platform.resource('compiler') as SefNode | undefined;
if (!compiler || compiler.N !== 'package') {
throw new Error(
'SaxonJS compiler resource is not available. You are probably loading SaxonJS2.rt.js, which contains only the runtime. Use the full SaxonJS2.js browser file for dynamic XSLT-to-SEF compilation.'
);
}
addParentPointers(compiler);
const stylesheetParams = new saxon.XdmMap();
const staticParameters = new saxon.XdmMap();
stylesheetParams.inSituPut(
saxon.XS.QName.fromParts('', '', 'staticParameters'),
[staticParameters]
);
const source = await parseXmlForSaxon(saxon, xsltText);
const options = {
destination: 'application',
initialMode: 'compile-complete',
isDynamicStylesheet: true,
templateParams: {
'Q{}stylesheet-base-uri': getSyntheticStylesheetBaseUri(),
'Q{}options': {
noXPath: false,
...compilerOptions,
},
},
stylesheetParams,
stylesheetInternal: compiler,
outputProperties: {},
};
if (compiler.relocatable === 'true') {
(options as Record<string, unknown>).isRelocatableStylesheet = true;
}
const checkedOptions = saxon.checkOptions(options);
saxon.internalTransform(compiler, source, checkedOptions);
const sefXml = getFirstPrincipalNode(checkedOptions.principalResult);
const sefJson = saxon.XPath.sefToJSON(
sefXml.firstChild ?? sefXml,
false
) as SefNode;
checksum(sefJson);
return JSON.stringify(sefJson);
}
export async function transformXmlWithDynamicSaxon(
xmlText: string,
xsltText: string
): Promise<string> {
const saxon = await getSaxon();
if (!saxon.transform) {
throw new Error('SaxonJS transform API is not available.');
}
const generatedSef = await compileXsltTextToSefJson(xsltText);
const result = await saxon.transform(
{
stylesheetText: generatedSef,
sourceText: xmlText,
destination: 'serialized',
},
'async'
);
return serializeSaxonResult(result.principalResult);
}

View File

@@ -0,0 +1,44 @@
import type {
TransformEngine,
TransformRequest,
TransformResult,
} from './transformTypes';
import { transformXmlWithDynamicSaxon } from './saxonJsDynamicCompiler';
export const saxonJsDynamicEngine: TransformEngine = {
id: 'saxon-js-dynamic',
label: 'SaxonJS 2 dynamic XSLT 3.0',
supportsXsltVersions: ['2.0', '3.0'],
async transform(request: TransformRequest): Promise<TransformResult> {
try {
const output = await transformXmlWithDynamicSaxon(
request.xmlText,
request.xsltText
);
return {
output,
diagnostics: [],
engine: request.engine,
transformedAt: new Date().toISOString(),
};
} catch (error) {
return {
output: '',
diagnostics: [
{
severity: 'error',
source: 'SaxonJS dynamic compiler',
message:
error instanceof Error
? error.message
: 'Dynamic SaxonJS transformation failed.',
},
],
engine: request.engine,
transformedAt: new Date().toISOString(),
};
}
},
};

View File

@@ -0,0 +1,37 @@
export interface SaxonJsTransformResult {
principalResult?: unknown;
resultDocuments?: Record<string, unknown>;
stylesheetInternal?: unknown;
masterDocument?: Document;
}
export interface SaxonJsRuntime {
transform(
options: {
stylesheetText?: string;
stylesheetLocation?: string;
stylesheetInternal?: unknown;
sourceText?: string;
sourceNode?: Node;
destination?: 'serialized' | 'document' | 'raw' | 'application';
logLevel?: number;
},
execution?: 'sync' | 'async'
): SaxonJsTransformResult | Promise<SaxonJsTransformResult>;
serialize?: (
value: unknown,
options?: {
method?: 'xml' | 'html' | 'text' | 'json' | 'adaptive';
indent?: boolean;
}
) => string;
}
declare global {
interface Window {
SaxonJS?: SaxonJsRuntime;
}
}
export {};

View File

@@ -0,0 +1,11 @@
import { prettyPrintXml } from '../validation/xmlValidation';
export function serializeResultDocument(result: Document): string {
const serialized = new XMLSerializer().serializeToString(result);
try {
return prettyPrintXml(serialized);
} catch {
return serialized;
}
}

View File

@@ -0,0 +1,77 @@
import { parseXmlDocument } from '../validation/xmlValidation';
export interface ApproximateTraceItem {
resultPath: string;
likelySourcePath?: string;
likelyTemplate?: string;
confidence: 'low' | 'medium';
}
export function createApproximateTrace(
sourceXml: string,
xslt: string,
outputXml: string
): ApproximateTraceItem[] {
const outputParse = parseXmlDocument(outputXml, 'XML output');
const xsltParse = parseXmlDocument(xslt, 'XSLT stylesheet');
const sourceParse = parseXmlDocument(sourceXml, 'XML input');
if (
outputParse.diagnostics.length > 0 ||
xsltParse.diagnostics.length > 0 ||
sourceParse.diagnostics.length > 0
) {
return [];
}
const templates = Array.from(
xsltParse.document.getElementsByTagNameNS(
'http://www.w3.org/1999/XSL/Transform',
'template'
)
)
.map((template) => template.getAttribute('match'))
.filter(Boolean);
const resultRoot = outputParse.document.documentElement;
if (!resultRoot) return [];
return walkElementPaths(resultRoot)
.slice(0, 20)
.map((path) => {
const localName = path
.split('/')
.pop()
?.replace(/\[\d+\]$/, '');
const likelyTemplate = templates.find((template) => {
if (!template || !localName) return false;
return template === localName || template.endsWith(`/${localName}`);
});
return {
resultPath: path,
likelySourcePath: localName
? `//*[local-name()='${localName}']`
: undefined,
likelyTemplate: likelyTemplate
? `match="${likelyTemplate}"`
: undefined,
confidence: likelyTemplate ? 'medium' : 'low',
};
});
}
function walkElementPaths(root: Element): string[] {
const paths: string[] = [];
const visit = (element: Element, path: string) => {
paths.push(path);
Array.from(element.children).forEach((child, index) => {
visit(child, `${path}/${child.localName}[${index + 1}]`);
});
};
visit(root, `/${root.localName}[1]`);
return paths;
}

View File

@@ -0,0 +1,27 @@
import { nativeXsltEngine } from './nativeXsltEngine';
import { saxonJsDynamicEngine } from './saxonJsDynamicEngine';
import type {
TransformEngine,
TransformEngineId,
TransformRequest,
TransformResult,
} from './transformTypes';
const engines: Record<TransformEngineId, TransformEngine> = {
'saxon-js-dynamic': saxonJsDynamicEngine,
'native-xsltprocessor': nativeXsltEngine,
};
export function getTransformEngine(id: TransformEngineId): TransformEngine {
return engines[id] ?? saxonJsDynamicEngine;
}
export async function runTransformation(
request: TransformRequest
): Promise<TransformResult> {
const engine = getTransformEngine(request.engine);
return engine.transform(request);
}
export const availableTransformEngines = Object.values(engines);

View File

@@ -0,0 +1,32 @@
import type { DiagnosticMessage } from '../validation/validationTypes';
export type TransformEngineId = 'native-xsltprocessor' | 'saxon-js-dynamic';
export interface TransformRequest {
xmlText: string;
xsltText: string;
engine: TransformEngineId;
}
export interface TransformResult {
output: string;
diagnostics: DiagnosticMessage[];
engine: TransformEngineId;
transformedAt: string;
}
export interface TransformationRun {
engine: TransformEngineId;
transformedAt: string;
xmlInputHash: string;
xsltHash: string;
outputHash: string;
outputLength: number;
}
export interface TransformEngine {
id: TransformEngineId;
label: string;
supportsXsltVersions: string[];
transform(request: TransformRequest): Promise<TransformResult>;
}

View File

@@ -0,0 +1,18 @@
export type DiagnosticSeverity = 'info' | 'warning' | 'error';
export interface DiagnosticMessage {
severity: DiagnosticSeverity;
message: string;
source: string;
from?: number;
to?: number;
}
export interface ParsedXmlDocument {
document: Document;
diagnostics: DiagnosticMessage[];
}
export function hasErrors(diagnostics: DiagnosticMessage[]): boolean {
return diagnostics.some((diagnostic) => diagnostic.severity === 'error');
}

View File

@@ -0,0 +1,93 @@
import type { DiagnosticMessage, ParsedXmlDocument } from './validationTypes';
function extractParserError(document: Document): string | null {
const parserError = document.querySelector('parsererror');
if (!parserError) return null;
return parserError.textContent?.trim().replace(/\s+/g, ' ') || 'Invalid XML.';
}
export function parseXmlDocument(
text: string,
source = 'XML'
): ParsedXmlDocument {
const diagnostics: DiagnosticMessage[] = [];
if (!text.trim()) {
diagnostics.push({
severity: 'error',
source,
message: `${source} is empty.`,
from: 0,
to: 0,
});
const emptyDocument = document.implementation.createDocument(null, null);
return { document: emptyDocument, diagnostics };
}
const parser = new DOMParser();
const parsed = parser.parseFromString(text, 'application/xml');
const parserError = extractParserError(parsed);
if (parserError) {
diagnostics.push({
severity: 'error',
source,
message: parserError,
from: 0,
to: Math.min(text.length, 1),
});
}
return { document: parsed, diagnostics };
}
export function validateXml(text: string, source = 'XML'): DiagnosticMessage[] {
return parseXmlDocument(text, source).diagnostics;
}
export function formatXml(text: string): string {
const { document: parsed, diagnostics } = parseXmlDocument(text);
if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
throw new Error(diagnostics[0]?.message ?? 'Cannot format invalid XML.');
}
const serialized = new XMLSerializer().serializeToString(parsed);
return prettyPrintXml(serialized);
}
export function prettyPrintXml(xml: string): string {
const compact = xml.replace(/>\s*</g, '><').trim();
const withBreaks = compact.replace(/></g, '>\n<');
const lines = withBreaks.split('\n');
let indent = 0;
return `${lines
.map((line) => {
const trimmed = line.trim();
const isClosing = /^<\//.test(trimmed);
const isDeclaration = /^<\?/.test(trimmed);
const isComment = /^<!--/.test(trimmed);
const isSelfClosing = /\/\s*>$/.test(trimmed);
const opensAndClosesSameLine = /^<[^!?/][\s\S]*>.*<\//.test(trimmed);
if (isClosing) indent = Math.max(indent - 1, 0);
const result = `${' '.repeat(indent)}${trimmed}`;
if (
!isClosing &&
!isDeclaration &&
!isComment &&
!isSelfClosing &&
!opensAndClosesSameLine &&
/^<[^!?/]/.test(trimmed)
) {
indent += 1;
}
return result;
})
.join('\n')}\n`;
}

View File

@@ -0,0 +1,63 @@
import type { TransformEngineId } from '../transform/transformTypes';
import { parseXmlDocument } from './xmlValidation';
import type { DiagnosticMessage } from './validationTypes';
const XSLT_NAMESPACE = 'http://www.w3.org/1999/XSL/Transform';
export function validateXslt(
text: string,
engine: TransformEngineId = 'native-xsltprocessor'
): DiagnosticMessage[] {
const { document: stylesheet, diagnostics } = parseXmlDocument(
text,
'XSLT stylesheet'
);
if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
return diagnostics;
}
const root = stylesheet.documentElement;
if (!root) {
return [
...diagnostics,
{
severity: 'error',
source: 'XSLT stylesheet',
message: 'The stylesheet has no document element.',
},
];
}
if (
root.namespaceURI !== XSLT_NAMESPACE ||
!['stylesheet', 'transform'].includes(root.localName)
) {
diagnostics.push({
severity: 'error',
source: 'XSLT stylesheet',
message:
'The root element should be xsl:stylesheet or xsl:transform in the XSLT namespace.',
});
}
const version = root.getAttribute('version');
if (!version) {
diagnostics.push({
severity: 'warning',
source: 'XSLT stylesheet',
message: 'The stylesheet root should declare a version attribute.',
});
}
if (engine === 'native-xsltprocessor' && version && version !== '1.0') {
diagnostics.push({
severity: 'warning',
source: 'XSLT stylesheet',
message:
'The native browser engine usually supports XSLT 1.0 only. XSLT 2.0/3.0 features may fail.',
});
}
return diagnostics;
}

1
src/version.ts Normal file
View File

@@ -0,0 +1 @@
export const APP_VERSION = '0.3.1';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,76 @@
import type { WorkbenchState } from './workspaceTypes';
export const DEFAULT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b1">
<title>Designing XML Workflows</title>
<author>Ada Example</author>
<year>2026</year>
</book>
<book id="b2">
<title>Practical XSLT</title>
<author>Lin Example</author>
<year>2025</year>
</book>
</library>
`;
export const DEFAULT_XSLT = `<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/library">
<books>
<xsl:apply-templates select="book"/>
</books>
</xsl:template>
<xsl:template match="book">
<book>
<xsl:attribute name="id">
<xsl:value-of select="@id"/>
</xsl:attribute>
<label>
<xsl:value-of select="upper-case(title)"/>
<xsl:text> — </xsl:text>
<xsl:value-of select="author"/>
</label>
</book>
</xsl:template>
</xsl:stylesheet>
`;
export function createDefaultWorkbenchState(): WorkbenchState {
return {
schemaVersion: 1,
options: {
prettifyOutputAfterTransform: true,
askBeforeOverwritingOutput: true,
},
selectedEngine: 'saxon-js-dynamic',
xmlInput: {
kind: 'xmlInput',
label: 'XML input',
text: DEFAULT_XML,
fileName: 'input.xml',
dirty: false,
},
xsltCode: {
kind: 'xsltCode',
label: 'XSL transformation code',
text: DEFAULT_XSLT,
fileName: 'transform.xsl',
dirty: false,
},
xmlOutput: {
kind: 'xmlOutput',
label: 'XML output',
text: '',
fileName: 'output.xml',
dirty: false,
},
};
}

View File

@@ -0,0 +1,52 @@
import { createDefaultWorkbenchState } from './defaultWorkspace';
import type { WorkbenchState } from './workspaceTypes';
const STORAGE_KEY = 'xslt-tools:workspace:v1';
function isWorkbenchState(value: unknown): value is WorkbenchState {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<WorkbenchState>;
return (
candidate.schemaVersion === 1 &&
Boolean(candidate.xmlInput) &&
Boolean(candidate.xsltCode) &&
Boolean(candidate.xmlOutput)
);
}
export function loadWorkbenchState(): WorkbenchState {
const fallback = createDefaultWorkbenchState();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as unknown;
if (!isWorkbenchState(parsed)) return fallback;
return {
...fallback,
...parsed,
options: { ...fallback.options, ...parsed.options },
xmlInput: { ...fallback.xmlInput, ...parsed.xmlInput },
xsltCode: { ...fallback.xsltCode, ...parsed.xsltCode },
xmlOutput: { ...fallback.xmlOutput, ...parsed.xmlOutput },
};
} catch (error) {
console.warn('Failed to load workspace from LocalStorage.', error);
return fallback;
}
}
export function saveWorkbenchState(state: WorkbenchState): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('Failed to save workspace to LocalStorage.', error);
}
}
export function clearWorkbenchState(): void {
window.localStorage.removeItem(STORAGE_KEY);
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from 'react';
import { createDefaultWorkbenchState } from './defaultWorkspace';
import {
clearWorkbenchState,
loadWorkbenchState,
saveWorkbenchState,
} from './localStorageStore';
import type { WorkbenchDocumentKind, WorkbenchState } from './workspaceTypes';
import type { TransformationRun } from '../transform/transformTypes';
export function useWorkbenchState() {
const [state, setState] = useState<WorkbenchState>(() =>
loadWorkbenchState()
);
useEffect(() => {
const timeout = window.setTimeout(() => {
saveWorkbenchState(state);
}, 250);
return () => window.clearTimeout(timeout);
}, [state]);
const documents = useMemo(
() => [state.xmlInput, state.xsltCode, state.xmlOutput],
[state.xmlInput, state.xsltCode, state.xmlOutput]
);
const updateDocumentText = (kind: WorkbenchDocumentKind, text: string) => {
setState((current) => ({
...current,
[kind]: {
...current[kind],
text,
dirty: true,
},
}));
};
const replaceDocument = (
kind: WorkbenchDocumentKind,
text: string,
fileName?: string,
dirty = true
) => {
setState((current) => ({
...current,
[kind]: {
...current[kind],
text,
fileName: fileName ?? current[kind].fileName,
dirty,
},
}));
};
const markDocumentSaved = (
kind: WorkbenchDocumentKind,
fileName?: string
) => {
setState((current) => ({
...current,
[kind]: {
...current[kind],
fileName: fileName ?? current[kind].fileName,
dirty: false,
lastSavedAt: new Date().toISOString(),
},
}));
};
const setLastTransformation = (lastTransformation?: TransformationRun) => {
setState((current) => ({ ...current, lastTransformation }));
};
const resetWorkspace = () => {
clearWorkbenchState();
setState(createDefaultWorkbenchState());
};
return {
state,
documents,
setState,
updateDocumentText,
replaceDocument,
markDocumentSaved,
setLastTransformation,
resetWorkspace,
};
}

View File

@@ -0,0 +1,20 @@
import type { WorkbenchDocumentKind } from './workspaceTypes';
export interface WorkspaceCommandRecord {
id: string;
label: string;
target: WorkbenchDocumentKind;
createdAt: string;
}
export function createWorkspaceCommandRecord(
label: string,
target: WorkbenchDocumentKind
): WorkspaceCommandRecord {
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
label,
target,
createdAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,35 @@
import type { DiagnosticMessage } from '../validation/validationTypes';
import type {
TransformEngineId,
TransformationRun,
} from '../transform/transformTypes';
export type WorkbenchDocumentKind = 'xmlInput' | 'xsltCode' | 'xmlOutput';
export interface WorkbenchDocument {
kind: WorkbenchDocumentKind;
label: string;
text: string;
fileName?: string;
dirty: boolean;
lastSavedAt?: string;
}
export interface WorkbenchOptions {
prettifyOutputAfterTransform: boolean;
askBeforeOverwritingOutput: boolean;
}
export interface WorkbenchState {
schemaVersion: 1;
options: WorkbenchOptions;
xmlInput: WorkbenchDocument;
xsltCode: WorkbenchDocument;
xmlOutput: WorkbenchDocument;
selectedEngine: TransformEngineId;
lastTransformation?: TransformationRun;
}
export interface WorkbenchDocumentWithDiagnostics extends WorkbenchDocument {
diagnostics: DiagnosticMessage[];
}