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

2
.gitignore vendored
View File

@@ -136,3 +136,5 @@ dist
.yarn/install-state.gz
.pnp.*
.DS_Store
chatgpt_continuation_chat.md

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist
node_modules
public/vendor/**

4
.prettierrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "es5"
}

57
CHANGELOG.md Normal file
View File

@@ -0,0 +1,57 @@
# Changelog
## 0.3.1
### Added
* Added global keyboard shortcut `Shift + Enter` to apply the current transformation.
* Added global keyboard shortcut `F1` to open the help dialog.
* Added shortcut hints for transformation and help actions.
### Changed
* Bumped application version to `0.3.1`.
## 0.3.0
### Added
* Added browser-side SaxonJS dynamic compilation support.
* Added support for raw XSLT 1.0, 2.0, and 3.0 transformations through the SaxonJS dynamic engine.
* Added dynamic XSLT-to-SEF compilation in the browser using the full `SaxonJS2.js` browser artifact.
* Added lazy loading for the SaxonJS browser compiler/runtime.
* Added tests covering XSLT 1.0, XSLT 2.0, and XSLT 3.0 transformation scenarios.
* Added project quality commands for type checking, linting, formatting, testing, building, and combined checks.
### Changed
* Made `saxon-js-dynamic` the primary SaxonJS-based engine for raw XML + raw XSLT workflows.
* Removed the earlier failed npm-bundled SaxonJS approach.
* Removed the SEF-only SaxonJS placeholder from the main workflow.
* Excluded vendored SaxonJS files from linting and formatting.
* Cleaned up debug-only code and unused declarations.
* Improved transformation engine registry and test coverage.
### Fixed
* Fixed SaxonJS browser integration by using the full `SaxonJS2.js` artifact instead of `SaxonJS2.rt.js`.
* Fixed dynamic compiler setup by providing a synthetic stylesheet base URI.
* Fixed SaxonJS static parameter handling by passing an `XdmMap` instead of a plain JavaScript object.
* Fixed browser compiler result extraction by reading `checkedOptions.principalResult`.
* Removed duplicate XML output replacement after transformations.
## 0.2.0
- Added SaxonJS 2.7 as the default transformation engine.
- Added lazy loading for the SaxonJS runtime so it is split into a separate build chunk.
- Kept the native browser `XSLTProcessor` engine as selectable fallback.
- Switched the default example stylesheet to XSLT 3.0 and added an `upper-case()` example.
- Added a SaxonJS transformation test.
- Updated README with SaxonJS runtime and licensing notes.
## 0.1.0
- Initial browser-only XSLT tools MVP.
- Added three-panel XML/XSLT/output workbench.
- Added native browser XSLTProcessor engine.
- Added LocalStorage persistence, file open/save, overwrite confirmations, snippets, validation, and approximate explain table.

104
README.md
View File

@@ -1,2 +1,104 @@
# xslt-tools
# XSLT tools
Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded; the app uses browser APIs, LocalStorage, and local file open/save dialogs where available.
## Features in this MVP
- Three-column workbench:
- XML input
- XSL transformation code
- XML output
- CodeMirror editors with:
- line numbers
- XML syntax highlighting
- editor undo/redo
- cut/copy/paste helpers
- find support via CodeMirror shortcuts
- Transformation engines:
- SaxonJS 2.7 as the default engine for XSLT 1.0/2.0/3.0-style workflows
- Browser-native `XSLTProcessor` as a lightweight XSLT 1.0 fallback
- XML and XSLT well-formedness checks
- Basic XSLT root/version checks
- Snippet toolbox for common XSLT constructs
- Open/save local files
- File System Access API when supported
- file input/download fallback otherwise
- Output → Input action for chained transformations
- Confirmation dialog before destructive overwrites
- LocalStorage persistence
- Approximate “explain transformation” table after a run
## SaxonJS note
SaxonJS is bundled through the `saxon-js` npm package and loaded lazily when the SaxonJS engine runs. The browser build may show Vite warnings about Node modules such as `fs`, `path`, or `stream` being externalized. Those warnings come from the package containing Node.js support as well; the app does not use file-based SaxonJS APIs in the browser.
SaxonJS itself is free to use but not open source. Check `node_modules/saxon-js/LICENSE.txt` after installation before distributing a packaged version.
## Run locally
```bash
npm install
npm run dev
```
Then open the local Vite URL shown in your terminal.
## Build
```bash
npm run build
npm run preview
```
## Suggested next steps
1. Add parameter handling for `xsl:param`.
2. Add a real diagnostics/lint bridge into CodeMirror gutter markers.
3. Add transformation test cases with expected output assertions.
4. Add project import/export as a single `.xslt-tools.json` file.
5. Improve explain mode through stylesheet instrumentation.
6. Add optional precompiled SEF import/export support for larger SaxonJS projects.
## Structure
```text
src/
├── components/ # Layout, toolbar, editor panels, dialogs
├── editor/ # CodeMirror integration and XSLT snippets
├── file/ # Local file open/save helpers
├── transform/ # Transform engine abstraction, SaxonJS, native engine
├── validation/ # XML/XSLT validation helpers
└── workspace/ # LocalStorage-backed workbench state
```
## Development checks
Use Node.js 22 or newer. The main local quality gate is:
```bash
npm run check
```
Useful individual commands:
```bash
npm run typecheck # TypeScript only
npm run lint # ESLint
npm run lint:fix # ESLint autofix where possible
npm run format:check # Prettier check
npm run format # Prettier write
npm run test # Vitest/jsdom test suite
npm run build # Production build
npm run clean # Remove generated build/cache files
```
The vendored SaxonJS browser file is intentionally excluded from ESLint and Prettier because it is a third-party distribution artifact.
## Transformation engines
The app currently exposes two engines:
- **SaxonJS 2 dynamic XSLT 3.0**: default engine. It lazy-loads `public/vendor/saxon/SaxonJS2.js`, compiles raw XSLT text to SEF in the browser, then executes the generated SEF locally.
- **Native browser XSLTProcessor**: XSLT 1.0 fallback using the browser implementation.
The SaxonJS runtime is loaded only when a Saxon transformation is requested, so the initial app shell does not block on the large compiler/runtime file.

31
eslint.config.mjs Normal file
View File

@@ -0,0 +1,31 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist', 'public/vendor/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
prettier
);

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Browser-only XSLT tools for local XML and XSLT transformations."
/>
<title>XSLT tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4033
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "xsl-tools",
"version": "0.3.1",
"private": true,
"type": "module",
"description": "Browser-only XSLT workbench for local XML/XSLT transformations.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run --environment jsdom",
"test:watch": "vitest --environment jsdom",
"check": "npm run typecheck && npm run lint && npm run test && npm run build",
"lint:fix": "eslint . --fix",
"clean": "rm -rf dist node_modules/.vite coverage"
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.3",
"bootstrap-icons": "^1.11.3",
"codemirror": "^6.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13",
"vitest": "^4.1.6"
}
}

55
public/vendor/saxon/LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,55 @@
Version 1.0, June 2020
Software: This license applies to the packages "xslt3" and "saxon-js"
distributed via npm (https://www.npmjs.com) and to the modules SaxonJS2.js
and SaxonJS2.rt.js available for download from the Saxonica web site
(https://www.saxonica.com/).
Copyright: The copyright in the Software belongs to Saxonica Ltd, except
for third-party components listed in the documentation that are distributed
under license.
Binary form: In this license, "binary form" means the form in which the
Software is issued (this is technically a set of obfuscated Javascript files).
Deployment and use. The Software may be copied to any computer where the
primary purpose is the execution of the software on that computer, or on
connected client computers.
Redistribution. Redistribution in binary form, without
modification, is permitted as part of an application that makes use
of the Software, provided that the following conditions are
met:
1) Redistributions must reproduce the above copyright notice and the
following disclaimer in the documentation and/or other materials
provided with the distribution.
2) Except to the extent explicitly permitted by law, no reverse
engineering, decompilation, or disassembly of this software is
permitted.
3) Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
4) Copying the software to a site whose primary purpose is to make
it available to third parties is not permitted without specific
prior written permission.
If you institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the software
itself infringes your patent(s), then your rights granted under this
license shall terminate as of the date such litigation is filed.
DISCLAIMER. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS." ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

4698
public/vendor/saxon/SaxonJS2.js vendored Normal file

File diff suppressed because it is too large Load Diff

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[];
}

View File

@@ -0,0 +1,122 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import {
availableTransformEngines,
runTransformation,
} from '../src/transform/transformService';
import { compileXsltTextToSefJson } from '../src/transform/saxonJsDynamicCompiler';
const xmlText = `<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b1"><title>Practical XSLT</title><author>Ada</author></book>
<book id="b2"><title>Browser XML</title><author>Lin</author></book>
</library>`;
const xslt10Text = `<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/library">
<titles><xsl:for-each select="book"><title><xsl:value-of select="title"/></title></xsl:for-each></titles>
</xsl:template>
</xsl:stylesheet>`;
const xslt20Text = `<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/library">
<books><xsl:value-of select="upper-case(string-join(book/title, '|'))"/></books>
</xsl:template>
</xsl:stylesheet>`;
const xslt30Text = `<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:mode on-no-match="shallow-copy"/>
<xsl:template match="book/title/text()">
<xsl:value-of select="upper-case(.)"/>
</xsl:template>
</xsl:stylesheet>`;
beforeAll(() => {
if (window.SaxonJS) return;
const scriptPath = resolve(process.cwd(), 'public/vendor/saxon/SaxonJS2.js');
const script = readFileSync(scriptPath, 'utf8');
vm.runInContext(script, vm.createContext(window));
});
describe('transform engine registry', () => {
it('keeps the native browser XSLTProcessor engine selectable', () => {
expect(availableTransformEngines.map((engine) => engine.id)).toContain(
'native-xsltprocessor'
);
});
it('exposes the dynamic SaxonJS engine for XSLT 2.0/3.0', () => {
expect(availableTransformEngines.map((engine) => engine.id)).toContain(
'saxon-js-dynamic'
);
});
});
describe('SaxonJS dynamic compiler', () => {
it('compiles raw XSLT text to SEF JSON', async () => {
const sef = await compileXsltTextToSefJson(xslt30Text);
const parsed = JSON.parse(sef) as { N?: string; target?: string };
expect(parsed.N).toBe('package');
expect(parsed.target).toBe('JS');
});
it('runs an XSLT 1.0 stylesheet', async () => {
const result = await runTransformation({
xmlText,
xsltText: xslt10Text,
engine: 'saxon-js-dynamic',
});
expect(
result.diagnostics.filter((item) => item.severity === 'error')
).toHaveLength(0);
expect(result.output).toContain('<title>Practical XSLT</title>');
expect(result.output).toContain('<title>Browser XML</title>');
});
it('runs an XSLT 2.0 stylesheet with XPath 2.0 functions', async () => {
const result = await runTransformation({
xmlText,
xsltText: xslt20Text,
engine: 'saxon-js-dynamic',
});
expect(
result.diagnostics.filter((item) => item.severity === 'error')
).toHaveLength(0);
expect(result.output).toContain('PRACTICAL XSLT|BROWSER XML');
});
it('runs an XSLT 3.0 stylesheet with xsl:mode on-no-match', async () => {
const result = await runTransformation({
xmlText,
xsltText: xslt30Text,
engine: 'saxon-js-dynamic',
});
expect(
result.diagnostics.filter((item) => item.severity === 'error')
).toHaveLength(0);
expect(result.output).toContain('<title>PRACTICAL XSLT</title>');
expect(result.output).toContain('<title>BROWSER XML</title>');
});
it('returns diagnostics for invalid XSLT', async () => {
const result = await runTransformation({
xmlText,
xsltText: '<xsl:stylesheet version="3.0">',
engine: 'saxon-js-dynamic',
});
expect(result.diagnostics.some((item) => item.severity === 'error')).toBe(
true
);
});
});

52
todo.md Normal file
View File

@@ -0,0 +1,52 @@
# Roadmap: good next functions
Near-term useful features:
1. Engine UX polish
- Show “SaxonJS loading…” when the large compiler file is first loaded.
- Show compile time vs transform time separately.
- Cache compiled SEF by stylesheet hash to avoid recompiling unchanged XSLT.
2. Parameters
- Add UI for xsl:param.
- Support string/number/boolean parameters first.
- Later support XPath expressions.
3. Better diagnostics
- Convert SaxonJS compiler/runtime errors into editor diagnostics.
- Extract line/module info when available.
- Jump from diagnostic to editor line.
4. Examples / templates
- Identity transform.
- Rename elements.
- Extract table.
- Namespace cleanup.
- Grouping example.
- XSLT 3.0 shallow-copy example.
5. Project files
- Export/import a full local project JSON:
'''XML input
XSLT
output
engine
examples/tests
parameters'''
- No uploads needed.
6. Test cases inside the app
- Multiple XML inputs per stylesheet.
- Expected output assertions.
- Diff expected vs actual.
- “Run all tests” button.
7. Diff views
- XML input vs output.
- Previous output vs current output.
- Pretty XML diff.
8. Explain mode
- Start with static analysis:
'''template match → output node candidates'''
- Later add instrumentation around generated SEF or stylesheet templates.
9. Performance
- Web Worker for SaxonJS compile/transform.
- Prevent UI lockups on large XML.
- Progress indicator and cancellation if possible.
10. Storage
- LocalStorage for small projects.
- IndexedDB for larger documents and histories.

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["node", "vite/client"]
},
"include": ["src", "tests"],
"references": []
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});

BIN
xslt-tools.tar.gz Normal file

Binary file not shown.