feat: integrate XSLT Tools with toolbox portal
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,6 +91,7 @@ out
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
release/
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
89
README.md
89
README.md
@@ -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
10
SOURCE.md
Normal 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
15
THIRD_PARTY_NOTICES.md
Normal 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.
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
46
package-lock.json
generated
@@ -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": {
|
||||
|
||||
16
package.json
16
package.json
@@ -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",
|
||||
|
||||
@@ -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
41
public/toolbox-app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
scripts/generate-toolbox-manifest.mjs
Normal file
33
scripts/generate-toolbox-manifest.mjs
Normal 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);
|
||||
}
|
||||
26
scripts/package-release.d.mts
Normal file
26
scripts/package-release.d.mts
Normal 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
346
scripts/package-release.mjs
Normal 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') });
|
||||
}
|
||||
151
scripts/smoke-static-build.mjs
Normal file
151
scripts/smoke-static-build.mjs
Normal 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();
|
||||
}
|
||||
174
src/App.tsx
174
src/App.tsx
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
49
src/toolboxApp.ts
Normal 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);
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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.'));
|
||||
|
||||
16
src/transform/stylesheetTrust.ts
Normal file
16
src/transform/stylesheetTrust.ts
Normal 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
|
||||
);
|
||||
}
|
||||
65
src/transform/transformLimits.ts
Normal file
65
src/transform/transformLimits.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
125
tests/releasePackaging.test.ts
Normal file
125
tests/releasePackaging.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
51
tests/stylesheetTrustUi.test.tsx
Normal file
51
tests/stylesheetTrustUi.test.tsx
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
143
tests/toolboxIntegration.test.tsx
Normal file
143
tests/toolboxIntegration.test.tsx
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
80
tests/transformationSecurity.test.ts
Normal file
80
tests/transformationSecurity.test.ts
Normal 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
21
todo.md
@@ -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.
|
||||
@@ -11,6 +11,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user