feat: integrate XSLT Tools with toolbox portal

This commit is contained in:
2026-07-20 18:16:18 +02:00
parent 3193cc395c
commit 8f8a59e76c
36 changed files with 1737 additions and 243 deletions

1
.gitignore vendored
View File

@@ -91,6 +91,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
release/
# Gatsby files
.cache/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/

View File

@@ -1,44 +1,66 @@
# Changelog
## 0.3.2
### Added
- Added a typed Toolbox Contract v1 application definition and generated `toolbox-app.json` manifest.
- Added the shared React application shell while retaining the XSLT-specific Help action.
- Added standalone and toolbox-mode integration coverage, including nested-path static deployment checks.
- Added deterministic, cross-time-zone release ZIP and SHA-256 generation with complete production dependency licence files.
- Added a session-only SHA-256/revision trust gate that disables both transformation engines until the exact stylesheet is reviewed and confirmed.
- Added conservative transformation input/output limits and a best-effort asynchronous deadline.
- Added a prominent Source action and corresponding-source notice for the 0.3.2 tag.
### Changed
- Made Vite build assets relocatable with a relative deployment base.
- Resolved the lazily loaded SaxonJS runtime relative to the deployed application path.
- Documented that the vendored SaxonJS 2.7 browser distribution is free to use under separate, non-open-source terms and is not part of the application's AGPL licensing.
- Classified stylesheet processing conservatively and documented that executable stylesheets may access browser or network resources.
- Documented that trusted SaxonJS can access same-site Toolbox data, send data over the network, or navigate away; the recommended portal CSP is defense in depth rather than containment.
- Switched Toolbox SDK dependencies from local workspace links to the public token-free package registry.
- Release packaging now refuses to overwrite same-version artifacts unless `--force` is explicit.
## 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.
- 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`.
- 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.
- 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.
- 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.
- 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

View File

@@ -1,6 +1,12 @@
# 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.
Browser-based MVP for testing XML/XSLT transformations. The application itself
does not intentionally upload opened files, and it uses LocalStorage plus local
file dialogs where available. XSLT is executable code, however: a trusted
stylesheet may access browser or network resources, so processing is not
described as guaranteed local-only.
The application is independently deployable and also implements Toolbox Contract v1. When launched with a same-origin `?toolbox=<catalog URL>` context, the shared application shell adds toolbox navigation and an application switcher; without that context, the same build runs standalone.
## Features in this MVP
@@ -17,6 +23,9 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded
- 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
- A mandatory SHA-256 trust confirmation for the exact executable stylesheet;
every edit, replacement, format, or reset revokes trust for both engines
- Defense-in-depth input/output limits and a best-effort asynchronous deadline
- XML and XSLT well-formedness checks
- Basic XSLT root/version checks
- Snippet toolbox for common XSLT constructs
@@ -27,12 +36,34 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded
- Confirmation dialog before destructive overwrites
- LocalStorage persistence
- Approximate “explain transformation” table after a run
- Shared toolbox shell with standalone fallback, application switching, and the XSLT-specific Help action
- Relocatable static output for deployment at root or nested paths
## 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 2.7 is vendored as the unmodified browser distribution file at `public/vendor/saxon/SaxonJS2.js` and loaded lazily when the SaxonJS engine runs. It is not bundled from the npm package during the application build.
SaxonJS itself is free to use but not open source. Check `node_modules/saxon-js/LICENSE.txt` after installation before distributing a packaged version.
SaxonJS is available free of charge but is not open source. Its separate redistribution terms are preserved at `public/vendor/saxon/LICENSE.txt`. Release archives include that notice as `LICENSES/SaxonJS-LICENSE.txt` alongside `LICENSES/xslt-tools-LICENSE.txt` for the AGPL-3.0-only application; do not describe the complete archive as AGPL-only.
## Executable stylesheet security
Treat every stylesheet like executable code. Transformation is disabled until
the user reviews a warning and trusts the SHA-256 fingerprint of the exact
current stylesheet. Trust is session-only and tied to both the content hash and
an edit revision; changing and then reverting text still requires another
confirmation. The same gate covers SaxonJS and native `XSLTProcessor`.
The application enforces conservative input/output limits and a best-effort
asynchronous deadline, but transformations are not isolated from the browser or
network and synchronous CPU exhaustion cannot be forcibly stopped. A trusted
SaxonJS stylesheet may read data stored by other Toolbox apps on the same site,
including PDF workspaces and portal preferences, then send it over the network
or navigate the page away.
When served with the recommended portal CSP, cross-origin fetches and dynamic
JavaScript evaluation are restricted without granting `unsafe-eval`. CSP is
defense in depth, not complete containment. Only execute stylesheets from trusted
sources.
## Run locally
@@ -50,6 +81,45 @@ npm run build
npm run preview
```
Vite uses `base: './'`, so a single build can be hosted at an unknown nested path such as `/apps/xslt/`. The SaxonJS runtime is resolved from that application base as well.
## Toolbox manifest
`src/toolboxApp.ts` is the typed source for both the shared shell and the published `public/toolbox-app.json` manifest. Keep the generated file current with:
```bash
npm run manifest:generate
npm run manifest:check
```
Toolbox mode is contextual rather than a separate build:
```text
/apps/xslt/ standalone
/apps/xslt/?toolbox=/toolbox.catalog.json toolbox mode
```
An unavailable or invalid catalogue falls back to standalone operation.
## Release archive
After a successful build, create the deterministic application ZIP and SHA-256 sidecar with:
```bash
npm run release
```
The command refuses to overwrite a same-version archive. Use
`npm run release -- --force` only when intentionally replacing it.
The archive contains the static app and `toolbox-app.json`, plus `CHANGELOG.md`,
`SOURCE.md` with the corresponding tagged source link,
`THIRD_PARTY_NOTICES.md`, the application licence at
`LICENSES/xslt-tools-LICENSE.txt`, the separate vendored SaxonJS licence, and
licence/notice files for every installed production npm dependency below
`LICENSES/npm/`. Generated artifacts are written below `release/` and are not
committed.
## Suggested next steps
1. Add parameter handling for `xsl:param`.
@@ -63,12 +133,13 @@ npm run preview
```text
src/
├── components/ # Layout, toolbar, editor panels, dialogs
├── components/ # Toolbar, editor panels, dialogs, and app-specific actions
├── 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
── workspace/ # LocalStorage-backed workbench state
└── toolboxApp.ts # Toolbox manifest and shared-shell application definition
```
## Development checks
@@ -89,6 +160,9 @@ 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 smoke:static # Nested-path static build smoke test
npm run smoke:toolbox # Contract, asset, standalone, and contextual checks
npm run release # Deterministic ZIP and SHA-256 release artifacts
npm run clean # Remove generated build/cache files
```
@@ -98,7 +172,8 @@ The vendored SaxonJS browser file is intentionally excluded from ESLint and Pret
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.
- **SaxonJS 2 dynamic XSLT 3.0**: default engine. It loads the vendored `public/vendor/saxon/SaxonJS2.js`, compiles raw XSLT text to SEF in the browser, then executes the generated SEF after explicit trust confirmation.
- **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.
The SaxonJS runtime is loaded only for a trusted Saxon transformation, so the
initial app shell does not block on the large compiler/runtime file.

10
SOURCE.md Normal file
View File

@@ -0,0 +1,10 @@
# Corresponding source
The corresponding application source for XSLT tools 0.3.2 is available at:
<https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.3.2>
The XSLT tools application source is licensed under AGPL-3.0-only. This release
also contains third-party components under separate terms. See
`THIRD_PARTY_NOTICES.md` and the complete notices below `LICENSES/` in the
release archive, including the separate SaxonJS terms.

15
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,15 @@
# Third-party notices
This application includes third-party software under licences that are separate
from the XSLT tools application's AGPL-3.0-only licence.
- The vendored SaxonJS browser distribution is proprietary software available
under Saxonica's free-of-charge terms. Its complete supplied licence is at
`LICENSES/SaxonJS-LICENSE.txt` in release archives.
- Runtime npm dependencies retain their own licences. Release archives include
the installed licence and notice files for every production dependency below
`LICENSES/npm/`, including the Apache-2.0 Toolbox Contract and React shell
packages.
The dependency set and exact versions are fixed by `package-lock.json`. Review
the accompanying licence files before redistributing the complete archive.

View File

@@ -9,6 +9,12 @@ export default tseslint.config(
{ ignores: ['dist', 'public/vendor/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['scripts/**/*.{js,mjs,cjs}'],
languageOptions: {
globals: globals.node,
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {

View File

@@ -5,7 +5,7 @@
<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."
content="Browser-based XSLT tools with explicit executable-stylesheet trust controls."
/>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

46
package-lock.json generated
View File

@@ -1,13 +1,16 @@
{
"name": "xsl-tools",
"version": "0.2.0",
"version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xsl-tools",
"version": "0.2.0",
"version": "0.3.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@add-ideas/toolbox-contract": "^0.1.0",
"@add-ideas/toolbox-shell-react": "^0.1.0",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lint": "^6.8.5",
@@ -20,6 +23,7 @@
"react-dom": "^19.2.6"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "^0.1.0",
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -40,6 +44,38 @@
"vitest": "^4.1.6"
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.1.1",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz",
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.1.1",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.1.1/toolbox-shell-react-0.1.1.tgz",
"integrity": "sha512-Di2mu1BoJrWJg4/v5jDExROXDpvLXorlUgqcsBQV8ea/LklKoBFTAfgTkk/23HZ2BzixyMIjrhl9kD507s+iPQ==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.1"
},
"peerDependencies": {
"react": ">=18 <20",
"react-dom": ">=18 <20"
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.1.1",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.1.1/toolbox-testkit-0.1.1.tgz",
"integrity": "sha512-o+H7LfM7ipdgmYz18S0lLuN6JsXUR+8ubAbcjTWTk8rSqNfp1JyeoRHOPbJVEHXrQwIFe/OB86UzRpex2gnKUQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.1"
},
"bin": {
"toolbox-check": "dist/cli.js"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@@ -3647,9 +3683,9 @@
}
},
"node_modules/undici": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz",
"integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -3,9 +3,12 @@
"version": "0.3.2",
"private": true,
"type": "module",
"description": "Browser-only XSLT workbench for local XML/XSLT transformations.",
"license": "AGPL-3.0-only",
"description": "Browser-based XSLT workbench with explicit executable-stylesheet trust controls.",
"scripts": {
"predev": "npm run manifest:generate",
"dev": "vite",
"prebuild": "npm run manifest:generate",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
@@ -14,11 +17,19 @@
"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",
"manifest:generate": "node --experimental-strip-types scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node --experimental-strip-types scripts/generate-toolbox-manifest.mjs --check",
"smoke:static": "node scripts/smoke-static-build.mjs",
"smoke:toolbox": "toolbox-check dist",
"release:artifact": "npm run check && node scripts/package-release.mjs",
"release": "npm run release:artifact",
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run test && npm run build && npm run smoke:static && npm run smoke:toolbox",
"lint:fix": "eslint . --fix",
"clean": "rm -rf dist node_modules/.vite coverage"
},
"dependencies": {
"@add-ideas/toolbox-contract": "^0.1.0",
"@add-ideas/toolbox-shell-react": "^0.1.0",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lint": "^6.8.5",
@@ -31,6 +42,7 @@
"react-dom": "^19.2.6"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "^0.1.0",
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",

View File

@@ -3,13 +3,13 @@
"short_name": "XSLTools",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"src": "./web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"src": "./web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"

41
public/toolbox-app.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.xslt-tools",
"name": "XSLT tools",
"version": "0.3.2",
"description": "Browser-based XML and XSLT transformations with executable-stylesheet trust controls.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "xml", "xslt"],
"tags": ["transform", "validate", "format", "explain"],
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.3.2"
}
],
"assets": ["./vendor/saxon/SaxonJS2.js", "./vendor/saxon/LICENSE.txt"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": true,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false
},
"privacy": {
"processing": "mixed",
"fileUploads": true,
"telemetry": false,
"label": "Stylesheets are executable: after explicit trust they may read same-site app data, contact the network, or navigate away."
},
"source": {
"repository": "https://git.add-ideas.de/zemion/xslt-tools",
"license": "AGPL-3.0-only"
}
}

View File

@@ -0,0 +1,33 @@
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { format } from 'prettier';
import { toolboxAppDefinition } from '../src/toolboxApp.ts';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = JSON.parse(
await readFile(join(repositoryRoot, 'package.json'), 'utf8')
);
const manifestPath = join(repositoryRoot, 'public', 'toolbox-app.json');
if (toolboxAppDefinition.version !== packageJson.version) {
throw new Error(
`Version mismatch: package.json is ${packageJson.version}, while src/toolboxApp.ts is ${toolboxAppDefinition.version}.`
);
}
const output = await format(JSON.stringify(toolboxAppDefinition), {
parser: 'json',
});
if (process.argv.includes('--check')) {
const existing = await readFile(manifestPath, 'utf8').catch(() => '');
if (existing !== output) {
throw new Error(
'public/toolbox-app.json is out of date. Run `npm run manifest:generate`.'
);
}
} else {
await writeFile(manifestPath, output);
}

View File

@@ -0,0 +1,26 @@
export interface ReleaseEntry {
name: string;
data: Uint8Array;
}
export interface DosDateTime {
time: number;
date: number;
}
export function createDeterministicZip(
entries: ReleaseEntry[]
): Buffer<ArrayBuffer>;
export function collectReleaseEntries(): ReleaseEntry[];
export function collectRuntimeDependencyLicenseEntries(): ReleaseEntry[];
export function listFiles(directory: string): string[];
export function toDosDateTime(date: Date): DosDateTime;
export function writeReleaseFiles(options: {
archive: Uint8Array;
archiveTarget: string;
checksum: string;
checksumTarget: string;
force?: boolean;
}): void;
export function packageRelease(options?: { force?: boolean }): void;

346
scripts/package-release.mjs Normal file
View File

@@ -0,0 +1,346 @@
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDirectory = join(repositoryRoot, 'dist');
const releaseDirectory = join(repositoryRoot, 'release');
const packageJson = JSON.parse(
readFileSync(join(repositoryRoot, 'package.json'), 'utf8')
);
const artifactName = `xslt-tools-${packageJson.version}`;
const archivePath = join(releaseDirectory, `${artifactName}.zip`);
const checksumPath = join(releaseDirectory, `${artifactName}.sha256`);
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
export function listFiles(directory) {
return readdirSync(directory, { withFileTypes: true })
.sort((left, right) => compareText(left.name, right.name))
.flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return listFiles(path);
if (entry.isFile()) return [path];
throw new Error(
`Refusing to package symlink or special filesystem entry: ${path}`
);
});
}
function zipPath(path) {
return path.split(sep).join('/');
}
export function collectReleaseEntries() {
if (!existsSync(join(distDirectory, 'index.html'))) {
throw new Error('dist/index.html is missing. Run `npm run build` first.');
}
if (!existsSync(join(distDirectory, 'toolbox-app.json'))) {
throw new Error(
'dist/toolbox-app.json is missing. Generate and build the toolbox manifest first.'
);
}
const entries = listFiles(distDirectory).map((path) => ({
name: zipPath(relative(distDirectory, path)),
data: readFileSync(path),
}));
entries.push(
{
name: 'CHANGELOG.md',
data: readFileSync(join(repositoryRoot, 'CHANGELOG.md')),
},
{
name: 'LICENSE',
data: readFileSync(join(repositoryRoot, 'LICENSE')),
},
{
name: 'LICENSES/xslt-tools-LICENSE.txt',
data: readFileSync(join(repositoryRoot, 'LICENSE')),
},
{
name: 'LICENSES/SaxonJS-LICENSE.txt',
data: readFileSync(
join(repositoryRoot, 'public/vendor/saxon/LICENSE.txt')
),
},
{
name: 'THIRD_PARTY_NOTICES.md',
data: readFileSync(join(repositoryRoot, 'THIRD_PARTY_NOTICES.md')),
},
{
name: 'SOURCE.md',
data: readFileSync(join(repositoryRoot, 'SOURCE.md')),
}
);
entries.push(...collectRuntimeDependencyLicenseEntries());
return entries.sort((left, right) => compareText(left.name, right.name));
}
export function collectRuntimeDependencyLicenseEntries() {
const packages = collectRuntimePackages();
const entries = [];
for (const runtimePackage of packages) {
const legalFiles = readdirSync(runtimePackage.directory, {
withFileTypes: true,
})
.filter(
(entry) =>
entry.isFile() &&
/^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name)
)
.sort((left, right) => compareText(left.name, right.name));
if (legalFiles.length === 0) {
throw new Error(
`Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no licence file.`
);
}
const directoryName = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`;
for (const legalFile of legalFiles) {
entries.push({
name: `LICENSES/npm/${directoryName}/${basename(legalFile.name)}`,
data: readFileSync(join(runtimePackage.directory, legalFile.name)),
});
}
}
return entries.sort((left, right) => compareText(left.name, right.name));
}
function collectRuntimePackages() {
const queue = Object.keys(packageJson.dependencies ?? {}).map((name) => ({
name,
fromDirectory: repositoryRoot,
}));
const visited = new Set();
const packages = [];
while (queue.length > 0) {
const dependency = queue.shift();
const directory = findInstalledPackageDirectory(
dependency.name,
dependency.fromDirectory
);
const manifest = JSON.parse(
readFileSync(join(directory, 'package.json'), 'utf8')
);
const identity = `${manifest.name}@${manifest.version}`;
if (visited.has(identity)) continue;
visited.add(identity);
packages.push({
name: manifest.name,
version: manifest.version,
directory,
});
const childNames = new Set([
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.optionalDependencies ?? {}),
]);
for (const name of [...childNames].sort(compareText)) {
const childDirectory = tryFindInstalledPackageDirectory(name, directory);
if (childDirectory) {
queue.push({ name, fromDirectory: directory });
}
}
}
return packages.sort((left, right) =>
compareText(
`${left.name}@${left.version}`,
`${right.name}@${right.version}`
)
);
}
function findInstalledPackageDirectory(name, fromDirectory) {
const directory = tryFindInstalledPackageDirectory(name, fromDirectory);
if (!directory) {
throw new Error(`Runtime dependency ${name} is not installed.`);
}
return directory;
}
function tryFindInstalledPackageDirectory(name, fromDirectory) {
let current = resolve(fromDirectory);
const packageSegments = name.split('/');
while (true) {
const candidate = join(current, 'node_modules', ...packageSegments);
if (existsSync(join(candidate, 'package.json'))) {
return realpathSync(candidate);
}
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
function safePackageName(name) {
return name.replace(/^@/, '').replaceAll('/', '--');
}
function compareText(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
const crcTable = Array.from({ length: 256 }, (_, value) => {
let result = value;
for (let bit = 0; bit < 8; bit += 1) {
result = (result & 1) !== 0 ? 0xedb88320 ^ (result >>> 1) : result >>> 1;
}
return result >>> 0;
});
function crc32(data) {
let result = 0xffffffff;
for (const byte of data) {
result = crcTable[(result ^ byte) & 0xff] ^ (result >>> 8);
}
return (result ^ 0xffffffff) >>> 0;
}
export function createDeterministicZip(entries) {
const localRecords = [];
const centralRecords = [];
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name, 'utf8');
const data = Buffer.from(entry.data);
const checksum = crc32(data);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0x0800, 6);
localHeader.writeUInt16LE(0, 8);
localHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 10);
localHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 12);
localHeader.writeUInt32LE(checksum, 14);
localHeader.writeUInt32LE(data.length, 18);
localHeader.writeUInt32LE(data.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28);
localRecords.push(localHeader, name, data);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0x0800, 8);
centralHeader.writeUInt16LE(0, 10);
centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 12);
centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 14);
centralHeader.writeUInt32LE(checksum, 16);
centralHeader.writeUInt32LE(data.length, 20);
centralHeader.writeUInt32LE(data.length, 24);
centralHeader.writeUInt16LE(name.length, 28);
centralHeader.writeUInt16LE(0, 30);
centralHeader.writeUInt16LE(0, 32);
centralHeader.writeUInt16LE(0, 34);
centralHeader.writeUInt16LE(0, 36);
centralHeader.writeUInt32LE(0, 38);
centralHeader.writeUInt32LE(offset, 42);
centralRecords.push(centralHeader, name);
offset += localHeader.length + name.length + data.length;
}
const centralDirectory = Buffer.concat(centralRecords);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localRecords, centralDirectory, end]);
}
export function toDosDateTime(date) {
const year = date.getFullYear();
if (year < 1980 || year > 2107) {
throw new RangeError('ZIP timestamps must be between 1980 and 2107.');
}
return {
time:
(date.getHours() << 11) |
(date.getMinutes() << 5) |
Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
export function writeReleaseFiles({
archive,
archiveTarget,
checksum,
checksumTarget,
force = false,
}) {
mkdirSync(dirname(archiveTarget), { recursive: true });
mkdirSync(dirname(checksumTarget), { recursive: true });
const existingTargets = [archiveTarget, checksumTarget].filter(existsSync);
if (!force && existingTargets.length > 0) {
throw new Error(
`Refusing to overwrite existing release file${existingTargets.length === 1 ? '' : 's'}: ${existingTargets.join(', ')}. Pass --force to replace the same-version artifact.`
);
}
const writeOptions = force ? undefined : { flag: 'wx' };
writeFileSync(archiveTarget, archive, writeOptions);
writeFileSync(checksumTarget, checksum, {
encoding: 'utf8',
...(writeOptions ?? {}),
});
}
export function packageRelease({ force = false } = {}) {
const archive = createDeterministicZip(collectReleaseEntries());
const checksum = createHash('sha256').update(archive).digest('hex');
writeReleaseFiles({
archive,
archiveTarget: archivePath,
checksum: `${checksum} ${artifactName}.zip\n`,
checksumTarget: checksumPath,
force,
});
console.log(relative(repositoryRoot, archivePath));
console.log(relative(repositoryRoot, checksumPath));
}
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
const arguments_ = process.argv.slice(2);
const unknownArguments = arguments_.filter(
(argument) => argument !== '--force'
);
if (unknownArguments.length > 0) {
throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`);
}
packageRelease({ force: arguments_.includes('--force') });
}

View File

@@ -0,0 +1,151 @@
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDirectory = join(repositoryRoot, 'dist');
const nestedPath = '/deep/nested/app/';
const mimeTypes = {
'.css': 'text/css',
'.html': 'text/html',
'.ico': 'image/x-icon',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
if (requestUrl.pathname === '/toolbox.catalog.json') {
response.setHeader('content-type', 'application/json');
response.end(
JSON.stringify({
schemaVersion: 1,
id: 'nested-smoke-test',
name: 'Nested smoke test',
home: './',
theme: {
mode: 'system',
brand: 'Nested smoke test',
},
apps: [
{
manifest: `${nestedPath}toolbox-app.json`,
enabled: true,
},
],
})
);
return;
}
if (!requestUrl.pathname.startsWith(nestedPath)) {
response.writeHead(404).end();
return;
}
const requestedPath = decodeURIComponent(
requestUrl.pathname.slice(nestedPath.length)
);
const filePath = resolve(
distDirectory,
requestedPath === '' ? 'index.html' : requestedPath
);
if (
filePath !== distDirectory &&
!filePath.startsWith(`${distDirectory}${sep}`)
) {
response.writeHead(403).end();
return;
}
if (!(await stat(filePath)).isFile()) {
response.writeHead(404).end();
return;
}
response.setHeader(
'content-type',
mimeTypes[extname(filePath)] ?? 'application/octet-stream'
);
response.end(await readFile(filePath));
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolveListening) => {
server.listen(0, '127.0.0.1', resolveListening);
});
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Static smoke server did not expose a TCP port.');
}
const appUrl = `http://127.0.0.1:${address.port}${nestedPath}`;
const standaloneHtml = await fetchText(appUrl);
const toolboxHtml = await fetchText(
`${appUrl}?toolbox=${encodeURIComponent('/toolbox.catalog.json')}`
);
if (toolboxHtml !== standaloneHtml) {
throw new Error('Toolbox mode did not serve the same static app artifact.');
}
const assetReferences = Array.from(
standaloneHtml.matchAll(/(?:href|src)="([^"]+)"/g),
(match) => match[1]
).filter(
(reference) =>
!reference.startsWith('#') &&
!reference.startsWith('data:') &&
!reference.startsWith('http:') &&
!reference.startsWith('https:')
);
for (const reference of assetReferences) {
const resolved = new URL(reference, appUrl);
if (!resolved.pathname.startsWith(nestedPath)) {
throw new Error(
`Built asset escaped the nested app path: ${reference} -> ${resolved.pathname}`
);
}
await fetchOk(resolved);
}
const manifestUrl = new URL('toolbox-app.json', appUrl);
const manifest = JSON.parse(await fetchText(manifestUrl));
await fetchOk(new URL(manifest.entry, appUrl));
await fetchOk(new URL(manifest.icon, manifestUrl));
await fetchOk(new URL('vendor/saxon/SaxonJS2.js', appUrl));
await fetchOk(`http://127.0.0.1:${address.port}/toolbox.catalog.json`);
console.log(
`Static smoke test passed at ${nestedPath} in standalone and toolbox modes.`
);
} finally {
await new Promise((resolveClosed, reject) => {
server.close((error) => (error ? reject(error) : resolveClosed()));
});
}
async function fetchOk(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Expected ${url} to be available, received HTTP ${response.status}.`
);
}
return response;
}
async function fetchText(url) {
return (await fetchOk(url)).text();
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Layout from './components/Layout';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import Toolbar from './components/Toolbar';
import EditorPanel from './components/EditorPanel';
import ActionDialog, {
@@ -19,8 +19,14 @@ import {
} from './validation/validationTypes';
import { runTransformation } from './transform/transformService';
import { createTransformationRun } from './transform/nativeXsltEngine';
import { createSha256Hash } from './transform/hash';
import {
isStylesheetExecutionTrusted,
type TrustedStylesheet,
} from './transform/stylesheetTrust';
import type { TransformEngineId } from './transform/transformTypes';
import { createApproximateTrace } from './transform/traceAnalyzer';
import { toolboxApp } from './toolboxApp';
interface DialogState {
title: string;
@@ -52,6 +58,19 @@ const App: React.FC = () => {
DiagnosticMessage[]
>([]);
const [validationTouched, setValidationTouched] = useState(false);
const [trustedStylesheet, setTrustedStylesheet] =
useState<TrustedStylesheet | null>(null);
const stylesheetRevisionRef = useRef(0);
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const invalidateStylesheetTrust = () => {
stylesheetRevisionRef.current += 1;
setTrustedStylesheet(null);
};
const xmlDiagnostics = useMemo(
() => validateXml(state.xmlInput.text, 'XML input'),
@@ -161,6 +180,7 @@ const App: React.FC = () => {
}
replaceDocument(kind, opened.text, opened.name, false);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Loaded ${opened.name}.`);
} catch (error) {
if (isAbortError(error)) return;
@@ -203,6 +223,16 @@ const App: React.FC = () => {
return;
}
if (
!trustedStylesheet ||
trustedStylesheet.revision !== stylesheetRevisionRef.current
) {
setStatusMessage(
'Review and trust this exact stylesheet before transforming.'
);
return;
}
if (state.options.askBeforeOverwritingOutput) {
const mayOverwriteOutput = await confirmOverwrite(
'xmlOutput',
@@ -213,13 +243,33 @@ const App: React.FC = () => {
setBusy(true);
try {
const currentRevision = stylesheetRevisionRef.current;
const currentState = stateRef.current;
const currentHash = await createSha256Hash(currentState.xsltCode.text);
if (
currentRevision !== stylesheetRevisionRef.current ||
!isStylesheetExecutionTrusted(
trustedStylesheet,
currentHash,
currentRevision
)
) {
setTrustedStylesheet(null);
setStatusMessage(
'The stylesheet changed. Review and trust it again before transforming.'
);
return;
}
const request = {
xmlText: state.xmlInput.text,
xsltText: state.xsltCode.text,
engine: state.selectedEngine,
xmlText: currentState.xmlInput.text,
xsltText: currentState.xsltCode.text,
engine: currentState.selectedEngine,
};
const result = await runTransformation(request);
const result = await runTransformation(request, {
trustedStylesheet,
currentRevision,
});
setRuntimeDiagnostics(result.diagnostics);
if (hasErrors(result.diagnostics)) {
@@ -229,11 +279,16 @@ const App: React.FC = () => {
return;
}
const output = state.options.prettifyOutputAfterTransform
const output = currentState.options.prettifyOutputAfterTransform
? tryFormatXml(result.output)
: result.output;
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
replaceDocument(
'xmlOutput',
output,
currentState.xmlOutput.fileName,
true
);
const run = await createTransformationRun(request, output);
setLastTransformation(run);
@@ -253,6 +308,66 @@ const App: React.FC = () => {
}
};
const handleTrustStylesheet = async () => {
const revision = stylesheetRevisionRef.current;
const stylesheetText = stateRef.current.xsltCode.text;
try {
const hash = await createSha256Hash(stylesheetText);
const confirmed = await askForConfirmation({
title: 'Trust this executable stylesheet?',
content: (
<>
<p>
XSLT is executable code. Both engines may access browser or
network resources. Only continue if you trust the source of this
exact stylesheet.
</p>
<p>
A trusted SaxonJS stylesheet may access data stored by other
Toolbox apps on this same site, including PDF workspaces and
portal preferences. It may send that data over the network or
navigate this page away.
</p>
<p>
Size limits and a best-effort asynchronous deadline reduce
accidental overload, but the engines do not provide complete
process or network isolation. Synchronous work cannot be forcibly
stopped. Any edit, opened replacement, formatting change, or reset
revokes trust.
</p>
</>
),
proceedLabel: 'Trust this exact stylesheet',
danger: true,
});
if (!confirmed) {
setStatusMessage('Stylesheet remains untrusted.');
return;
}
if (
revision !== stylesheetRevisionRef.current ||
stylesheetText !== stateRef.current.xsltCode.text
) {
setTrustedStylesheet(null);
setStatusMessage(
'The stylesheet changed during review. Review it again.'
);
return;
}
setTrustedStylesheet({ hash, revision });
setStatusMessage(
'This exact stylesheet is trusted for the current session.'
);
} catch (error) {
setTrustedStylesheet(null);
setStatusMessage(
error instanceof Error
? error.message
: 'Could not fingerprint the stylesheet.'
);
}
};
const handleMoveOutputToInput = async () => {
if (!state.xmlOutput.text.trim()) {
setStatusMessage('The output editor is empty. Nothing to move.');
@@ -292,6 +407,7 @@ const App: React.FC = () => {
if (!mayProceed) return;
replaceDocument(kind, formatted, documentState.fileName, true);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Formatted ${documentState.label}.`);
} catch (error) {
setStatusMessage(
@@ -336,6 +452,7 @@ const App: React.FC = () => {
if (!mayProceed) return;
resetWorkspace();
invalidateStylesheetTrust();
setRuntimeDiagnostics([]);
setValidationTouched(false);
setStatusMessage('Workspace reset.');
@@ -402,7 +519,8 @@ const App: React.FC = () => {
event.stopPropagation();
if (!busy) {
void handleApplyTransformation();
if (trustedStylesheet) void handleApplyTransformation();
else void handleTrustStylesheet();
}
}
};
@@ -412,10 +530,25 @@ const App: React.FC = () => {
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [busy, handleApplyTransformation, handleOpenHelp]);
});
return (
<Layout onOpenHelp={() => handleOpenHelp()}>
<AppShell
app={toolboxApp}
className="xslt-app-shell"
appActions={
<button
type="button"
className="app-help-button"
onClick={handleOpenHelp}
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="workspace-grid">
<EditorPanel
ref={xmlInputEditorRef}
@@ -443,7 +576,10 @@ const App: React.FC = () => {
fileName={state.xsltCode.fileName}
dirty={state.xsltCode.dirty}
diagnostics={xsltDiagnostics}
onChange={(value) => updateDocumentText('xsltCode', value)}
onChange={(value) => {
invalidateStylesheetTrust();
updateDocumentText('xsltCode', value);
}}
onOpen={() => void handleOpenFile('xsltCode')}
onSave={() => void handleSaveFile('xsltCode')}
onUndo={() => xsltEditorRef.current?.undo()}
@@ -487,10 +623,13 @@ const App: React.FC = () => {
selectedEngine={state.selectedEngine}
onEngineChange={handleEngineChange}
onApplyTransformation={() => void handleApplyTransformation()}
onTrustStylesheet={() => void handleTrustStylesheet()}
onMoveOutputToInput={() => void handleMoveOutputToInput()}
onValidate={handleValidate}
onReset={() => void handleReset()}
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
prettifyOutputAfterTransform={
state.options.prettifyOutputAfterTransform
}
onPrettifyOutputAfterTransformChange={
handlePrettifyOutputAfterTransformChange
}
@@ -499,6 +638,7 @@ const App: React.FC = () => {
handleAskBeforeOverwritingOutputChange
}
busy={busy}
stylesheetTrusted={trustedStylesheet !== null}
/>
{statusMessage && <div className="status-banner">{statusMessage}</div>}
@@ -520,7 +660,6 @@ const App: React.FC = () => {
</section>
)}
<section className="trace-panel card">
<div className="trace-header">
<div>
@@ -586,13 +725,8 @@ const App: React.FC = () => {
{dialog?.content}
</ActionDialog>
{helpOpen && (
<HelpDialog
open={helpOpen}
onClose={handleCloseHelp}
/>
)}
</Layout>
{helpOpen && <HelpDialog open={helpOpen} onClose={handleCloseHelp} />}
</AppShell>
);
};

View File

@@ -14,7 +14,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<div className="help-dialog-header">
<div>
<h2>XSLT tools help</h2>
<p>Local-only XML/XSLT transformation workbench.</p>
<p>Browser XML/XSLT transformation workbench.</p>
</div>
<button type="button" className="icon-button" onClick={onClose}>
×
@@ -32,9 +32,25 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<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).
, 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>Executable stylesheet safety</h3>
<p>
XSLT is executable code. Before either engine can run, you must
explicitly trust the SHA-256 fingerprint of the exact current
stylesheet. Every edit revokes that trust. Run only stylesheets from
sources you trust.
</p>
<p>
A trusted SaxonJS stylesheet may read same-site data belonging to
other Toolbox apps, including PDF workspaces and portal preferences,
then send it over the network or navigate this page away. Input and
output limits plus a best-effort asynchronous deadline reduce
accidental overload, but transformations are not isolated and
synchronous work cannot be forcibly stopped.
</p>
<h3>Keyboard shortcuts</h3>
@@ -43,8 +59,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<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
<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
@@ -60,8 +76,12 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<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.
The application itself does not intentionally upload opened files
and stores the workspace in browser storage. A stylesheet you choose
to trust and execute may nevertheless access same-site data, request
external resources, or navigate away, so processing is
conservatively classified as mixed rather than guaranteed
local-only.
</p>
</div>
</div>

View File

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

View File

@@ -6,6 +6,7 @@ interface ToolbarProps {
selectedEngine: TransformEngineId;
onEngineChange: (engine: TransformEngineId) => void;
onApplyTransformation: () => void;
onTrustStylesheet: () => void;
onMoveOutputToInput: () => void;
onValidate: () => void;
onReset: () => void;
@@ -14,12 +15,14 @@ interface ToolbarProps {
askBeforeOverwritingOutput: boolean;
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
busy?: boolean;
stylesheetTrusted: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
selectedEngine,
onEngineChange,
onApplyTransformation,
onTrustStylesheet,
onMoveOutputToInput,
onValidate,
onReset,
@@ -28,6 +31,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
askBeforeOverwritingOutput = true,
onAskBeforeOverwritingOutputChange,
busy = false,
stylesheetTrusted,
}) => {
return (
<section className="toolbar card" aria-label="Workbench actions">
@@ -36,11 +40,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
type="button"
className="button primary"
onClick={onApplyTransformation}
disabled={busy}
title="Apply transformation (Ctrl/Cmd+Enter)"
disabled={busy || !stylesheetTrusted}
title={
stylesheetTrusted
? 'Apply transformation (Ctrl/Cmd+Enter)'
: 'Review and trust this exact stylesheet before running it'
}
>
{busy ? 'Transforming…' : 'Apply transformation'}
</button>
{!stylesheetTrusted && (
<button
type="button"
className="button danger-outline"
onClick={onTrustStylesheet}
disabled={busy}
>
Review &amp; trust stylesheet
</button>
)}
<button
type="button"
className="button secondary"
@@ -61,6 +79,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
</button>
</div>
<div
className={`stylesheet-trust-status ${
stylesheetTrusted ? 'is-trusted' : 'is-untrusted'
}`}
role="status"
>
<strong>
{stylesheetTrusted
? 'This exact stylesheet is trusted for this session.'
: 'Transformation is disabled until this exact stylesheet is reviewed and trusted.'}
</strong>{' '}
Stylesheets are executable code and may access browser or network
resources. Any edit revokes trust.
</div>
<div className="toolbar-row toolbar-options">
<label className="toggle-option">
<input

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import 'bootstrap-icons/font/bootstrap-icons.css';
import '@add-ideas/toolbox-shell-react/styles.css';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(

View File

@@ -35,69 +35,28 @@ kbd {
monospace;
}
.app-root {
min-height: 100vh;
.xslt-app-shell {
color-scheme: light;
--toolbox-background: #f3f4f6;
--toolbox-surface: #ffffff;
--toolbox-text: #111827;
--toolbox-muted: #6b7280;
--toolbox-border: #d1d5db;
--toolbox-accent: #2563eb;
--toolbox-accent-contrast: #ffffff;
}
.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 {
.xslt-app-shell > .toolbox-shell__main {
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;
flex-direction: column;
gap: 0.75rem;
}
.app-help-button {
border: 1px solid #4b5563;
border: 1px solid var(--toolbox-border);
border-radius: 999px;
background: transparent;
color: #e5e7eb;
color: var(--toolbox-text);
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-weight: 600;
@@ -107,17 +66,10 @@ kbd {
.app-help-button:hover,
.app-help-button:focus-visible {
background: #374151;
background: var(--toolbox-background);
outline: none;
}
.app-main {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
width: 100%;
background: #ffffff;
@@ -207,6 +159,26 @@ kbd {
font-size: 0.85rem;
}
.stylesheet-trust-status {
padding: 0.65rem 0.75rem;
border: 1px solid;
border-radius: 0.55rem;
font-size: 0.82rem;
line-height: 1.45;
}
.stylesheet-trust-status.is-untrusted {
border-color: #fca5a5;
background: #fff1f2;
color: #881337;
}
.stylesheet-trust-status.is-trusted {
border-color: #86efac;
background: #f0fdf4;
color: #14532d;
}
.toggle-option {
display: inline-flex;
align-items: center;

49
src/toolboxApp.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineToolboxApp } from '@add-ideas/toolbox-contract';
import { APP_VERSION } from './version.ts';
export const toolboxAppDefinition = {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
schemaVersion: 1,
id: 'de.add-ideas.xslt-tools',
name: 'XSLT tools',
version: APP_VERSION,
description:
'Browser-based XML and XSLT transformations with executable-stylesheet trust controls.',
entry: './',
icon: './favicon.svg',
categories: ['documents', 'xml', 'xslt'],
tags: ['transform', 'validate', 'format', 'explain'],
actions: [
{
id: 'source',
label: 'Source',
url: 'https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.3.2',
},
],
assets: ['./vendor/saxon/SaxonJS2.js', './vendor/saxon/LICENSE.txt'],
integration: {
contextVersion: 1,
launchModes: ['navigate', 'new-tab'],
embedding: 'unsupported',
},
requirements: {
secureContext: true,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
},
privacy: {
processing: 'mixed',
fileUploads: true,
telemetry: false,
label:
'Stylesheets are executable: after explicit trust they may read same-site app data, contact the network, or navigate away.',
},
source: {
repository: 'https://git.add-ideas.de/zemion/xslt-tools',
license: 'AGPL-3.0-only',
},
} as const;
export const toolboxApp = defineToolboxApp(toolboxAppDefinition);

View File

@@ -1,6 +1,8 @@
export async function createSha256Hash(text: string): Promise<string> {
if (!window.crypto?.subtle) {
return createFallbackHash(text);
throw new Error(
'SHA-256 is unavailable. Open this app in a secure browser context before trusting or running a stylesheet.'
);
}
const data = new TextEncoder().encode(text);
@@ -8,13 +10,3 @@ export async function createSha256Hash(text: string): Promise<string> {
const bytes = Array.from(new Uint8Array(digest));
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function createFallbackHash(text: string): string {
let hash = 0;
for (let index = 0; index < text.length; index += 1) {
hash = (hash << 5) - hash + text.charCodeAt(index);
hash |= 0;
}
return `fallback-${Math.abs(hash).toString(16)}`;
}

View File

@@ -9,10 +9,16 @@ type SaxonXdmMap = {
inSituPut: (key: unknown, value: unknown[]) => void;
};
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
let saxonLoadPromise: Promise<void> | null = null;
export function resolveSaxonScriptUrl(
baseUrl = import.meta.env.BASE_URL,
pageUrl = window.location.href
): string {
const appBase = new URL(baseUrl, pageUrl);
return new URL('vendor/saxon/SaxonJS2.js', appBase).href;
}
type SaxonPrivateRuntime = {
getPlatform?: () => {
resource?: (name: string) => unknown;
@@ -60,8 +66,9 @@ async function ensureSaxonLoaded(): Promise<void> {
if (window.SaxonJS) return;
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
const saxonScriptUrl = resolveSaxonScriptUrl();
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${SAXON_SCRIPT_URL}"]`
`script[src="${saxonScriptUrl}"]`
);
if (existingScript) {
@@ -75,7 +82,7 @@ async function ensureSaxonLoaded(): Promise<void> {
}
const script = document.createElement('script');
script.src = SAXON_SCRIPT_URL;
script.src = saxonScriptUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));

View File

@@ -0,0 +1,16 @@
export interface TrustedStylesheet {
hash: string;
revision: number;
}
export function isStylesheetExecutionTrusted(
trust: TrustedStylesheet | null,
currentHash: string,
currentRevision: number
): boolean {
return (
trust !== null &&
trust.hash === currentHash &&
trust.revision === currentRevision
);
}

View File

@@ -0,0 +1,65 @@
import type { TransformRequest, TransformResult } from './transformTypes';
export const TRANSFORM_LIMITS = {
maxXmlBytes: 5 * 1024 * 1024,
maxXsltBytes: 2 * 1024 * 1024,
maxOutputBytes: 10 * 1024 * 1024,
timeoutMs: 20_000,
} as const;
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
export function assertTransformInputWithinLimits(
request: TransformRequest
): void {
const xmlBytes = utf8Bytes(request.xmlText);
if (xmlBytes > TRANSFORM_LIMITS.maxXmlBytes) {
throw new Error(
`XML input exceeds the ${TRANSFORM_LIMITS.maxXmlBytes}-byte execution limit.`
);
}
const xsltBytes = utf8Bytes(request.xsltText);
if (xsltBytes > TRANSFORM_LIMITS.maxXsltBytes) {
throw new Error(
`XSLT stylesheet exceeds the ${TRANSFORM_LIMITS.maxXsltBytes}-byte execution limit.`
);
}
}
export function assertTransformOutputWithinLimits(
result: TransformResult
): void {
if (utf8Bytes(result.output) > TRANSFORM_LIMITS.maxOutputBytes) {
throw new Error(
`Transformation output exceeds the ${TRANSFORM_LIMITS.maxOutputBytes}-byte execution limit.`
);
}
}
export async function withTransformationTimeout<T>(
operation: () => Promise<T>,
timeoutMs: number = TRANSFORM_LIMITS.timeoutMs
): Promise<T> {
let timer: number | undefined;
try {
return await Promise.race([
Promise.resolve().then(operation),
new Promise<never>((_resolve, reject) => {
timer = window.setTimeout(
() =>
reject(
new Error(
`Transformation exceeded the ${timeoutMs}-millisecond execution limit.`
)
),
timeoutMs
);
}),
]);
} finally {
if (timer !== undefined) window.clearTimeout(timer);
}
}

View File

@@ -7,6 +7,21 @@ import type {
TransformRequest,
TransformResult,
} from './transformTypes';
import {
assertTransformInputWithinLimits,
assertTransformOutputWithinLimits,
withTransformationTimeout,
} from './transformLimits';
import { createSha256Hash } from './hash';
import {
isStylesheetExecutionTrusted,
type TrustedStylesheet,
} from './stylesheetTrust';
export interface TransformationAuthorization {
trustedStylesheet: TrustedStylesheet;
currentRevision: number;
}
const engines: Record<TransformEngineId, TransformEngine> = {
'saxon-js-dynamic': saxonJsDynamicEngine,
@@ -18,10 +33,30 @@ export function getTransformEngine(id: TransformEngineId): TransformEngine {
}
export async function runTransformation(
request: TransformRequest
request: TransformRequest,
authorization: TransformationAuthorization
): Promise<TransformResult> {
const stylesheetHash = await createSha256Hash(request.xsltText);
if (
!authorization ||
!isStylesheetExecutionTrusted(
authorization.trustedStylesheet,
stylesheetHash,
authorization.currentRevision
)
) {
throw new Error(
'Transformation refused: review and trust this exact stylesheet first.'
);
}
assertTransformInputWithinLimits(request);
const engine = getTransformEngine(request.engine);
return engine.transform(request);
const result = await withTransformationTimeout(() =>
engine.transform(request)
);
assertTransformOutputWithinLimits(result);
return result;
}
export const availableTransformEngines = Object.values(engines);

View File

@@ -0,0 +1,125 @@
import { execFileSync } from 'node:child_process';
import {
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
collectRuntimeDependencyLicenseEntries,
createDeterministicZip,
listFiles,
writeReleaseFiles,
} from '../scripts/package-release.mjs';
describe('release packaging', () => {
it('creates byte-for-byte deterministic ZIP files', () => {
const entries = [
{ name: 'index.html', data: Buffer.from('<h1>XSLT tools</h1>') },
{ name: 'toolbox-app.json', data: Buffer.from('{"schemaVersion":1}') },
];
expect(createDeterministicZip(entries)).toEqual(
createDeterministicZip(entries)
);
});
it('creates the same archive in different host time zones', () => {
const moduleUrl = pathToFileURL(
join(process.cwd(), 'scripts/package-release.mjs')
).href;
const program = `
import { createDeterministicZip } from ${JSON.stringify(moduleUrl)};
const archive = createDeterministicZip([
{ name: 'index.html', data: Buffer.from('timezone invariant') }
]);
process.stdout.write(archive.toString('base64'));
`;
const createInTimezone = (timezone: string) =>
execFileSync(
process.execPath,
['--input-type=module', '--eval', program],
{
encoding: 'utf8',
env: { ...process.env, TZ: timezone },
}
);
expect(createInTimezone('UTC')).toBe(createInTimezone('Pacific/Honolulu'));
});
it('includes legal files for production dependencies but not test tooling', () => {
const names = collectRuntimeDependencyLicenseEntries().map(
(entry) => entry.name
);
expect(
names.some((name) =>
/^LICENSES\/npm\/add-ideas--toolbox-contract-[^/]+\/LICENSE$/.test(name)
)
).toBe(true);
expect(
names.some((name) =>
/^LICENSES\/npm\/add-ideas--toolbox-shell-react-[^/]+\/LICENSE$/.test(
name
)
)
).toBe(true);
expect(names.some((name) => /\/react-[^/]+\/LICENSE$/.test(name))).toBe(
true
);
expect(names.some((name) => name.includes('toolbox-testkit'))).toBe(false);
});
it('refuses same-version overwrites unless force is explicit', () => {
const directory = mkdtempSync(join(tmpdir(), 'xslt-release-test-'));
const archiveTarget = join(directory, 'xslt-tools.zip');
const checksumTarget = join(directory, 'xslt-tools.sha256');
writeFileSync(archiveTarget, 'existing archive');
writeFileSync(checksumTarget, 'existing checksum');
try {
expect(() =>
writeReleaseFiles({
archive: Buffer.from('new archive'),
archiveTarget,
checksum: 'new checksum',
checksumTarget,
})
).toThrow(/Refusing to overwrite/);
expect(readFileSync(archiveTarget, 'utf8')).toBe('existing archive');
expect(readFileSync(checksumTarget, 'utf8')).toBe('existing checksum');
writeReleaseFiles({
archive: Buffer.from('new archive'),
archiveTarget,
checksum: 'new checksum',
checksumTarget,
force: true,
});
expect(readFileSync(archiveTarget, 'utf8')).toBe('new archive');
expect(readFileSync(checksumTarget, 'utf8')).toBe('new checksum');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it('rejects symlinks instead of following them into an archive', () => {
const directory = mkdtempSync(join(tmpdir(), 'xslt-release-tree-test-'));
const target = join(directory, 'target.txt');
const link = join(directory, 'linked.txt');
writeFileSync(target, 'must not be followed through a link');
symlinkSync(target, link);
try {
expect(() => listFiles(directory)).toThrow(/symlink or special/i);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,51 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import Toolbar from '../src/components/Toolbar';
afterEach(cleanup);
describe('stylesheet trust UI gate', () => {
it.each(['saxon-js-dynamic', 'native-xsltprocessor'] as const)(
'keeps the %s engine disabled until the exact stylesheet is trusted',
(selectedEngine) => {
const applyTransformation = vi.fn();
const trustStylesheet = vi.fn();
const props = {
selectedEngine,
onEngineChange: vi.fn(),
onApplyTransformation: applyTransformation,
onTrustStylesheet: trustStylesheet,
onMoveOutputToInput: vi.fn(),
onValidate: vi.fn(),
onReset: vi.fn(),
prettifyOutputAfterTransform: true,
onPrettifyOutputAfterTransformChange: vi.fn(),
askBeforeOverwritingOutput: true,
onAskBeforeOverwritingOutputChange: vi.fn(),
stylesheetTrusted: false,
};
const { rerender } = render(<Toolbar {...props} />);
const applyButton = screen.getByRole('button', {
name: 'Apply transformation',
}) as HTMLButtonElement;
expect(applyButton.disabled).toBe(true);
fireEvent.click(applyButton);
expect(applyTransformation).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole('button', { name: 'Review & trust stylesheet' })
);
expect(trustStylesheet).toHaveBeenCalledOnce();
rerender(<Toolbar {...props} stylesheetTrusted />);
expect(
(
screen.getByRole('button', {
name: 'Apply transformation',
}) as HTMLButtonElement
).disabled
).toBe(false);
}
);
});

View File

@@ -0,0 +1,143 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import type {
ToolboxAppManifest,
ToolboxCatalog,
} from '@add-ideas/toolbox-contract';
import { toolboxApp } from '../src/toolboxApp';
afterEach(cleanup);
const pdfApp: ToolboxAppManifest = {
...toolboxApp,
id: 'de.add-ideas.pdf-tools',
name: 'PDF Workbench',
version: '0.3.4',
description: 'Local PDF page operations in your browser.',
categories: ['documents', 'pdf'],
tags: ['merge', 'split', 'rotate', 'reorder'],
requirements: {
...toolboxApp.requirements,
workers: true,
indexedDb: true,
},
};
const catalog: ToolboxCatalog = {
schemaVersion: 1,
id: 'integration-test',
name: 'Integration test toolbox',
home: './',
theme: {
mode: 'system',
brand: 'Test Toolbox',
},
apps: [
{
manifest: './apps/xslt/toolbox-app.json',
enabled: true,
},
{
manifest: './apps/pdf/toolbox-app.json',
enabled: true,
},
],
};
describe('toolbox shell integration', () => {
it('keeps the app usable standalone with its XSLT Help action', () => {
render(
<AppShell
app={toolboxApp}
contextOptions={{ location: 'https://example.test/deep/nested/app/' }}
appActions={<button type="button">Help</button>}
>
<p>XSLT workbench</p>
</AppShell>
);
expect(screen.getByText('XSLT workbench')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Help' })).toBeTruthy();
expect(
screen.getByRole('link', { name: 'Source' }).getAttribute('href')
).toBe('https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.3.2');
expect(
screen.queryByRole('navigation', { name: 'Toolbox applications' })
).toBeNull();
});
it('loads a same-origin catalogue and carries context through switcher links', async () => {
const location =
'https://example.test/deep/nested/app/?toolbox=/toolbox.catalog.json';
const fetch = vi.fn(createCatalogFetch());
const { container } = render(
<AppShell
app={toolboxApp}
manifestUrl="https://example.test/deep/nested/app/toolbox-app.json"
contextOptions={{ location, fetch }}
>
<p>XSLT workbench</p>
</AppShell>
);
const navigation = await screen.findByRole('navigation', {
name: 'Toolbox applications',
});
expect(navigation).toBeTruthy();
expect(
container.firstElementChild?.getAttribute('data-toolbox-context')
).toBe('connected');
expect(
screen.getByRole('link', { name: 'Test Toolbox' }).getAttribute('href')
).toBe('https://example.test/');
const pdfLink = screen.getByRole('link', { name: 'PDF Workbench' });
const pdfUrl = new URL(pdfLink.getAttribute('href') ?? '');
expect(pdfUrl.pathname).toBe('/apps/pdf/');
expect(pdfUrl.searchParams.get('toolbox')).toBe(
'https://example.test/toolbox.catalog.json'
);
expect(fetch).toHaveBeenCalledTimes(3);
await waitFor(() => {
expect(
screen
.getByRole('link', { name: 'XSLT tools' })
.getAttribute('aria-current')
).toBe('page');
});
});
});
function createCatalogFetch() {
return async (input: string | URL | Request): Promise<Response> => {
const url = new URL(
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url
);
const documents = new Map<string, unknown>([
['/toolbox.catalog.json', catalog],
['/apps/xslt/toolbox-app.json', toolboxApp],
['/apps/pdf/toolbox-app.json', pdfApp],
]);
const document = documents.get(url.pathname);
if (document === undefined) {
return {
ok: false,
status: 404,
json: async () => ({}),
} as Response;
}
return {
ok: true,
status: 200,
json: async () => document,
} as Response;
};
}

View File

@@ -6,7 +6,12 @@ import {
availableTransformEngines,
runTransformation,
} from '../src/transform/transformService';
import { compileXsltTextToSefJson } from '../src/transform/saxonJsDynamicCompiler';
import { createSha256Hash } from '../src/transform/hash';
import type { TransformRequest } from '../src/transform/transformTypes';
import {
compileXsltTextToSefJson,
resolveSaxonScriptUrl,
} from '../src/transform/saxonJsDynamicCompiler';
const xmlText = `<?xml version="1.0" encoding="UTF-8"?>
<library>
@@ -36,6 +41,17 @@ const xslt30Text = `<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1
</xsl:template>
</xsl:stylesheet>`;
async function runTrustedTransformation(request: TransformRequest) {
const revision = 1;
return runTransformation(request, {
trustedStylesheet: {
hash: await createSha256Hash(request.xsltText),
revision,
},
currentRevision: revision,
});
}
beforeAll(() => {
if (window.SaxonJS) return;
@@ -59,6 +75,12 @@ describe('transform engine registry', () => {
});
describe('SaxonJS dynamic compiler', () => {
it('resolves the vendored runtime relative to a nested app deployment', () => {
expect(
resolveSaxonScriptUrl('./', 'https://example.test/deep/nested/app/')
).toBe('https://example.test/deep/nested/app/vendor/saxon/SaxonJS2.js');
});
it('compiles raw XSLT text to SEF JSON', async () => {
const sef = await compileXsltTextToSefJson(xslt30Text);
const parsed = JSON.parse(sef) as { N?: string; target?: string };
@@ -68,7 +90,7 @@ describe('SaxonJS dynamic compiler', () => {
});
it('runs an XSLT 1.0 stylesheet', async () => {
const result = await runTransformation({
const result = await runTrustedTransformation({
xmlText,
xsltText: xslt10Text,
engine: 'saxon-js-dynamic',
@@ -82,7 +104,7 @@ describe('SaxonJS dynamic compiler', () => {
});
it('runs an XSLT 2.0 stylesheet with XPath 2.0 functions', async () => {
const result = await runTransformation({
const result = await runTrustedTransformation({
xmlText,
xsltText: xslt20Text,
engine: 'saxon-js-dynamic',
@@ -95,7 +117,7 @@ describe('SaxonJS dynamic compiler', () => {
});
it('runs an XSLT 3.0 stylesheet with xsl:mode on-no-match', async () => {
const result = await runTransformation({
const result = await runTrustedTransformation({
xmlText,
xsltText: xslt30Text,
engine: 'saxon-js-dynamic',
@@ -109,7 +131,7 @@ describe('SaxonJS dynamic compiler', () => {
});
it('returns diagnostics for invalid XSLT', async () => {
const result = await runTransformation({
const result = await runTrustedTransformation({
xmlText,
xsltText: '<xsl:stylesheet version="3.0">',
engine: 'saxon-js-dynamic',

View File

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest';
import { isStylesheetExecutionTrusted } from '../src/transform/stylesheetTrust';
import { runTransformation } from '../src/transform/transformService';
import {
TRANSFORM_LIMITS,
assertTransformInputWithinLimits,
assertTransformOutputWithinLimits,
withTransformationTimeout,
} from '../src/transform/transformLimits';
describe('stylesheet execution trust', () => {
it('requires both the exact SHA-256 and the current edit revision', () => {
const trust = { hash: 'sha256-a', revision: 4 };
expect(isStylesheetExecutionTrusted(trust, 'sha256-a', 4)).toBe(true);
expect(isStylesheetExecutionTrusted(trust, 'sha256-b', 4)).toBe(false);
expect(isStylesheetExecutionTrusted(trust, 'sha256-a', 5)).toBe(false);
expect(isStylesheetExecutionTrusted(null, 'sha256-a', 4)).toBe(false);
});
it.each(['saxon-js-dynamic', 'native-xsltprocessor'] as const)(
'refuses the %s service path when authorization does not match',
async (engine) => {
await expect(
runTransformation(
{
engine,
xmlText: '<root/>',
xsltText: '<xsl:stylesheet/>',
},
{
trustedStylesheet: { hash: 'not-the-stylesheet-hash', revision: 7 },
currentRevision: 7,
}
)
).rejects.toThrow(/review and trust this exact stylesheet/i);
}
);
});
describe('transformation resource limits', () => {
it('rejects oversized XML, XSLT, and result strings', () => {
expect(() =>
assertTransformInputWithinLimits({
engine: 'native-xsltprocessor',
xmlText: 'x'.repeat(TRANSFORM_LIMITS.maxXmlBytes + 1),
xsltText: '<xsl:stylesheet/>',
})
).toThrow(/XML input exceeds/i);
expect(() =>
assertTransformInputWithinLimits({
engine: 'saxon-js-dynamic',
xmlText: '<root/>',
xsltText: 'x'.repeat(TRANSFORM_LIMITS.maxXsltBytes + 1),
})
).toThrow(/stylesheet exceeds/i);
expect(() =>
assertTransformOutputWithinLimits({
engine: 'saxon-js-dynamic',
output: 'x'.repeat(TRANSFORM_LIMITS.maxOutputBytes + 1),
diagnostics: [],
transformedAt: '2026-01-01T00:00:00.000Z',
})
).toThrow(/output exceeds/i);
});
it('rejects an asynchronous execution that exceeds its deadline', async () => {
vi.useFakeTimers();
try {
const result = withTransformationTimeout(
() => new Promise<never>(() => undefined),
25
);
const rejection = expect(result).rejects.toThrow(/25-millisecond/i);
await vi.advanceTimersByTimeAsync(25);
await rejection;
} finally {
vi.useRealTimers();
}
});
});

21
todo.md
View File

@@ -3,25 +3,34 @@
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
@@ -29,24 +38,34 @@ output
engine
examples/tests
parameters'''
- No uploads needed.
- Use local import/export dialogs; keep executable-stylesheet privacy warnings.
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.

View File

@@ -11,6 +11,7 @@
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,

View File

@@ -2,7 +2,11 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
base: './',
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
host: true,
allowedHosts: ['xslttools.add-ideas.de'], // ← ADD THIS