v0.3.1 with Saxon fully working
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -136,3 +136,5 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
chatgpt_continuation_chat.md
|
||||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
public/vendor/**
|
||||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
57
CHANGELOG.md
Normal file
57
CHANGELOG.md
Normal 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
104
README.md
@@ -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
31
eslint.config.mjs
Normal 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
16
index.html
Normal 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
4033
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal 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
55
public/vendor/saxon/LICENSE.txt
vendored
Normal 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
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
622
src/App.tsx
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Toolbar from './components/Toolbar';
|
||||||
|
import EditorPanel from './components/EditorPanel';
|
||||||
|
import ActionDialog, {
|
||||||
|
type ActionDialogAction,
|
||||||
|
} from './components/ActionDialog';
|
||||||
|
import HelpDialog from './components/HelpDialog';
|
||||||
|
import SnippetToolbox from './components/SnippetToolbox';
|
||||||
|
import { useWorkbenchState } from './workspace/useWorkbenchState';
|
||||||
|
import type { WorkbenchDocumentKind } from './workspace/workspaceTypes';
|
||||||
|
import type { CodeMirrorEditorHandle } from './editor/editorTypes';
|
||||||
|
import { openTextFile, saveTextFile } from './file/fileService';
|
||||||
|
import { validateXml, formatXml } from './validation/xmlValidation';
|
||||||
|
import { validateXslt } from './validation/xsltValidation';
|
||||||
|
import {
|
||||||
|
hasErrors,
|
||||||
|
type DiagnosticMessage,
|
||||||
|
} from './validation/validationTypes';
|
||||||
|
import { runTransformation } from './transform/transformService';
|
||||||
|
import { createTransformationRun } from './transform/nativeXsltEngine';
|
||||||
|
import type { TransformEngineId } from './transform/transformTypes';
|
||||||
|
import { createApproximateTrace } from './transform/traceAnalyzer';
|
||||||
|
|
||||||
|
interface DialogState {
|
||||||
|
title: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
actions: ActionDialogAction[];
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
updateDocumentText,
|
||||||
|
replaceDocument,
|
||||||
|
markDocumentSaved,
|
||||||
|
setLastTransformation,
|
||||||
|
resetWorkspace,
|
||||||
|
} = useWorkbenchState();
|
||||||
|
|
||||||
|
const xmlInputEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
|
||||||
|
const xsltEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
|
||||||
|
const outputEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
|
||||||
|
|
||||||
|
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
|
const [runtimeDiagnostics, setRuntimeDiagnostics] = useState<
|
||||||
|
DiagnosticMessage[]
|
||||||
|
>([]);
|
||||||
|
const [validationTouched, setValidationTouched] = useState(false);
|
||||||
|
|
||||||
|
const xmlDiagnostics = useMemo(
|
||||||
|
() => validateXml(state.xmlInput.text, 'XML input'),
|
||||||
|
[state.xmlInput.text]
|
||||||
|
);
|
||||||
|
|
||||||
|
const xsltDiagnostics = useMemo(
|
||||||
|
() => validateXslt(state.xsltCode.text, state.selectedEngine),
|
||||||
|
[state.xsltCode.text, state.selectedEngine]
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputDiagnostics = useMemo(() => {
|
||||||
|
if (!state.xmlOutput.text.trim()) return [];
|
||||||
|
return validateXml(state.xmlOutput.text, 'XML output');
|
||||||
|
}, [state.xmlOutput.text]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
dialog?.onCancel?.();
|
||||||
|
setDialog(null);
|
||||||
|
}, [dialog]);
|
||||||
|
|
||||||
|
const askForConfirmation = useCallback(
|
||||||
|
(options: {
|
||||||
|
title: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
proceedLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = (confirmed: boolean) => {
|
||||||
|
setDialog(null);
|
||||||
|
resolve(confirmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
setDialog({
|
||||||
|
title: options.title,
|
||||||
|
content: options.content,
|
||||||
|
onCancel: () => resolve(false),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
variant: 'secondary',
|
||||||
|
autoFocus: true,
|
||||||
|
onClick: () => finish(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: options.proceedLabel ?? 'Proceed',
|
||||||
|
variant: options.danger ? 'danger' : 'primary',
|
||||||
|
onClick: () => finish(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmOverwrite = useCallback(
|
||||||
|
async (kind: WorkbenchDocumentKind, reason: string): Promise<boolean> => {
|
||||||
|
const documentState = state[kind];
|
||||||
|
if (!documentState.text.trim() && !documentState.dirty) return true;
|
||||||
|
|
||||||
|
return askForConfirmation({
|
||||||
|
title: `Overwrite ${documentState.label}?`,
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
{reason} This will replace the current contents of{' '}
|
||||||
|
<strong>{documentState.label}</strong>.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
proceedLabel: 'Overwrite',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[askForConfirmation, state]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenHelp = useCallback(() => {
|
||||||
|
setHelpOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseHelp = useCallback(() => {
|
||||||
|
setHelpOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenFile = async (kind: WorkbenchDocumentKind) => {
|
||||||
|
try {
|
||||||
|
const opened = await openTextFile(kind);
|
||||||
|
if (!opened) return;
|
||||||
|
|
||||||
|
const documentState = state[kind];
|
||||||
|
|
||||||
|
if (documentState.dirty) {
|
||||||
|
const mayProceed = await askForConfirmation({
|
||||||
|
title: `Overwrite ${documentState.label}?`,
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Opening <strong>{opened.name}</strong> will replace the current
|
||||||
|
unsaved contents of <strong>{documentState.label}</strong>.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
proceedLabel: 'Overwrite',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mayProceed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceDocument(kind, opened.text, opened.name, false);
|
||||||
|
setStatusMessage(`Loaded ${opened.name}.`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) return;
|
||||||
|
setStatusMessage(
|
||||||
|
error instanceof Error ? error.message : 'Failed to open file.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveFile = async (kind: WorkbenchDocumentKind) => {
|
||||||
|
try {
|
||||||
|
const documentState = state[kind];
|
||||||
|
const suggestedName = documentState.fileName ?? defaultFileName(kind);
|
||||||
|
const savedName = await saveTextFile({
|
||||||
|
suggestedName,
|
||||||
|
text: documentState.text,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (savedName) {
|
||||||
|
markDocumentSaved(kind, savedName);
|
||||||
|
setStatusMessage(`Saved ${savedName}.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) return;
|
||||||
|
setStatusMessage(
|
||||||
|
error instanceof Error ? error.message : 'Failed to save file.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyTransformation = async () => {
|
||||||
|
setValidationTouched(true);
|
||||||
|
setRuntimeDiagnostics([]);
|
||||||
|
|
||||||
|
const diagnostics = [...xmlDiagnostics, ...xsltDiagnostics];
|
||||||
|
if (hasErrors(diagnostics)) {
|
||||||
|
setRuntimeDiagnostics(diagnostics);
|
||||||
|
setStatusMessage('Please fix validation errors before transforming.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.options.askBeforeOverwritingOutput) {
|
||||||
|
const mayOverwriteOutput = await confirmOverwrite(
|
||||||
|
'xmlOutput',
|
||||||
|
'Applying the transformation writes a new result.'
|
||||||
|
);
|
||||||
|
if (!mayOverwriteOutput) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
xmlText: state.xmlInput.text,
|
||||||
|
xsltText: state.xsltCode.text,
|
||||||
|
engine: state.selectedEngine,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runTransformation(request);
|
||||||
|
setRuntimeDiagnostics(result.diagnostics);
|
||||||
|
|
||||||
|
if (hasErrors(result.diagnostics)) {
|
||||||
|
setStatusMessage(
|
||||||
|
'Transformation failed because the input or stylesheet is invalid.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = state.options.prettifyOutputAfterTransform
|
||||||
|
? tryFormatXml(result.output)
|
||||||
|
: result.output;
|
||||||
|
|
||||||
|
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
|
||||||
|
const run = await createTransformationRun(request, output);
|
||||||
|
|
||||||
|
setLastTransformation(run);
|
||||||
|
setStatusMessage(`Transformation completed with ${result.engine}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setRuntimeDiagnostics([
|
||||||
|
{
|
||||||
|
severity: 'error',
|
||||||
|
source: 'Transformation engine',
|
||||||
|
message:
|
||||||
|
error instanceof Error ? error.message : 'Transformation failed.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setStatusMessage('Transformation failed.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveOutputToInput = async () => {
|
||||||
|
if (!state.xmlOutput.text.trim()) {
|
||||||
|
setStatusMessage('The output editor is empty. Nothing to move.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mayProceed = await confirmOverwrite(
|
||||||
|
'xmlInput',
|
||||||
|
'Moving output to input is useful for chained transformations.'
|
||||||
|
);
|
||||||
|
if (!mayProceed) return;
|
||||||
|
|
||||||
|
replaceDocument('xmlInput', state.xmlOutput.text, 'from-output.xml', true);
|
||||||
|
setStatusMessage('Moved output to XML input.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormat = async (kind: WorkbenchDocumentKind) => {
|
||||||
|
const documentState = state[kind];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatted = formatXml(documentState.text);
|
||||||
|
if (formatted === documentState.text) {
|
||||||
|
setStatusMessage(`${documentState.label} is already formatted.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mayProceed = await askForConfirmation({
|
||||||
|
title: `Format ${documentState.label}?`,
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
Formatting rewrites whitespace in{' '}
|
||||||
|
<strong>{documentState.label}</strong>.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
proceedLabel: 'Format',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mayProceed) return;
|
||||||
|
replaceDocument(kind, formatted, documentState.fileName, true);
|
||||||
|
setStatusMessage(`Formatted ${documentState.label}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage(
|
||||||
|
error instanceof Error ? error.message : 'Could not format XML.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidate = () => {
|
||||||
|
setValidationTouched(true);
|
||||||
|
const allDiagnostics = [
|
||||||
|
...xmlDiagnostics,
|
||||||
|
...xsltDiagnostics,
|
||||||
|
...outputDiagnostics,
|
||||||
|
];
|
||||||
|
setRuntimeDiagnostics(allDiagnostics);
|
||||||
|
|
||||||
|
if (allDiagnostics.length === 0) {
|
||||||
|
setStatusMessage('All current documents are well-formed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage(
|
||||||
|
hasErrors(allDiagnostics)
|
||||||
|
? 'Validation found errors.'
|
||||||
|
: 'Validation completed with warnings.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
const mayProceed = await askForConfirmation({
|
||||||
|
title: 'Reset workspace?',
|
||||||
|
content: (
|
||||||
|
<p>
|
||||||
|
This restores the example XML and XSLT, clears the output, and removes
|
||||||
|
the saved LocalStorage workspace.
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
proceedLabel: 'Reset workspace',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mayProceed) return;
|
||||||
|
resetWorkspace();
|
||||||
|
setRuntimeDiagnostics([]);
|
||||||
|
setValidationTouched(false);
|
||||||
|
setStatusMessage('Workspace reset.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEngineChange = (engine: TransformEngineId) => {
|
||||||
|
setState((current) => ({ ...current, selectedEngine: engine }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrettifyOutputAfterTransformChange = (enabled: boolean) => {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
options: {
|
||||||
|
...current.options,
|
||||||
|
prettifyOutputAfterTransform: enabled,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAskBeforeOverwritingOutputChange = (enabled: boolean) => {
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
options: {
|
||||||
|
...current.options,
|
||||||
|
askBeforeOverwritingOutput: enabled,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const traceItems = useMemo(() => {
|
||||||
|
if (!state.xmlOutput.text.trim()) return [];
|
||||||
|
return createApproximateTrace(
|
||||||
|
state.xmlInput.text,
|
||||||
|
state.xsltCode.text,
|
||||||
|
state.xmlOutput.text
|
||||||
|
);
|
||||||
|
}, [state.xmlInput.text, state.xsltCode.text, state.xmlOutput.text]);
|
||||||
|
|
||||||
|
const displayedRuntimeDiagnostics = validationTouched
|
||||||
|
? runtimeDiagnostics
|
||||||
|
: [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented || event.repeat || event.isComposing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'F1') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleOpenHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApplyShortcut =
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
event.ctrlKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.metaKey;
|
||||||
|
|
||||||
|
if (isApplyShortcut) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (!busy) {
|
||||||
|
void handleApplyTransformation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [busy, handleApplyTransformation, handleOpenHelp]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout onOpenHelp={() => handleOpenHelp()}>
|
||||||
|
<div className="workspace-grid">
|
||||||
|
<EditorPanel
|
||||||
|
ref={xmlInputEditorRef}
|
||||||
|
title="XML input"
|
||||||
|
value={state.xmlInput.text}
|
||||||
|
fileName={state.xmlInput.fileName}
|
||||||
|
dirty={state.xmlInput.dirty}
|
||||||
|
diagnostics={xmlDiagnostics}
|
||||||
|
onChange={(value) => updateDocumentText('xmlInput', value)}
|
||||||
|
onOpen={() => void handleOpenFile('xmlInput')}
|
||||||
|
onSave={() => void handleSaveFile('xmlInput')}
|
||||||
|
onUndo={() => xmlInputEditorRef.current?.undo()}
|
||||||
|
onRedo={() => xmlInputEditorRef.current?.redo()}
|
||||||
|
onCopy={() => void xmlInputEditorRef.current?.copySelection()}
|
||||||
|
onCut={() => void xmlInputEditorRef.current?.cutSelection()}
|
||||||
|
onPaste={() => void xmlInputEditorRef.current?.pasteText()}
|
||||||
|
onPrettify={() => void handleFormat('xmlInput')}
|
||||||
|
placeholderText="Paste or open XML here."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditorPanel
|
||||||
|
ref={xsltEditorRef}
|
||||||
|
title="XSL transformation code"
|
||||||
|
value={state.xsltCode.text}
|
||||||
|
fileName={state.xsltCode.fileName}
|
||||||
|
dirty={state.xsltCode.dirty}
|
||||||
|
diagnostics={xsltDiagnostics}
|
||||||
|
onChange={(value) => updateDocumentText('xsltCode', value)}
|
||||||
|
onOpen={() => void handleOpenFile('xsltCode')}
|
||||||
|
onSave={() => void handleSaveFile('xsltCode')}
|
||||||
|
onUndo={() => xsltEditorRef.current?.undo()}
|
||||||
|
onRedo={() => xsltEditorRef.current?.redo()}
|
||||||
|
onCopy={() => void xsltEditorRef.current?.copySelection()}
|
||||||
|
onCut={() => void xsltEditorRef.current?.cutSelection()}
|
||||||
|
onPaste={() => void xsltEditorRef.current?.pasteText()}
|
||||||
|
onPrettify={() => void handleFormat('xsltCode')}
|
||||||
|
placeholderText="Write or open XSLT here."
|
||||||
|
actions={
|
||||||
|
<SnippetToolbox
|
||||||
|
onInsertSnippet={(snippet) =>
|
||||||
|
xsltEditorRef.current?.insertText(snippet)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditorPanel
|
||||||
|
ref={outputEditorRef}
|
||||||
|
title="XML output"
|
||||||
|
value={state.xmlOutput.text}
|
||||||
|
fileName={state.xmlOutput.fileName}
|
||||||
|
dirty={state.xmlOutput.dirty}
|
||||||
|
readOnly={false}
|
||||||
|
diagnostics={outputDiagnostics}
|
||||||
|
onChange={(value) => updateDocumentText('xmlOutput', value)}
|
||||||
|
onOpen={null}
|
||||||
|
onSave={() => void handleSaveFile('xmlOutput')}
|
||||||
|
onUndo={() => outputEditorRef.current?.undo()}
|
||||||
|
onRedo={() => outputEditorRef.current?.redo()}
|
||||||
|
onCopy={() => void outputEditorRef.current?.copySelection()}
|
||||||
|
onCut={() => void outputEditorRef.current?.cutSelection()}
|
||||||
|
onPaste={() => void outputEditorRef.current?.pasteText()}
|
||||||
|
onPrettify={() => void handleFormat('xmlOutput')}
|
||||||
|
placeholderText="Transformation output appears here."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toolbar
|
||||||
|
selectedEngine={state.selectedEngine}
|
||||||
|
onEngineChange={handleEngineChange}
|
||||||
|
onApplyTransformation={() => void handleApplyTransformation()}
|
||||||
|
onMoveOutputToInput={() => void handleMoveOutputToInput()}
|
||||||
|
onValidate={handleValidate}
|
||||||
|
onReset={() => void handleReset()}
|
||||||
|
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
|
||||||
|
onPrettifyOutputAfterTransformChange={
|
||||||
|
handlePrettifyOutputAfterTransformChange
|
||||||
|
}
|
||||||
|
askBeforeOverwritingOutput={state.options.askBeforeOverwritingOutput}
|
||||||
|
onAskBeforeOverwritingOutputChange={
|
||||||
|
handleAskBeforeOverwritingOutputChange
|
||||||
|
}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{statusMessage && <div className="status-banner">{statusMessage}</div>}
|
||||||
|
|
||||||
|
{displayedRuntimeDiagnostics.length > 0 && (
|
||||||
|
<section className="card runtime-diagnostics">
|
||||||
|
<h2>Validation / runtime messages</h2>
|
||||||
|
<ul className="diagnostics-list">
|
||||||
|
{displayedRuntimeDiagnostics.map((diagnostic, index) => (
|
||||||
|
<li
|
||||||
|
key={`${diagnostic.source}-${diagnostic.message}-${index}`}
|
||||||
|
className={`diagnostic diagnostic-${diagnostic.severity}`}
|
||||||
|
>
|
||||||
|
<strong>{diagnostic.source}</strong>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<section className="trace-panel card">
|
||||||
|
<div className="trace-header">
|
||||||
|
<div>
|
||||||
|
<h2>Explain transformation</h2>
|
||||||
|
<p>
|
||||||
|
MVP approximation: result nodes are compared with simple
|
||||||
|
stylesheet template matches. Full execution tracing requires an
|
||||||
|
instrumented engine later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{state.lastTransformation && (
|
||||||
|
<small>
|
||||||
|
Last run:{' '}
|
||||||
|
{new Date(
|
||||||
|
state.lastTransformation.transformedAt
|
||||||
|
).toLocaleString()}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{traceItems.length === 0 ? (
|
||||||
|
<p className="diagnostics-empty">
|
||||||
|
Run a transformation to see approximate result explanations.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="trace-table-wrap">
|
||||||
|
<table className="trace-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Result</th>
|
||||||
|
<th>Likely source</th>
|
||||||
|
<th>Likely template</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{traceItems.map((item) => (
|
||||||
|
<tr key={item.resultPath}>
|
||||||
|
<td>
|
||||||
|
<code>{item.resultPath}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{item.likelySourcePath ?? '—'}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{item.likelyTemplate ?? '—'}</code>
|
||||||
|
</td>
|
||||||
|
<td>{item.confidence}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ActionDialog
|
||||||
|
open={Boolean(dialog)}
|
||||||
|
title={dialog?.title ?? ''}
|
||||||
|
actions={dialog?.actions ?? []}
|
||||||
|
onClose={closeDialog}
|
||||||
|
>
|
||||||
|
{dialog?.content}
|
||||||
|
</ActionDialog>
|
||||||
|
|
||||||
|
{helpOpen && (
|
||||||
|
<HelpDialog
|
||||||
|
open={helpOpen}
|
||||||
|
onClose={handleCloseHelp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function tryFormatXml(text: string): string {
|
||||||
|
try {
|
||||||
|
return text.trim() ? formatXml(text) : text;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultFileName(kind: WorkbenchDocumentKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'xmlInput':
|
||||||
|
return 'input.xml';
|
||||||
|
case 'xsltCode':
|
||||||
|
return 'transform.xsl';
|
||||||
|
case 'xmlOutput':
|
||||||
|
return 'output.xml';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException && error.name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
88
src/components/ActionDialog.tsx
Normal file
88
src/components/ActionDialog.tsx
Normal 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;
|
||||||
28
src/components/DiagnosticsPanel.tsx
Normal file
28
src/components/DiagnosticsPanel.tsx
Normal 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;
|
||||||
189
src/components/EditorPanel.tsx
Normal file
189
src/components/EditorPanel.tsx
Normal 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;
|
||||||
72
src/components/HelpDialog.tsx
Normal file
72
src/components/HelpDialog.tsx
Normal 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
50
src/components/Layout.tsx
Normal 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;
|
||||||
32
src/components/SnippetToolbox.tsx
Normal file
32
src/components/SnippetToolbox.tsx
Normal 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
115
src/components/Toolbar.tsx
Normal 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;
|
||||||
205
src/editor/CodeMirrorEditor.tsx
Normal file
205
src/editor/CodeMirrorEditor.tsx
Normal 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
14
src/editor/diagnostics.ts
Normal 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)`;
|
||||||
|
}
|
||||||
9
src/editor/editorTypes.ts
Normal file
9
src/editor/editorTypes.ts
Normal 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>;
|
||||||
|
}
|
||||||
96
src/editor/xsltSnippets.ts
Normal file
96
src/editor/xsltSnippets.ts
Normal 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
135
src/file/fileService.ts
Normal 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
13
src/file/fileTypes.ts
Normal 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
11
src/main.tsx
Normal 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
670
src/styles.css
Normal 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
20
src/transform/hash.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
69
src/transform/nativeXsltEngine.ts
Normal file
69
src/transform/nativeXsltEngine.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
309
src/transform/saxonJsDynamicCompiler.ts
Normal file
309
src/transform/saxonJsDynamicCompiler.ts
Normal 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);
|
||||||
|
}
|
||||||
44
src/transform/saxonJsDynamicEngine.ts
Normal file
44
src/transform/saxonJsDynamicEngine.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
37
src/transform/saxonJsRuntimeTypes.ts
Normal file
37
src/transform/saxonJsRuntimeTypes.ts
Normal 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 {};
|
||||||
11
src/transform/serialization.ts
Normal file
11
src/transform/serialization.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/transform/traceAnalyzer.ts
Normal file
77
src/transform/traceAnalyzer.ts
Normal 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;
|
||||||
|
}
|
||||||
27
src/transform/transformService.ts
Normal file
27
src/transform/transformService.ts
Normal 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);
|
||||||
32
src/transform/transformTypes.ts
Normal file
32
src/transform/transformTypes.ts
Normal 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>;
|
||||||
|
}
|
||||||
18
src/validation/validationTypes.ts
Normal file
18
src/validation/validationTypes.ts
Normal 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');
|
||||||
|
}
|
||||||
93
src/validation/xmlValidation.ts
Normal file
93
src/validation/xmlValidation.ts
Normal 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`;
|
||||||
|
}
|
||||||
63
src/validation/xsltValidation.ts
Normal file
63
src/validation/xsltValidation.ts
Normal 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
1
src/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '0.3.1';
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
76
src/workspace/defaultWorkspace.ts
Normal file
76
src/workspace/defaultWorkspace.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/workspace/localStorageStore.ts
Normal file
52
src/workspace/localStorageStore.ts
Normal 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);
|
||||||
|
}
|
||||||
91
src/workspace/useWorkbenchState.ts
Normal file
91
src/workspace/useWorkbenchState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/workspace/workspaceCommands.ts
Normal file
20
src/workspace/workspaceCommands.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/workspace/workspaceTypes.ts
Normal file
35
src/workspace/workspaceTypes.ts
Normal 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[];
|
||||||
|
}
|
||||||
122
tests/transformEngines.test.ts
Normal file
122
tests/transformEngines.test.ts
Normal 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
52
todo.md
Normal 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
22
tsconfig.json
Normal 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
6
vite.config.ts
Normal 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
BIN
xslt-tools.tar.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user