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.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
|
release/
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.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
|
# 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
|
## 0.3.1
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* Added global keyboard shortcut `Shift + Enter` to apply the current transformation.
|
- Added global keyboard shortcut `Shift + Enter` to apply the current transformation.
|
||||||
* Added global keyboard shortcut `F1` to open the help dialog.
|
- Added global keyboard shortcut `F1` to open the help dialog.
|
||||||
* Added shortcut hints for transformation and help actions.
|
- Added shortcut hints for transformation and help actions.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
* Bumped application version to `0.3.1`.
|
- Bumped application version to `0.3.1`.
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* Added browser-side SaxonJS dynamic compilation support.
|
- 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 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 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 lazy loading for the SaxonJS browser compiler/runtime.
|
||||||
* Added tests covering XSLT 1.0, XSLT 2.0, and XSLT 3.0 transformation scenarios.
|
- 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 project quality commands for type checking, linting, formatting, testing, building, and combined checks.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
* Made `saxon-js-dynamic` the primary SaxonJS-based engine for raw XML + raw XSLT workflows.
|
- 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 earlier failed npm-bundled SaxonJS approach.
|
||||||
* Removed the SEF-only SaxonJS placeholder from the main workflow.
|
- Removed the SEF-only SaxonJS placeholder from the main workflow.
|
||||||
* Excluded vendored SaxonJS files from linting and formatting.
|
- Excluded vendored SaxonJS files from linting and formatting.
|
||||||
* Cleaned up debug-only code and unused declarations.
|
- Cleaned up debug-only code and unused declarations.
|
||||||
* Improved transformation engine registry and test coverage.
|
- Improved transformation engine registry and test coverage.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
* Fixed SaxonJS browser integration by using the full `SaxonJS2.js` artifact instead of `SaxonJS2.rt.js`.
|
- 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 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 SaxonJS static parameter handling by passing an `XdmMap` instead of a plain JavaScript object.
|
||||||
* Fixed browser compiler result extraction by reading `checkedOptions.principalResult`.
|
- Fixed browser compiler result extraction by reading `checkedOptions.principalResult`.
|
||||||
* Removed duplicate XML output replacement after transformations.
|
- Removed duplicate XML output replacement after transformations.
|
||||||
|
|
||||||
## 0.2.0
|
## 0.2.0
|
||||||
|
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -1,6 +1,12 @@
|
|||||||
# XSLT tools
|
# XSLT tools
|
||||||
|
|
||||||
Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded; the app uses browser APIs, LocalStorage, and local file open/save dialogs where available.
|
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
|
## Features in this MVP
|
||||||
|
|
||||||
@@ -17,6 +23,9 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded
|
|||||||
- Transformation engines:
|
- Transformation engines:
|
||||||
- SaxonJS 2.7 as the default engine for XSLT 1.0/2.0/3.0-style workflows
|
- 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
|
- 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
|
- XML and XSLT well-formedness checks
|
||||||
- Basic XSLT root/version checks
|
- Basic XSLT root/version checks
|
||||||
- Snippet toolbox for common XSLT constructs
|
- 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
|
- Confirmation dialog before destructive overwrites
|
||||||
- LocalStorage persistence
|
- LocalStorage persistence
|
||||||
- Approximate “explain transformation” table after a run
|
- 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 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
|
## Run locally
|
||||||
|
|
||||||
@@ -50,6 +81,45 @@ npm run build
|
|||||||
npm run preview
|
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
|
## Suggested next steps
|
||||||
|
|
||||||
1. Add parameter handling for `xsl:param`.
|
1. Add parameter handling for `xsl:param`.
|
||||||
@@ -63,12 +133,13 @@ npm run preview
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── components/ # Layout, toolbar, editor panels, dialogs
|
├── components/ # Toolbar, editor panels, dialogs, and app-specific actions
|
||||||
├── editor/ # CodeMirror integration and XSLT snippets
|
├── editor/ # CodeMirror integration and XSLT snippets
|
||||||
├── file/ # Local file open/save helpers
|
├── file/ # Local file open/save helpers
|
||||||
├── transform/ # Transform engine abstraction, SaxonJS, native engine
|
├── transform/ # Transform engine abstraction, SaxonJS, native engine
|
||||||
├── validation/ # XML/XSLT validation helpers
|
├── 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
|
## Development checks
|
||||||
@@ -89,6 +160,9 @@ npm run format:check # Prettier check
|
|||||||
npm run format # Prettier write
|
npm run format # Prettier write
|
||||||
npm run test # Vitest/jsdom test suite
|
npm run test # Vitest/jsdom test suite
|
||||||
npm run build # Production build
|
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
|
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:
|
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.
|
- **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/**'] },
|
{ ignores: ['dist', 'public/vendor/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.{js,mjs,cjs}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
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/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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",
|
"name": "xsl-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "xsl-tools",
|
"name": "xsl-tools",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@add-ideas/toolbox-contract": "^0.1.0",
|
||||||
|
"@add-ideas/toolbox-shell-react": "^0.1.0",
|
||||||
"@codemirror/commands": "^6.8.1",
|
"@codemirror/commands": "^6.8.1",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lint": "^6.8.5",
|
"@codemirror/lint": "^6.8.5",
|
||||||
@@ -20,6 +23,7 @@
|
|||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@add-ideas/toolbox-testkit": "^0.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -40,6 +44,38 @@
|
|||||||
"vitest": "^4.1.6"
|
"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": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "5.1.11",
|
"version": "5.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
@@ -3647,9 +3683,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.27.1",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
"integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==",
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -3,9 +3,12 @@
|
|||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
|
"predev": "npm run manifest:generate",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "npm run manifest:generate",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -14,11 +17,19 @@
|
|||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"test": "vitest run --environment jsdom",
|
"test": "vitest run --environment jsdom",
|
||||||
"test:watch": "vitest --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",
|
"lint:fix": "eslint . --fix",
|
||||||
"clean": "rm -rf dist node_modules/.vite coverage"
|
"clean": "rm -rf dist node_modules/.vite coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@add-ideas/toolbox-contract": "^0.1.0",
|
||||||
|
"@add-ideas/toolbox-shell-react": "^0.1.0",
|
||||||
"@codemirror/commands": "^6.8.1",
|
"@codemirror/commands": "^6.8.1",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lint": "^6.8.5",
|
"@codemirror/lint": "^6.8.5",
|
||||||
@@ -31,6 +42,7 @@
|
|||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@add-ideas/toolbox-testkit": "^0.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
"short_name": "XSLTools",
|
"short_name": "XSLTools",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/web-app-manifest-192x192.png",
|
"src": "./web-app-manifest-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/web-app-manifest-512x512.png",
|
"src": "./web-app-manifest-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"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 { 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 Toolbar from './components/Toolbar';
|
||||||
import EditorPanel from './components/EditorPanel';
|
import EditorPanel from './components/EditorPanel';
|
||||||
import ActionDialog, {
|
import ActionDialog, {
|
||||||
@@ -19,8 +19,14 @@ import {
|
|||||||
} from './validation/validationTypes';
|
} from './validation/validationTypes';
|
||||||
import { runTransformation } from './transform/transformService';
|
import { runTransformation } from './transform/transformService';
|
||||||
import { createTransformationRun } from './transform/nativeXsltEngine';
|
import { createTransformationRun } from './transform/nativeXsltEngine';
|
||||||
|
import { createSha256Hash } from './transform/hash';
|
||||||
|
import {
|
||||||
|
isStylesheetExecutionTrusted,
|
||||||
|
type TrustedStylesheet,
|
||||||
|
} from './transform/stylesheetTrust';
|
||||||
import type { TransformEngineId } from './transform/transformTypes';
|
import type { TransformEngineId } from './transform/transformTypes';
|
||||||
import { createApproximateTrace } from './transform/traceAnalyzer';
|
import { createApproximateTrace } from './transform/traceAnalyzer';
|
||||||
|
import { toolboxApp } from './toolboxApp';
|
||||||
|
|
||||||
interface DialogState {
|
interface DialogState {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -52,6 +58,19 @@ const App: React.FC = () => {
|
|||||||
DiagnosticMessage[]
|
DiagnosticMessage[]
|
||||||
>([]);
|
>([]);
|
||||||
const [validationTouched, setValidationTouched] = useState(false);
|
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(
|
const xmlDiagnostics = useMemo(
|
||||||
() => validateXml(state.xmlInput.text, 'XML input'),
|
() => validateXml(state.xmlInput.text, 'XML input'),
|
||||||
@@ -161,6 +180,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceDocument(kind, opened.text, opened.name, false);
|
replaceDocument(kind, opened.text, opened.name, false);
|
||||||
|
if (kind === 'xsltCode') invalidateStylesheetTrust();
|
||||||
setStatusMessage(`Loaded ${opened.name}.`);
|
setStatusMessage(`Loaded ${opened.name}.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) return;
|
if (isAbortError(error)) return;
|
||||||
@@ -203,6 +223,16 @@ const App: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!trustedStylesheet ||
|
||||||
|
trustedStylesheet.revision !== stylesheetRevisionRef.current
|
||||||
|
) {
|
||||||
|
setStatusMessage(
|
||||||
|
'Review and trust this exact stylesheet before transforming.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.options.askBeforeOverwritingOutput) {
|
if (state.options.askBeforeOverwritingOutput) {
|
||||||
const mayOverwriteOutput = await confirmOverwrite(
|
const mayOverwriteOutput = await confirmOverwrite(
|
||||||
'xmlOutput',
|
'xmlOutput',
|
||||||
@@ -213,13 +243,33 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
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 = {
|
const request = {
|
||||||
xmlText: state.xmlInput.text,
|
xmlText: currentState.xmlInput.text,
|
||||||
xsltText: state.xsltCode.text,
|
xsltText: currentState.xsltCode.text,
|
||||||
engine: state.selectedEngine,
|
engine: currentState.selectedEngine,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await runTransformation(request);
|
const result = await runTransformation(request, {
|
||||||
|
trustedStylesheet,
|
||||||
|
currentRevision,
|
||||||
|
});
|
||||||
setRuntimeDiagnostics(result.diagnostics);
|
setRuntimeDiagnostics(result.diagnostics);
|
||||||
|
|
||||||
if (hasErrors(result.diagnostics)) {
|
if (hasErrors(result.diagnostics)) {
|
||||||
@@ -229,11 +279,16 @@ const App: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = state.options.prettifyOutputAfterTransform
|
const output = currentState.options.prettifyOutputAfterTransform
|
||||||
? tryFormatXml(result.output)
|
? tryFormatXml(result.output)
|
||||||
: result.output;
|
: result.output;
|
||||||
|
|
||||||
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
|
replaceDocument(
|
||||||
|
'xmlOutput',
|
||||||
|
output,
|
||||||
|
currentState.xmlOutput.fileName,
|
||||||
|
true
|
||||||
|
);
|
||||||
const run = await createTransformationRun(request, output);
|
const run = await createTransformationRun(request, output);
|
||||||
|
|
||||||
setLastTransformation(run);
|
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 () => {
|
const handleMoveOutputToInput = async () => {
|
||||||
if (!state.xmlOutput.text.trim()) {
|
if (!state.xmlOutput.text.trim()) {
|
||||||
setStatusMessage('The output editor is empty. Nothing to move.');
|
setStatusMessage('The output editor is empty. Nothing to move.');
|
||||||
@@ -292,6 +407,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
if (!mayProceed) return;
|
if (!mayProceed) return;
|
||||||
replaceDocument(kind, formatted, documentState.fileName, true);
|
replaceDocument(kind, formatted, documentState.fileName, true);
|
||||||
|
if (kind === 'xsltCode') invalidateStylesheetTrust();
|
||||||
setStatusMessage(`Formatted ${documentState.label}.`);
|
setStatusMessage(`Formatted ${documentState.label}.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
@@ -336,6 +452,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
if (!mayProceed) return;
|
if (!mayProceed) return;
|
||||||
resetWorkspace();
|
resetWorkspace();
|
||||||
|
invalidateStylesheetTrust();
|
||||||
setRuntimeDiagnostics([]);
|
setRuntimeDiagnostics([]);
|
||||||
setValidationTouched(false);
|
setValidationTouched(false);
|
||||||
setStatusMessage('Workspace reset.');
|
setStatusMessage('Workspace reset.');
|
||||||
@@ -402,7 +519,8 @@ const App: React.FC = () => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
void handleApplyTransformation();
|
if (trustedStylesheet) void handleApplyTransformation();
|
||||||
|
else void handleTrustStylesheet();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -412,10 +530,25 @@ const App: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||||
};
|
};
|
||||||
}, [busy, handleApplyTransformation, handleOpenHelp]);
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<div className="workspace-grid">
|
||||||
<EditorPanel
|
<EditorPanel
|
||||||
ref={xmlInputEditorRef}
|
ref={xmlInputEditorRef}
|
||||||
@@ -443,7 +576,10 @@ const App: React.FC = () => {
|
|||||||
fileName={state.xsltCode.fileName}
|
fileName={state.xsltCode.fileName}
|
||||||
dirty={state.xsltCode.dirty}
|
dirty={state.xsltCode.dirty}
|
||||||
diagnostics={xsltDiagnostics}
|
diagnostics={xsltDiagnostics}
|
||||||
onChange={(value) => updateDocumentText('xsltCode', value)}
|
onChange={(value) => {
|
||||||
|
invalidateStylesheetTrust();
|
||||||
|
updateDocumentText('xsltCode', value);
|
||||||
|
}}
|
||||||
onOpen={() => void handleOpenFile('xsltCode')}
|
onOpen={() => void handleOpenFile('xsltCode')}
|
||||||
onSave={() => void handleSaveFile('xsltCode')}
|
onSave={() => void handleSaveFile('xsltCode')}
|
||||||
onUndo={() => xsltEditorRef.current?.undo()}
|
onUndo={() => xsltEditorRef.current?.undo()}
|
||||||
@@ -487,10 +623,13 @@ const App: React.FC = () => {
|
|||||||
selectedEngine={state.selectedEngine}
|
selectedEngine={state.selectedEngine}
|
||||||
onEngineChange={handleEngineChange}
|
onEngineChange={handleEngineChange}
|
||||||
onApplyTransformation={() => void handleApplyTransformation()}
|
onApplyTransformation={() => void handleApplyTransformation()}
|
||||||
|
onTrustStylesheet={() => void handleTrustStylesheet()}
|
||||||
onMoveOutputToInput={() => void handleMoveOutputToInput()}
|
onMoveOutputToInput={() => void handleMoveOutputToInput()}
|
||||||
onValidate={handleValidate}
|
onValidate={handleValidate}
|
||||||
onReset={() => void handleReset()}
|
onReset={() => void handleReset()}
|
||||||
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
|
prettifyOutputAfterTransform={
|
||||||
|
state.options.prettifyOutputAfterTransform
|
||||||
|
}
|
||||||
onPrettifyOutputAfterTransformChange={
|
onPrettifyOutputAfterTransformChange={
|
||||||
handlePrettifyOutputAfterTransformChange
|
handlePrettifyOutputAfterTransformChange
|
||||||
}
|
}
|
||||||
@@ -499,6 +638,7 @@ const App: React.FC = () => {
|
|||||||
handleAskBeforeOverwritingOutputChange
|
handleAskBeforeOverwritingOutputChange
|
||||||
}
|
}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
stylesheetTrusted={trustedStylesheet !== null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{statusMessage && <div className="status-banner">{statusMessage}</div>}
|
{statusMessage && <div className="status-banner">{statusMessage}</div>}
|
||||||
@@ -520,7 +660,6 @@ const App: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<section className="trace-panel card">
|
<section className="trace-panel card">
|
||||||
<div className="trace-header">
|
<div className="trace-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -586,13 +725,8 @@ const App: React.FC = () => {
|
|||||||
{dialog?.content}
|
{dialog?.content}
|
||||||
</ActionDialog>
|
</ActionDialog>
|
||||||
|
|
||||||
{helpOpen && (
|
{helpOpen && <HelpDialog open={helpOpen} onClose={handleCloseHelp} />}
|
||||||
<HelpDialog
|
</AppShell>
|
||||||
open={helpOpen}
|
|
||||||
onClose={handleCloseHelp}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
<div className="help-dialog-header">
|
<div className="help-dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>XSLT tools help</h2>
|
<h2>XSLT tools help</h2>
|
||||||
<p>Local-only XML/XSLT transformation workbench.</p>
|
<p>Browser XML/XSLT transformation workbench.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="icon-button" onClick={onClose}>
|
<button type="button" className="icon-button" onClick={onClose}>
|
||||||
×
|
×
|
||||||
@@ -32,9 +32,25 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
|||||||
<h3>Transformation engine</h3>
|
<h3>Transformation engine</h3>
|
||||||
<p>
|
<p>
|
||||||
The first version used the browser-native <code>XSLTProcessor</code>
|
The first version used the browser-native <code>XSLTProcessor</code>
|
||||||
, which is suitable for XSLT 1.0. This engine likely is to be retired
|
, which is suitable for XSLT 1.0. This engine likely is to be
|
||||||
in late 2026. For XSLT 3.0, a SaxonJS engine was added that fully runs
|
retired in late 2026. For XSLT 3.0, a SaxonJS engine was added that
|
||||||
in the browser (JS-only).
|
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>
|
</p>
|
||||||
|
|
||||||
<h3>Keyboard shortcuts</h3>
|
<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
|
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Z</kbd>: undo
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>:
|
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd>{' '}
|
||||||
redo
|
or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>: redo
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>F</kbd>: find in the active
|
<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>
|
<h3>Privacy</h3>
|
||||||
<p>
|
<p>
|
||||||
No upload code is included. Files are read locally by the browser
|
The application itself does not intentionally upload opened files
|
||||||
and the workspace is stored in your browser storage.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
selectedEngine: TransformEngineId;
|
||||||
onEngineChange: (engine: TransformEngineId) => void;
|
onEngineChange: (engine: TransformEngineId) => void;
|
||||||
onApplyTransformation: () => void;
|
onApplyTransformation: () => void;
|
||||||
|
onTrustStylesheet: () => void;
|
||||||
onMoveOutputToInput: () => void;
|
onMoveOutputToInput: () => void;
|
||||||
onValidate: () => void;
|
onValidate: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@@ -14,12 +15,14 @@ interface ToolbarProps {
|
|||||||
askBeforeOverwritingOutput: boolean;
|
askBeforeOverwritingOutput: boolean;
|
||||||
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
|
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
stylesheetTrusted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
selectedEngine,
|
selectedEngine,
|
||||||
onEngineChange,
|
onEngineChange,
|
||||||
onApplyTransformation,
|
onApplyTransformation,
|
||||||
|
onTrustStylesheet,
|
||||||
onMoveOutputToInput,
|
onMoveOutputToInput,
|
||||||
onValidate,
|
onValidate,
|
||||||
onReset,
|
onReset,
|
||||||
@@ -28,6 +31,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
askBeforeOverwritingOutput = true,
|
askBeforeOverwritingOutput = true,
|
||||||
onAskBeforeOverwritingOutputChange,
|
onAskBeforeOverwritingOutputChange,
|
||||||
busy = false,
|
busy = false,
|
||||||
|
stylesheetTrusted,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<section className="toolbar card" aria-label="Workbench actions">
|
<section className="toolbar card" aria-label="Workbench actions">
|
||||||
@@ -36,11 +40,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="button primary"
|
className="button primary"
|
||||||
onClick={onApplyTransformation}
|
onClick={onApplyTransformation}
|
||||||
disabled={busy}
|
disabled={busy || !stylesheetTrusted}
|
||||||
title="Apply transformation (Ctrl/Cmd+Enter)"
|
title={
|
||||||
|
stylesheetTrusted
|
||||||
|
? 'Apply transformation (Ctrl/Cmd+Enter)'
|
||||||
|
: 'Review and trust this exact stylesheet before running it'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{busy ? 'Transforming…' : 'Apply transformation'}
|
{busy ? 'Transforming…' : 'Apply transformation'}
|
||||||
</button>
|
</button>
|
||||||
|
{!stylesheetTrusted && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button danger-outline"
|
||||||
|
onClick={onTrustStylesheet}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Review & trust stylesheet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
@@ -61,6 +79,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="toolbar-row toolbar-options">
|
||||||
<label className="toggle-option">
|
<label className="toggle-option">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||||
|
import '@add-ideas/toolbox-shell-react/styles.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
|||||||
@@ -35,69 +35,28 @@ kbd {
|
|||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-root {
|
.xslt-app-shell {
|
||||||
min-height: 100vh;
|
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 {
|
.xslt-app-shell > .toolbox-shell__main {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
background: #111827;
|
|
||||||
color: #e5e7eb;
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 0.75rem;
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo {
|
|
||||||
font-size: 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header h1 {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header small {
|
|
||||||
color: #9ca3af;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-version {
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #374151;
|
|
||||||
color: #d1d5db;
|
|
||||||
padding: 0.4rem 0.7rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-help-button {
|
.app-help-button {
|
||||||
border: 1px solid #4b5563;
|
border: 1px solid var(--toolbox-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #e5e7eb;
|
color: var(--toolbox-text);
|
||||||
padding: 0.35rem 0.65rem;
|
padding: 0.35rem 0.65rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -107,17 +66,10 @@ kbd {
|
|||||||
|
|
||||||
.app-help-button:hover,
|
.app-help-button:hover,
|
||||||
.app-help-button:focus-visible {
|
.app-help-button:focus-visible {
|
||||||
background: #374151;
|
background: var(--toolbox-background);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -207,6 +159,26 @@ kbd {
|
|||||||
font-size: 0.85rem;
|
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 {
|
.toggle-option {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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> {
|
export async function createSha256Hash(text: string): Promise<string> {
|
||||||
if (!window.crypto?.subtle) {
|
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);
|
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));
|
const bytes = Array.from(new Uint8Array(digest));
|
||||||
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
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;
|
inSituPut: (key: unknown, value: unknown[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
|
|
||||||
|
|
||||||
let saxonLoadPromise: Promise<void> | null = null;
|
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 = {
|
type SaxonPrivateRuntime = {
|
||||||
getPlatform?: () => {
|
getPlatform?: () => {
|
||||||
resource?: (name: string) => unknown;
|
resource?: (name: string) => unknown;
|
||||||
@@ -60,8 +66,9 @@ async function ensureSaxonLoaded(): Promise<void> {
|
|||||||
if (window.SaxonJS) return;
|
if (window.SaxonJS) return;
|
||||||
|
|
||||||
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
|
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
|
||||||
|
const saxonScriptUrl = resolveSaxonScriptUrl();
|
||||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||||
`script[src="${SAXON_SCRIPT_URL}"]`
|
`script[src="${saxonScriptUrl}"]`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingScript) {
|
if (existingScript) {
|
||||||
@@ -75,7 +82,7 @@ async function ensureSaxonLoaded(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = SAXON_SCRIPT_URL;
|
script.src = saxonScriptUrl;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.onload = () => resolve();
|
script.onload = () => resolve();
|
||||||
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));
|
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,
|
TransformRequest,
|
||||||
TransformResult,
|
TransformResult,
|
||||||
} from './transformTypes';
|
} 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> = {
|
const engines: Record<TransformEngineId, TransformEngine> = {
|
||||||
'saxon-js-dynamic': saxonJsDynamicEngine,
|
'saxon-js-dynamic': saxonJsDynamicEngine,
|
||||||
@@ -18,10 +33,30 @@ export function getTransformEngine(id: TransformEngineId): TransformEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runTransformation(
|
export async function runTransformation(
|
||||||
request: TransformRequest
|
request: TransformRequest,
|
||||||
|
authorization: TransformationAuthorization
|
||||||
): Promise<TransformResult> {
|
): 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);
|
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);
|
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,
|
availableTransformEngines,
|
||||||
runTransformation,
|
runTransformation,
|
||||||
} from '../src/transform/transformService';
|
} 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"?>
|
const xmlText = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<library>
|
<library>
|
||||||
@@ -36,6 +41,17 @@ const xslt30Text = `<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1
|
|||||||
</xsl:template>
|
</xsl:template>
|
||||||
</xsl:stylesheet>`;
|
</xsl:stylesheet>`;
|
||||||
|
|
||||||
|
async function runTrustedTransformation(request: TransformRequest) {
|
||||||
|
const revision = 1;
|
||||||
|
return runTransformation(request, {
|
||||||
|
trustedStylesheet: {
|
||||||
|
hash: await createSha256Hash(request.xsltText),
|
||||||
|
revision,
|
||||||
|
},
|
||||||
|
currentRevision: revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (window.SaxonJS) return;
|
if (window.SaxonJS) return;
|
||||||
|
|
||||||
@@ -59,6 +75,12 @@ describe('transform engine registry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('SaxonJS dynamic compiler', () => {
|
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 () => {
|
it('compiles raw XSLT text to SEF JSON', async () => {
|
||||||
const sef = await compileXsltTextToSefJson(xslt30Text);
|
const sef = await compileXsltTextToSefJson(xslt30Text);
|
||||||
const parsed = JSON.parse(sef) as { N?: string; target?: string };
|
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 () => {
|
it('runs an XSLT 1.0 stylesheet', async () => {
|
||||||
const result = await runTransformation({
|
const result = await runTrustedTransformation({
|
||||||
xmlText,
|
xmlText,
|
||||||
xsltText: xslt10Text,
|
xsltText: xslt10Text,
|
||||||
engine: 'saxon-js-dynamic',
|
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 () => {
|
it('runs an XSLT 2.0 stylesheet with XPath 2.0 functions', async () => {
|
||||||
const result = await runTransformation({
|
const result = await runTrustedTransformation({
|
||||||
xmlText,
|
xmlText,
|
||||||
xsltText: xslt20Text,
|
xsltText: xslt20Text,
|
||||||
engine: 'saxon-js-dynamic',
|
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 () => {
|
it('runs an XSLT 3.0 stylesheet with xsl:mode on-no-match', async () => {
|
||||||
const result = await runTransformation({
|
const result = await runTrustedTransformation({
|
||||||
xmlText,
|
xmlText,
|
||||||
xsltText: xslt30Text,
|
xsltText: xslt30Text,
|
||||||
engine: 'saxon-js-dynamic',
|
engine: 'saxon-js-dynamic',
|
||||||
@@ -109,7 +131,7 @@ describe('SaxonJS dynamic compiler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns diagnostics for invalid XSLT', async () => {
|
it('returns diagnostics for invalid XSLT', async () => {
|
||||||
const result = await runTransformation({
|
const result = await runTrustedTransformation({
|
||||||
xmlText,
|
xmlText,
|
||||||
xsltText: '<xsl:stylesheet version="3.0">',
|
xsltText: '<xsl:stylesheet version="3.0">',
|
||||||
engine: 'saxon-js-dynamic',
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
95
todo.md
95
todo.md
@@ -3,50 +3,69 @@
|
|||||||
Near-term useful features:
|
Near-term useful features:
|
||||||
|
|
||||||
1. Engine UX polish
|
1. Engine UX polish
|
||||||
- Show “SaxonJS loading…” when the large compiler file is first loaded.
|
|
||||||
- Show compile time vs transform time separately.
|
- Show “SaxonJS loading…” when the large compiler file is first loaded.
|
||||||
- Cache compiled SEF by stylesheet hash to avoid recompiling unchanged XSLT.
|
- Show compile time vs transform time separately.
|
||||||
|
- Cache compiled SEF by stylesheet hash to avoid recompiling unchanged XSLT.
|
||||||
|
|
||||||
2. Parameters
|
2. Parameters
|
||||||
- Add UI for xsl:param.
|
|
||||||
- Support string/number/boolean parameters first.
|
- Add UI for xsl:param.
|
||||||
- Later support XPath expressions.
|
- Support string/number/boolean parameters first.
|
||||||
|
- Later support XPath expressions.
|
||||||
|
|
||||||
3. Better diagnostics
|
3. Better diagnostics
|
||||||
- Convert SaxonJS compiler/runtime errors into editor diagnostics.
|
|
||||||
- Extract line/module info when available.
|
- Convert SaxonJS compiler/runtime errors into editor diagnostics.
|
||||||
- Jump from diagnostic to editor line.
|
- Extract line/module info when available.
|
||||||
|
- Jump from diagnostic to editor line.
|
||||||
|
|
||||||
4. Examples / templates
|
4. Examples / templates
|
||||||
- Identity transform.
|
|
||||||
- Rename elements.
|
- Identity transform.
|
||||||
- Extract table.
|
- Rename elements.
|
||||||
- Namespace cleanup.
|
- Extract table.
|
||||||
- Grouping example.
|
- Namespace cleanup.
|
||||||
- XSLT 3.0 shallow-copy example.
|
- Grouping example.
|
||||||
|
- XSLT 3.0 shallow-copy example.
|
||||||
|
|
||||||
5. Project files
|
5. Project files
|
||||||
- Export/import a full local project JSON:
|
|
||||||
'''XML input
|
- Export/import a full local project JSON:
|
||||||
XSLT
|
'''XML input
|
||||||
output
|
XSLT
|
||||||
engine
|
output
|
||||||
examples/tests
|
engine
|
||||||
parameters'''
|
examples/tests
|
||||||
- No uploads needed.
|
parameters'''
|
||||||
|
- Use local import/export dialogs; keep executable-stylesheet privacy warnings.
|
||||||
|
|
||||||
6. Test cases inside the app
|
6. Test cases inside the app
|
||||||
- Multiple XML inputs per stylesheet.
|
|
||||||
- Expected output assertions.
|
- Multiple XML inputs per stylesheet.
|
||||||
- Diff expected vs actual.
|
- Expected output assertions.
|
||||||
- “Run all tests” button.
|
- Diff expected vs actual.
|
||||||
|
- “Run all tests” button.
|
||||||
|
|
||||||
7. Diff views
|
7. Diff views
|
||||||
- XML input vs output.
|
|
||||||
- Previous output vs current output.
|
- XML input vs output.
|
||||||
- Pretty XML diff.
|
- Previous output vs current output.
|
||||||
|
- Pretty XML diff.
|
||||||
|
|
||||||
8. Explain mode
|
8. Explain mode
|
||||||
- Start with static analysis:
|
|
||||||
'''template match → output node candidates'''
|
- Start with static analysis:
|
||||||
- Later add instrumentation around generated SEF or stylesheet templates.
|
'''template match → output node candidates'''
|
||||||
|
- Later add instrumentation around generated SEF or stylesheet templates.
|
||||||
|
|
||||||
9. Performance
|
9. Performance
|
||||||
- Web Worker for SaxonJS compile/transform.
|
|
||||||
- Prevent UI lockups on large XML.
|
- Web Worker for SaxonJS compile/transform.
|
||||||
- Progress indicator and cancellation if possible.
|
- Prevent UI lockups on large XML.
|
||||||
|
- Progress indicator and cancellation if possible.
|
||||||
|
|
||||||
10. Storage
|
10. Storage
|
||||||
- LocalStorage for small projects.
|
|
||||||
- IndexedDB for larger documents and histories.
|
- LocalStorage for small projects.
|
||||||
|
- IndexedDB for larger documents and histories.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
allowedHosts: ['xslttools.add-ideas.de'], // ← ADD THIS
|
allowedHosts: ['xslttools.add-ideas.de'], // ← ADD THIS
|
||||||
|
|||||||
Reference in New Issue
Block a user