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
|
||||
.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