6 Commits

39 changed files with 2138 additions and 337 deletions

3
.gitignore vendored
View File

@@ -91,6 +91,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
release/
# Gatsby files
.cache/
@@ -137,4 +138,4 @@ dist
.pnp.*
.DS_Store
chatgpt_continuation_chat.md
chatgpt_continuation_chat.md

1
.npmrc Normal file
View File

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

View File

@@ -1,44 +1,102 @@
# Changelog
## 0.4.3
### Changed
- Moved canonical repository, Toolbox schema, corresponding-source, and package-registry URLs from the personal `zemion` namespace to the `lotobo` LocalToolBox organization.
- Upgraded Toolbox Contract, React Shell, and Testkit dependencies to 0.2.3 from the organization-owned registry.
- Bumped the app/package version to 0.4.3.
## 0.4.2
### Changed
- Upgraded Toolbox Contract, React Shell, and Testkit dependencies to 0.2.2.
- Moved the Toolbox icon and `add·ideas Toolbox` label to the fixed far-left block and placed Help, the XSLT-specific Gitea source link, Apps, and Personalize in reverse order in the fixed right cluster while keeping the app title and tagline centered.
## 0.4.1
### Changed
- Upgraded Toolbox Contract, React Shell, and Testkit dependencies to 0.2.1.
- Adopted the stable shared top bar: the fixed left cluster contains Personalize, Apps, the XSLT-specific Gitea source link, and Help; the app title and one tagline remain centered; and the fixed far-right block contains the Toolbox icon and `add·ideas Toolbox` label.
## 0.4.0
### Added
- Added the unified Toolbox top bar with a centered app title, standard Help and Gitea source controls, Personalize, and Apps switching.
- Added persistent System, Light, and Dark appearance choices shared with the Toolbox Portal.
- Added a real dark CodeMirror configuration with dark editor surfaces, gutters, active lines, and syntax highlighting.
### Changed
- Upgraded Toolbox Contract, React Shell, and Testkit dependencies to 0.2.0.
- Replaced the app-specific Help and Source actions with the shared shell controls and manifest repository fallback.
- Converted workbench controls, cards, diagnostics, dialogs, tables, and editor chrome to semantic theme variables.
## 0.3.2
### Added
- Added a typed Toolbox Contract v1 application definition and generated `toolbox-app.json` manifest.
- Added the shared React application shell while retaining the XSLT-specific Help action.
- Added standalone and toolbox-mode integration coverage, including nested-path static deployment checks.
- Added deterministic, cross-time-zone release ZIP and SHA-256 generation with complete production dependency licence files.
- Added a session-only SHA-256/revision trust gate that disables both transformation engines until the exact stylesheet is reviewed and confirmed.
- Added conservative transformation input/output limits and a best-effort asynchronous deadline.
- Added a prominent Source action and corresponding-source notice for the 0.3.2 tag.
### Changed
- Made Vite build assets relocatable with a relative deployment base.
- Resolved the lazily loaded SaxonJS runtime relative to the deployed application path.
- Documented that the vendored SaxonJS 2.7 browser distribution is free to use under separate, non-open-source terms and is not part of the application's AGPL licensing.
- Classified stylesheet processing conservatively and documented that executable stylesheets may access browser or network resources.
- Documented that trusted SaxonJS can access same-site Toolbox data, send data over the network, or navigate away; the recommended portal CSP is defense in depth rather than containment.
- Switched Toolbox SDK dependencies from local workspace links to the public token-free package registry.
- Release packaging now refuses to overwrite same-version artifacts unless `--force` is explicit.
## 0.3.1
### Added
* Added global keyboard shortcut `Shift + Enter` to apply the current transformation.
* Added global keyboard shortcut `F1` to open the help dialog.
* Added shortcut hints for transformation and help actions.
- Added global keyboard shortcut `Shift + Enter` to apply the current transformation.
- Added global keyboard shortcut `F1` to open the help dialog.
- Added shortcut hints for transformation and help actions.
### Changed
* Bumped application version to `0.3.1`.
- Bumped application version to `0.3.1`.
## 0.3.0
### Added
* Added browser-side SaxonJS dynamic compilation support.
* Added support for raw XSLT 1.0, 2.0, and 3.0 transformations through the SaxonJS dynamic engine.
* Added dynamic XSLT-to-SEF compilation in the browser using the full `SaxonJS2.js` browser artifact.
* Added lazy loading for the SaxonJS browser compiler/runtime.
* Added tests covering XSLT 1.0, XSLT 2.0, and XSLT 3.0 transformation scenarios.
* Added project quality commands for type checking, linting, formatting, testing, building, and combined checks.
- Added browser-side SaxonJS dynamic compilation support.
- Added support for raw XSLT 1.0, 2.0, and 3.0 transformations through the SaxonJS dynamic engine.
- Added dynamic XSLT-to-SEF compilation in the browser using the full `SaxonJS2.js` browser artifact.
- Added lazy loading for the SaxonJS browser compiler/runtime.
- Added tests covering XSLT 1.0, XSLT 2.0, and XSLT 3.0 transformation scenarios.
- Added project quality commands for type checking, linting, formatting, testing, building, and combined checks.
### Changed
* Made `saxon-js-dynamic` the primary SaxonJS-based engine for raw XML + raw XSLT workflows.
* Removed the earlier failed npm-bundled SaxonJS approach.
* Removed the SEF-only SaxonJS placeholder from the main workflow.
* Excluded vendored SaxonJS files from linting and formatting.
* Cleaned up debug-only code and unused declarations.
* Improved transformation engine registry and test coverage.
- Made `saxon-js-dynamic` the primary SaxonJS-based engine for raw XML + raw XSLT workflows.
- Removed the earlier failed npm-bundled SaxonJS approach.
- Removed the SEF-only SaxonJS placeholder from the main workflow.
- Excluded vendored SaxonJS files from linting and formatting.
- Cleaned up debug-only code and unused declarations.
- Improved transformation engine registry and test coverage.
### Fixed
* Fixed SaxonJS browser integration by using the full `SaxonJS2.js` artifact instead of `SaxonJS2.rt.js`.
* Fixed dynamic compiler setup by providing a synthetic stylesheet base URI.
* Fixed SaxonJS static parameter handling by passing an `XdmMap` instead of a plain JavaScript object.
* Fixed browser compiler result extraction by reading `checkedOptions.principalResult`.
* Removed duplicate XML output replacement after transformations.
- Fixed SaxonJS browser integration by using the full `SaxonJS2.js` artifact instead of `SaxonJS2.rt.js`.
- Fixed dynamic compiler setup by providing a synthetic stylesheet base URI.
- Fixed SaxonJS static parameter handling by passing an `XdmMap` instead of a plain JavaScript object.
- Fixed browser compiler result extraction by reading `checkedOptions.principalResult`.
- Removed duplicate XML output replacement after transformations.
## 0.2.0

View File

@@ -1,6 +1,12 @@
# XSLT tools
Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded; the app uses browser APIs, LocalStorage, and local file open/save dialogs where available.
Browser-based MVP for testing XML/XSLT transformations. The application itself
does not intentionally upload opened files, and it uses LocalStorage plus local
file dialogs where available. XSLT is executable code, however: a trusted
stylesheet may access browser or network resources, so processing is not
described as guaranteed local-only.
The application is independently deployable and also implements Toolbox Contract v1. The shared top bar keeps the app title centered and provides Help, its Gitea source link, persistent System/Light/Dark personalization, and app selection. When launched with a same-origin `?toolbox=<catalog URL>` context, the Apps menu includes the catalogue tools; without that context, the same build runs standalone.
## Features in this MVP
@@ -11,12 +17,16 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded
- CodeMirror editors with:
- line numbers
- XML syntax highlighting
- theme-aware light and dark editor, gutter, and highlighting palettes
- editor undo/redo
- cut/copy/paste helpers
- find support via CodeMirror shortcuts
- Transformation engines:
- SaxonJS 2.7 as the default engine for XSLT 1.0/2.0/3.0-style workflows
- Browser-native `XSLTProcessor` as a lightweight XSLT 1.0 fallback
- A mandatory SHA-256 trust confirmation for the exact executable stylesheet;
every edit, replacement, format, or reset revokes trust for both engines
- Defense-in-depth input/output limits and a best-effort asynchronous deadline
- XML and XSLT well-formedness checks
- Basic XSLT root/version checks
- Snippet toolbox for common XSLT constructs
@@ -27,12 +37,34 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded
- Confirmation dialog before destructive overwrites
- LocalStorage persistence
- Approximate “explain transformation” table after a run
- Shared toolbox shell with standalone fallback, unified Help/source controls, personalization, and application switching
- Relocatable static output for deployment at root or nested paths
## SaxonJS note
SaxonJS is bundled through the `saxon-js` npm package and loaded lazily when the SaxonJS engine runs. The browser build may show Vite warnings about Node modules such as `fs`, `path`, or `stream` being externalized. Those warnings come from the package containing Node.js support as well; the app does not use file-based SaxonJS APIs in the browser.
SaxonJS 2.7 is vendored as the unmodified browser distribution file at `public/vendor/saxon/SaxonJS2.js` and loaded lazily when the SaxonJS engine runs. It is not bundled from the npm package during the application build.
SaxonJS itself is free to use but not open source. Check `node_modules/saxon-js/LICENSE.txt` after installation before distributing a packaged version.
SaxonJS is available free of charge but is not open source. Its separate redistribution terms are preserved at `public/vendor/saxon/LICENSE.txt`. Release archives include that notice as `LICENSES/SaxonJS-LICENSE.txt` alongside `LICENSES/xslt-tools-LICENSE.txt` for the AGPL-3.0-only application; do not describe the complete archive as AGPL-only.
## Executable stylesheet security
Treat every stylesheet like executable code. Transformation is disabled until
the user reviews a warning and trusts the SHA-256 fingerprint of the exact
current stylesheet. Trust is session-only and tied to both the content hash and
an edit revision; changing and then reverting text still requires another
confirmation. The same gate covers SaxonJS and native `XSLTProcessor`.
The application enforces conservative input/output limits and a best-effort
asynchronous deadline, but transformations are not isolated from the browser or
network and synchronous CPU exhaustion cannot be forcibly stopped. A trusted
SaxonJS stylesheet may read data stored by other Toolbox apps on the same site,
including PDF workspaces and portal preferences, then send it over the network
or navigate the page away.
When served with the recommended portal CSP, cross-origin fetches and dynamic
JavaScript evaluation are restricted without granting `unsafe-eval`. CSP is
defense in depth, not complete containment. Only execute stylesheets from trusted
sources.
## Run locally
@@ -50,6 +82,49 @@ npm run build
npm run preview
```
Vite uses `base: './'`, so a single build can be hosted at an unknown nested path such as `/apps/xslt/`. The SaxonJS runtime is resolved from that application base as well.
## Toolbox manifest
`src/toolboxApp.ts` is the typed source for both the shared shell and the published `public/toolbox-app.json` manifest. Keep the generated file current with:
```bash
npm run manifest:generate
npm run manifest:check
```
Toolbox mode is contextual rather than a separate build:
```text
/apps/xslt/ standalone
/apps/xslt/?toolbox=/toolbox.catalog.json toolbox mode
```
An unavailable or invalid catalogue falls back to standalone operation.
## Appearance
The stable shared top bar keeps the Toolbox icon and `add·ideas Toolbox` label in a fixed far-left block, the app title and one tagline centered, and Help, the XSLT source link, Apps, and Personalize in a fixed right cluster. Use **Personalize** to select System, Light, or Dark. The choice is stored only in this browser using the same preference record as the Toolbox Portal, so it follows you between current Toolbox apps on the same origin. System mode follows the operating-system color preference. CodeMirror is reconfigured when the effective palette changes, including its editor background, gutters, active line, and XML syntax colors.
## Release archive
After a successful build, create the deterministic application ZIP and SHA-256 sidecar with:
```bash
npm run release
```
The command refuses to overwrite a same-version archive. Use
`npm run release -- --force` only when intentionally replacing it.
The archive contains the static app and `toolbox-app.json`, plus `CHANGELOG.md`,
`SOURCE.md` with the corresponding tagged source link,
`THIRD_PARTY_NOTICES.md`, the application licence at
`LICENSES/xslt-tools-LICENSE.txt`, the separate vendored SaxonJS licence, and
licence/notice files for every installed production npm dependency below
`LICENSES/npm/`. Generated artifacts are written below `release/` and are not
committed.
## Suggested next steps
1. Add parameter handling for `xsl:param`.
@@ -63,12 +138,13 @@ npm run preview
```text
src/
├── components/ # Layout, toolbar, editor panels, dialogs
├── components/ # Toolbar, editor panels, dialogs, and app-specific actions
├── editor/ # CodeMirror integration and XSLT snippets
├── file/ # Local file open/save helpers
├── transform/ # Transform engine abstraction, SaxonJS, native engine
├── validation/ # XML/XSLT validation helpers
── workspace/ # LocalStorage-backed workbench state
── workspace/ # LocalStorage-backed workbench state
└── toolboxApp.ts # Toolbox manifest and shared-shell application definition
```
## Development checks
@@ -89,6 +165,9 @@ npm run format:check # Prettier check
npm run format # Prettier write
npm run test # Vitest/jsdom test suite
npm run build # Production build
npm run smoke:static # Nested-path static build smoke test
npm run smoke:toolbox # Contract, asset, standalone, and contextual checks
npm run release # Deterministic ZIP and SHA-256 release artifacts
npm run clean # Remove generated build/cache files
```
@@ -98,7 +177,8 @@ The vendored SaxonJS browser file is intentionally excluded from ESLint and Pret
The app currently exposes two engines:
- **SaxonJS 2 dynamic XSLT 3.0**: default engine. It lazy-loads `public/vendor/saxon/SaxonJS2.js`, compiles raw XSLT text to SEF in the browser, then executes the generated SEF locally.
- **SaxonJS 2 dynamic XSLT 3.0**: default engine. It loads the vendored `public/vendor/saxon/SaxonJS2.js`, compiles raw XSLT text to SEF in the browser, then executes the generated SEF after explicit trust confirmation.
- **Native browser XSLTProcessor**: XSLT 1.0 fallback using the browser implementation.
The SaxonJS runtime is loaded only when a Saxon transformation is requested, so the initial app shell does not block on the large compiler/runtime file.
The SaxonJS runtime is loaded only for a trusted Saxon transformation, so the
initial app shell does not block on the large compiler/runtime file.

10
SOURCE.md Normal file
View File

@@ -0,0 +1,10 @@
# Corresponding source
The corresponding application source for XSLT tools 0.4.3 is available at:
<https://git.add-ideas.de/lotobo/xslt-tools/src/tag/v0.4.3>
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.

18
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,18 @@
# 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.
- The CodeMirror packages, including the One Dark editor theme, are distributed
under the MIT licence; their installed licence files are included in release
archives.
- Runtime npm dependencies retain their own licences. Release archives include
the installed licence and notice files for every production dependency below
`LICENSES/npm/`, including the Apache-2.0 Toolbox Contract and React shell
packages.
The dependency set and exact versions are fixed by `package-lock.json`. Review
the accompanying licence files before redistributing the complete archive.

View File

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

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Browser-only XSLT tools for local XML and XSLT transformations."
content="Browser-based XSLT tools with explicit executable-stylesheet trust controls."
/>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

59
package-lock.json generated
View File

@@ -1,18 +1,22 @@
{
"name": "xsl-tools",
"version": "0.2.0",
"version": "0.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xsl-tools",
"version": "0.2.0",
"version": "0.4.3",
"license": "AGPL-3.0-only",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "^6.38.3",
"bootstrap-icons": "^1.11.3",
"codemirror": "^6.0.1",
@@ -20,6 +24,7 @@
"react-dom": "^19.2.6"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -40,6 +45,38 @@
"vitest": "^4.1.6"
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
},
"peerDependencies": {
"react": ">=18 <20",
"react-dom": ">=18 <20"
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
},
"bin": {
"toolbox-check": "dist/cli.js"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@@ -437,6 +474,18 @@
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
@@ -3647,9 +3696,9 @@
}
},
"node_modules/undici": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz",
"integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -1,11 +1,22 @@
{
"name": "xsl-tools",
"version": "0.3.1",
"version": "0.4.3",
"private": true,
"type": "module",
"description": "Browser-only XSLT workbench for local XML/XSLT transformations.",
"license": "AGPL-3.0-only",
"description": "Browser-based XSLT workbench with explicit executable-stylesheet trust controls.",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/lotobo/xslt-tools.git"
},
"homepage": "https://git.add-ideas.de/lotobo/xslt-tools",
"bugs": {
"url": "https://git.add-ideas.de/lotobo/xslt-tools/issues"
},
"scripts": {
"predev": "npm run manifest:generate",
"dev": "vite",
"prebuild": "npm run manifest:generate",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
@@ -14,16 +25,25 @@
"format:check": "prettier --check .",
"test": "vitest run --environment jsdom",
"test:watch": "vitest --environment jsdom",
"check": "npm run typecheck && npm run lint && npm run test && npm run build",
"manifest:generate": "node --experimental-strip-types scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node --experimental-strip-types scripts/generate-toolbox-manifest.mjs --check",
"smoke:static": "node scripts/smoke-static-build.mjs",
"smoke:toolbox": "toolbox-check dist",
"release:artifact": "npm run check && node scripts/package-release.mjs",
"release": "npm run release:artifact",
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run test && npm run build && npm run smoke:static && npm run smoke:toolbox",
"lint:fix": "eslint . --fix",
"clean": "rm -rf dist node_modules/.vite coverage"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "^6.38.3",
"bootstrap-icons": "^1.11.3",
"codemirror": "^6.0.1",
@@ -31,6 +51,7 @@
"react-dom": "^19.2.6"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",

View File

@@ -3,13 +3,13 @@
"short_name": "XSLTools",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"src": "./web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"src": "./web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
@@ -18,4 +18,4 @@
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
}

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

@@ -0,0 +1,34 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.xslt-tools",
"name": "XSLT tools",
"version": "0.4.3",
"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"],
"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/lotobo/xslt-tools",
"license": "AGPL-3.0-only"
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -9,14 +9,24 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
if (!open) return null;
return (
<div className="help-dialog-backdrop" role="dialog" aria-modal="true">
<div
className="help-dialog-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="xslt-help-title"
>
<div className="help-dialog-panel">
<div className="help-dialog-header">
<div>
<h2>XSLT tools help</h2>
<p>Local-only XML/XSLT transformation workbench.</p>
<h2 id="xslt-help-title">XSLT tools help</h2>
<p>Browser XML/XSLT transformation workbench.</p>
</div>
<button type="button" className="icon-button" onClick={onClose}>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="Close help"
>
×
</button>
</div>
@@ -32,9 +42,25 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h3>Transformation engine</h3>
<p>
The first version used the browser-native <code>XSLTProcessor</code>
, which is suitable for XSLT 1.0. This engine likely is to be retired
in late 2026. For XSLT 3.0, a SaxonJS engine was added that fully runs
in the browser (JS-only).
, which is suitable for XSLT 1.0. This engine likely is to be
retired in late 2026. For XSLT 3.0, a SaxonJS engine was added that
fully runs in the browser (JS-only).
</p>
<h3>Executable stylesheet safety</h3>
<p>
XSLT is executable code. Before either engine can run, you must
explicitly trust the SHA-256 fingerprint of the exact current
stylesheet. Every edit revokes that trust. Run only stylesheets from
sources you trust.
</p>
<p>
A trusted SaxonJS stylesheet may read same-site data belonging to
other Toolbox apps, including PDF workspaces and portal preferences,
then send it over the network or navigate this page away. Input and
output limits plus a best-effort asynchronous deadline reduce
accidental overload, but transformations are not isolated and
synchronous work cannot be forcibly stopped.
</p>
<h3>Keyboard shortcuts</h3>
@@ -43,8 +69,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Z</kbd>: undo
</li>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>:
redo
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd>{' '}
or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>: redo
</li>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>F</kbd>: find in the active
@@ -60,8 +86,12 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h3>Privacy</h3>
<p>
No upload code is included. Files are read locally by the browser
and the workspace is stored in your browser storage.
The application itself does not intentionally upload opened files
and stores the workspace in browser storage. A stylesheet you choose
to trust and execute may nevertheless access same-site data, request
external resources, or navigate away, so processing is
conservatively classified as mixed rather than guaranteed
local-only.
</p>
</div>
</div>

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import React, {
useRef,
} from 'react';
import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { EditorView, keymap, placeholder } from '@codemirror/view';
import {
defaultKeymap,
@@ -17,6 +17,7 @@ import {
} from '@codemirror/commands';
import { xml } from '@codemirror/lang-xml';
import { searchKeymap } from '@codemirror/search';
import { oneDark } from '@codemirror/theme-one-dark';
import type { CodeMirrorEditorHandle } from './editorTypes';
interface CodeMirrorEditorProps {
@@ -28,7 +29,7 @@ interface CodeMirrorEditorProps {
placeholderText?: string;
}
const editorTheme = EditorView.theme({
const editorLayoutTheme = EditorView.theme({
'&': {
minHeight: '100%',
height: '100%',
@@ -41,11 +42,61 @@ const editorTheme = EditorView.theme({
'.cm-content': {
minHeight: '100%',
},
'.cm-gutters': {
borderRight: '1px solid #e5e7eb',
},
});
const lightEditorTheme = EditorView.theme(
{
'&': {
backgroundColor: 'var(--app-editor-background)',
color: 'var(--app-editor-text)',
},
'.cm-gutters': {
backgroundColor: 'var(--app-editor-gutter)',
color: 'var(--app-muted)',
borderRight: '1px solid var(--app-border-soft)',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: 'var(--app-editor-active-line)',
},
},
{ dark: false }
);
const darkEditorTheme: Extension = [
oneDark,
EditorView.theme(
{
'&': {
backgroundColor: 'var(--app-editor-background)',
},
'.cm-gutters': {
backgroundColor: 'var(--app-editor-gutter)',
borderRight: '1px solid var(--app-border-soft)',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: 'var(--app-editor-active-line)',
},
},
{ dark: true }
),
];
function prefersDarkEditor(container: HTMLElement): boolean {
const themeRoot = container.closest<HTMLElement>('[data-toolbox-theme]');
const theme = themeRoot?.dataset.toolboxTheme;
if (theme === 'dark') return true;
if (theme === 'light') return false;
return (
globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
);
}
function editorColorTheme(dark: boolean): Extension {
return dark ? darkEditorTheme : lightEditorTheme;
}
const CodeMirrorEditor = forwardRef<
CodeMirrorEditorHandle,
CodeMirrorEditorProps
@@ -73,6 +124,11 @@ const CodeMirrorEditor = forwardRef<
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const themeCompartment = new Compartment();
let dark = prefersDarkEditor(container);
container.dataset.editorTheme = dark ? 'dark' : 'light';
const state = EditorState.create({
doc: value,
extensions: [
@@ -87,7 +143,8 @@ const CodeMirrorEditor = forwardRef<
]),
EditorState.readOnly.of(readOnly),
EditorView.lineWrapping,
editorTheme,
editorLayoutTheme,
themeCompartment.of(editorColorTheme(dark)),
placeholder(placeholderText ?? ''),
EditorView.updateListener.of((update) => {
if (update.docChanged && !syncingExternalValueRef.current) {
@@ -101,10 +158,37 @@ const CodeMirrorEditor = forwardRef<
],
});
const view = new EditorView({ state, parent: containerRef.current });
const view = new EditorView({ state, parent: container });
viewRef.current = view;
const applyColorTheme = () => {
const nextDark = prefersDarkEditor(container);
container.dataset.editorTheme = nextDark ? 'dark' : 'light';
if (nextDark === dark) return;
dark = nextDark;
view.dispatch({
effects: themeCompartment.reconfigure(editorColorTheme(nextDark)),
});
};
const themeRoot = container.closest<HTMLElement>('[data-toolbox-theme]');
const observer = themeRoot
? new MutationObserver(applyColorTheme)
: undefined;
observer?.observe(themeRoot as HTMLElement, {
attributes: true,
attributeFilter: ['data-toolbox-theme'],
});
const colorScheme = globalThis.matchMedia?.(
'(prefers-color-scheme: dark)'
);
colorScheme?.addEventListener('change', applyColorTheme);
return () => {
observer?.disconnect();
colorScheme?.removeEventListener('change', applyColorTheme);
view.destroy();
viewRef.current = null;
};

View File

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

View File

@@ -5,21 +5,19 @@
}
:root {
color-scheme: light;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background: #f3f4f6;
color: #111827;
background: #f6f7fb;
}
body {
margin: 0;
min-width: 1180px;
background: #f3f4f6;
background: #f6f7fb;
}
button,
@@ -35,84 +33,116 @@ kbd {
monospace;
}
.app-root {
min-height: 100vh;
.xslt-app-shell {
--app-surface: var(--toolbox-surface);
--app-surface-soft: var(--toolbox-surface-soft);
--app-text: var(--toolbox-text);
--app-text-secondary: #374151;
--app-muted: var(--toolbox-muted);
--app-border: var(--toolbox-border);
--app-border-soft: #e5e7eb;
--app-control: #e5e7eb;
--app-control-hover: #d1d5db;
--app-primary: #2563eb;
--app-primary-contrast: #ffffff;
--app-danger: #dc2626;
--app-danger-surface: #fff1f2;
--app-danger-text: #991b1b;
--app-danger-ink: #881337;
--app-danger-border: #fecaca;
--app-danger-border-strong: #fca5a5;
--app-success-surface: #f0fdf4;
--app-success-text: #14532d;
--app-success-border: #86efac;
--app-warning-surface: #fffbeb;
--app-warning-text: #92400e;
--app-info-surface: #eff6ff;
--app-info-text: #1e40af;
--app-focus: #bfdbfe;
--app-accent-border: #c7d2fe;
--app-backdrop: rgb(15 23 42 / 60%);
--app-card-shadow: 0 1px 3px rgb(15 23 42 / 12%);
--app-float-shadow: 0 12px 30px rgb(15 23 42 / 22%);
--app-dialog-shadow: 0 24px 60px rgb(15 23 42 / 40%);
--app-small-shadow: 0 1px 2px rgb(15 23 42 / 25%);
--app-focus-ring: rgb(37 99 235 / 22%);
--app-editor-background: #ffffff;
--app-editor-text: #111827;
--app-editor-gutter: #f9fafb;
--app-editor-active-line: rgb(37 99 235 / 8%);
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
background: #111827;
color: #e5e7eb;
padding: 0.65rem 1rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4);
[data-toolbox-theme='dark'].xslt-app-shell {
--app-text-secondary: #c8d1e4;
--app-border-soft: #303a50;
--app-control: #293247;
--app-control-hover: #37435c;
--app-primary: #a9b7ff;
--app-primary-contrast: #11182a;
--app-danger: #fb7185;
--app-danger-surface: #3b1d27;
--app-danger-text: #fecdd3;
--app-danger-ink: #fecdd3;
--app-danger-border: #7f3344;
--app-danger-border-strong: #a84457;
--app-success-surface: #123424;
--app-success-text: #bbf7d0;
--app-success-border: #23734a;
--app-warning-surface: #3a290e;
--app-warning-text: #fde68a;
--app-info-surface: #172554;
--app-info-text: #bfdbfe;
--app-focus: #7dd3fc;
--app-accent-border: #4c5c91;
--app-backdrop: rgb(0 0 0 / 72%);
--app-card-shadow: 0 1px 4px rgb(0 0 0 / 32%);
--app-float-shadow: 0 14px 34px rgb(0 0 0 / 46%);
--app-dialog-shadow: 0 24px 64px rgb(0 0 0 / 58%);
--app-small-shadow: 0 1px 2px rgb(0 0 0 / 45%);
--app-focus-ring: rgb(125 211 252 / 24%);
--app-editor-background: #20252e;
--app-editor-text: #abb2bf;
--app-editor-gutter: #171b22;
--app-editor-active-line: rgb(255 255 255 / 6%);
}
.app-header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
@media (prefers-color-scheme: dark) {
[data-toolbox-theme='system'].xslt-app-shell {
--app-text-secondary: #c8d1e4;
--app-border-soft: #303a50;
--app-control: #293247;
--app-control-hover: #37435c;
--app-primary: #a9b7ff;
--app-primary-contrast: #11182a;
--app-danger: #fb7185;
--app-danger-surface: #3b1d27;
--app-danger-text: #fecdd3;
--app-danger-ink: #fecdd3;
--app-danger-border: #7f3344;
--app-danger-border-strong: #a84457;
--app-success-surface: #123424;
--app-success-text: #bbf7d0;
--app-success-border: #23734a;
--app-warning-surface: #3a290e;
--app-warning-text: #fde68a;
--app-info-surface: #172554;
--app-info-text: #bfdbfe;
--app-focus: #7dd3fc;
--app-accent-border: #4c5c91;
--app-backdrop: rgb(0 0 0 / 72%);
--app-card-shadow: 0 1px 4px rgb(0 0 0 / 32%);
--app-float-shadow: 0 14px 34px rgb(0 0 0 / 46%);
--app-dialog-shadow: 0 24px 64px rgb(0 0 0 / 58%);
--app-small-shadow: 0 1px 2px rgb(0 0 0 / 45%);
--app-focus-ring: rgb(125 211 252 / 24%);
--app-editor-background: #20252e;
--app-editor-text: #abb2bf;
--app-editor-gutter: #171b22;
--app-editor-active-line: rgb(255 255 255 / 6%);
}
}
.app-header-title {
display: flex;
align-items: center;
gap: 0.65rem;
}
.app-logo {
font-size: 1.35rem;
}
.app-header h1 {
font-size: 1.05rem;
margin: 0;
}
.app-header small {
color: #9ca3af;
font-size: 0.8rem;
}
.app-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-version {
border-radius: 999px;
background: #374151;
color: #d1d5db;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.app-help-button {
border: 1px solid #4b5563;
border-radius: 999px;
background: transparent;
color: #e5e7eb;
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
line-height: 1;
}
.app-help-button:hover,
.app-help-button:focus-visible {
background: #374151;
outline: none;
}
.app-main {
padding: 0.75rem;
.xslt-app-shell > .toolbox-shell__main {
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -120,9 +150,9 @@ kbd {
.card {
width: 100%;
background: #ffffff;
background: var(--app-surface);
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
box-shadow: var(--app-card-shadow);
}
.button,
@@ -150,32 +180,32 @@ kbd {
}
.button.primary {
background: #2563eb;
color: #ffffff;
background: var(--app-primary);
color: var(--app-primary-contrast);
}
.button.secondary {
background: #e5e7eb;
color: #111827;
background: var(--app-control);
color: var(--app-text);
}
.button.danger {
background: #dc2626;
color: #ffffff;
background: var(--app-danger);
color: var(--app-primary-contrast);
}
.button.danger-outline {
border: 1px solid #fecaca;
background: #fff1f2;
color: #991b1b;
border: 1px solid var(--app-danger-border);
background: var(--app-danger-surface);
color: var(--app-danger-text);
}
.icon-button {
width: 1.9rem;
height: 1.9rem;
border-radius: 999px;
background: #e5e7eb;
color: #111827;
background: var(--app-control);
color: var(--app-text);
font-size: 1.2rem;
line-height: 1;
display: inline-flex;
@@ -203,10 +233,30 @@ kbd {
.toolbar-options {
align-items: center;
color: #374151;
color: var(--app-text-secondary);
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: var(--app-danger-border-strong);
background: var(--app-danger-surface);
color: var(--app-danger-ink);
}
.stylesheet-trust-status.is-trusted {
border-color: var(--app-success-border);
background: var(--app-success-surface);
color: var(--app-success-text);
}
.toggle-option {
display: inline-flex;
align-items: center;
@@ -227,7 +277,7 @@ kbd {
width: 2.35rem;
height: 1.25rem;
border-radius: 999px;
background: #d1d5db;
background: var(--app-control-hover);
transition:
background 0.15s ease,
box-shadow 0.15s ease;
@@ -241,13 +291,13 @@ kbd {
width: 0.91rem;
height: 0.91rem;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.25);
background: var(--app-surface);
box-shadow: var(--app-small-shadow);
transition: transform 0.15s ease;
}
.toggle-option input:checked + .toggle-slider {
background: #2563eb;
background: var(--app-primary);
}
.toggle-option input:checked + .toggle-slider::before {
@@ -255,12 +305,12 @@ kbd {
}
.toggle-option input:focus-visible + .toggle-slider {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.22);
box-shadow: 0 0 0 3px var(--app-focus-ring);
}
.toolbar-meta {
justify-content: space-between;
color: #6b7280;
color: var(--app-muted);
font-size: 0.85rem;
}
@@ -269,24 +319,25 @@ kbd {
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #374151;
color: var(--app-text-secondary);
}
.toolbar-meta select {
min-width: 18rem;
padding: 0.35rem 0.5rem;
border: 1px solid #d1d5db;
border: 1px solid var(--app-border);
border-radius: 0.5rem;
background: #ffffff;
background: var(--app-surface);
color: var(--app-text);
}
.status-banner {
border-left: 4px solid #2563eb;
border-left: 4px solid var(--app-primary);
border-radius: 0.65rem;
background: #eff6ff;
color: #1e3a8a;
background: var(--app-info-surface);
color: var(--app-info-text);
padding: 0.65rem 0.8rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
box-shadow: var(--app-card-shadow);
}
.runtime-diagnostics {
@@ -326,15 +377,15 @@ kbd {
}
.editor-panel small {
color: #6b7280;
color: var(--app-muted);
font-size: 0.78rem;
}
.diagnostics-badge {
flex: 0 0 auto;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
background: var(--app-surface-soft);
color: var(--app-text-secondary);
padding: 0.3rem 0.55rem;
font-size: 0.75rem;
font-weight: 700;
@@ -346,9 +397,9 @@ kbd {
flex-wrap: wrap;
align-items: center;
padding: 0.25rem;
border: 1px solid #e5e7eb;
border: 1px solid var(--app-border-soft);
border-radius: 0.55rem;
background: #f9fafb;
background: var(--app-surface-soft);
}
.editor-tool-button {
@@ -356,7 +407,7 @@ kbd {
height: 1.9rem;
border-radius: 0.4rem;
background: transparent;
color: #374151;
color: var(--app-text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -370,7 +421,7 @@ kbd {
.editor-tool-button:hover:not(:disabled),
.editor-tool-button:focus-visible {
background: #e5e7eb;
background: var(--app-control);
outline: none;
}
@@ -379,14 +430,14 @@ kbd {
align-self: stretch;
min-height: 1.45rem;
margin: 0 0.2rem;
background: #d1d5db;
background: var(--app-border);
}
.mini-button,
.snippet-button {
border-radius: 0.4rem;
background: #f3f4f6;
color: #374151;
background: var(--app-surface-soft);
color: var(--app-text-secondary);
padding: 0.28rem 0.48rem;
font-size: 0.78rem;
font-weight: 600;
@@ -394,14 +445,14 @@ kbd {
.mini-button:hover,
.snippet-button:hover {
background: #e5e7eb;
background: var(--app-control-hover);
}
.editor-host {
overflow: hidden;
border: 1px solid #d1d5db;
border: 1px solid var(--app-border);
border-radius: 0.65rem;
background: #ffffff;
background: var(--app-editor-background);
}
.editor-host .cm-editor {
@@ -410,7 +461,7 @@ kbd {
}
.editor-host .cm-focused {
outline: 2px solid #bfdbfe;
outline: 2px solid var(--app-focus);
outline-offset: -2px;
}
@@ -440,15 +491,15 @@ kbd {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.55rem;
border: 1px solid #c7d2fe;
border: 1px solid var(--app-accent-border);
border-radius: 0.65rem;
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.22);
background: var(--app-surface);
box-shadow: var(--app-float-shadow);
}
.diagnostics-empty {
margin: 0;
color: #6b7280;
color: var(--app-muted);
font-size: 0.82rem;
}
@@ -476,18 +527,18 @@ kbd {
}
.diagnostic-error {
background: #fef2f2;
color: #991b1b;
background: var(--app-danger-surface);
color: var(--app-danger-text);
}
.diagnostic-warning {
background: #fffbeb;
color: #92400e;
background: var(--app-warning-surface);
color: var(--app-warning-text);
}
.diagnostic-info {
background: #eff6ff;
color: #1e40af;
background: var(--app-info-surface);
color: var(--app-info-text);
}
.trace-panel {
@@ -509,18 +560,18 @@ kbd {
.trace-header p {
margin: 0.2rem 0 0;
color: #6b7280;
color: var(--app-muted);
font-size: 0.86rem;
}
.trace-header small {
color: #6b7280;
color: var(--app-muted);
white-space: nowrap;
}
.trace-table-wrap {
overflow: auto;
border: 1px solid #e5e7eb;
border: 1px solid var(--app-border-soft);
border-radius: 0.65rem;
}
@@ -533,14 +584,14 @@ kbd {
.trace-table th,
.trace-table td {
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
border-bottom: 1px solid var(--app-border-soft);
text-align: left;
vertical-align: top;
}
.trace-table th {
background: #f9fafb;
color: #374151;
background: var(--app-surface-soft);
color: var(--app-text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -551,7 +602,7 @@ kbd {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(15, 23, 42, 0.6);
background: var(--app-backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -560,9 +611,9 @@ kbd {
.action-dialog-panel {
width: min(440px, 100%);
background: #ffffff;
background: var(--app-surface);
border-radius: 0.75rem;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
box-shadow: var(--app-dialog-shadow);
padding: 1rem;
display: flex;
flex-direction: column;
@@ -584,7 +635,7 @@ kbd {
}
.action-dialog-body {
color: #4b5563;
color: var(--app-text-secondary);
font-size: 0.9rem;
line-height: 1.45;
}
@@ -608,9 +659,9 @@ kbd {
width: min(900px, 100%);
max-height: min(88vh, 760px);
overflow: auto;
background: #ffffff;
background: var(--app-surface);
border-radius: 0.9rem;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
box-shadow: var(--app-dialog-shadow);
}
.help-dialog-header {
@@ -618,18 +669,18 @@ kbd {
top: 0;
z-index: 1;
padding: 1rem;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
background: var(--app-surface);
border-bottom: 1px solid var(--app-border-soft);
}
.help-dialog-header p {
margin: 0.2rem 0 0;
color: #6b7280;
color: var(--app-muted);
}
.help-dialog-content {
padding: 1rem;
color: #374151;
color: var(--app-text-secondary);
line-height: 1.5;
}
@@ -642,10 +693,10 @@ kbd {
}
kbd {
border: 1px solid #d1d5db;
border: 1px solid var(--app-border);
border-bottom-width: 2px;
border-radius: 0.3rem;
background: #f9fafb;
background: var(--app-surface-soft);
padding: 0.05rem 0.25rem;
font-size: 0.82em;
}

42
src/toolboxApp.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineToolboxApp } from '@add-ideas/toolbox-contract';
import { APP_VERSION } from './version.ts';
export const toolboxAppDefinition = {
$schema:
'https://git.add-ideas.de/lotobo/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'],
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/lotobo/xslt-tools',
license: 'AGPL-3.0-only',
},
} as const;
export const toolboxApp = defineToolboxApp(toolboxAppDefinition);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
export const APP_VERSION = '0.3.1';
export const APP_VERSION = '0.4.3';

View File

@@ -0,0 +1,48 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import CodeMirrorEditor from '../src/editor/CodeMirrorEditor';
const originalGetClientRects = Range.prototype.getClientRects;
beforeAll(() => {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
});
afterAll(() => {
Range.prototype.getClientRects = originalGetClientRects;
});
afterEach(cleanup);
describe('CodeMirror themes', () => {
it('reconfigures the editor when the Toolbox theme changes', async () => {
const onChange = vi.fn();
const { container, rerender } = render(
<div data-toolbox-theme="dark">
<CodeMirrorEditor value="<root/>" onChange={onChange} ariaLabel="XML" />
</div>
);
const editorHost = container.querySelector<HTMLElement>('.editor-host');
expect(editorHost?.dataset.editorTheme).toBe('dark');
expect(container.querySelector('.cm-editor')).toBeTruthy();
rerender(
<div data-toolbox-theme="light">
<CodeMirrorEditor value="<root/>" onChange={onChange} ariaLabel="XML" />
</div>
);
await waitFor(() => {
expect(editorHost?.dataset.editorTheme).toBe('light');
});
});
});

View File

@@ -0,0 +1,130 @@
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) =>
/^LICENSES\/npm\/codemirror--theme-one-dark-[^/]+\/LICENSE$/.test(name)
)
).toBe(true);
expect(names.some((name) => /\/react-[^/]+\/LICENSE$/.test(name))).toBe(
true
);
expect(names.some((name) => name.includes('toolbox-testkit'))).toBe(false);
});
it('refuses same-version overwrites unless force is explicit', () => {
const directory = mkdtempSync(join(tmpdir(), 'xslt-release-test-'));
const archiveTarget = join(directory, 'xslt-tools.zip');
const checksumTarget = join(directory, 'xslt-tools.sha256');
writeFileSync(archiveTarget, 'existing archive');
writeFileSync(checksumTarget, 'existing checksum');
try {
expect(() =>
writeReleaseFiles({
archive: Buffer.from('new archive'),
archiveTarget,
checksum: 'new checksum',
checksumTarget,
})
).toThrow(/Refusing to overwrite/);
expect(readFileSync(archiveTarget, 'utf8')).toBe('existing archive');
expect(readFileSync(checksumTarget, 'utf8')).toBe('existing checksum');
writeReleaseFiles({
archive: Buffer.from('new archive'),
archiveTarget,
checksum: 'new checksum',
checksumTarget,
force: true,
});
expect(readFileSync(archiveTarget, 'utf8')).toBe('new archive');
expect(readFileSync(checksumTarget, 'utf8')).toBe('new checksum');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it('rejects symlinks instead of following them into an archive', () => {
const directory = mkdtempSync(join(tmpdir(), 'xslt-release-tree-test-'));
const target = join(directory, 'target.txt');
const link = join(directory, 'linked.txt');
writeFileSync(target, 'must not be followed through a link');
symlinkSync(target, link);
try {
expect(() => listFiles(directory)).toThrow(/symlink or special/i);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});

View File

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

View File

@@ -0,0 +1,179 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, beforeEach, 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';
beforeEach(() => localStorage.clear());
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('shows the standard app controls when running standalone', () => {
const onHelp = vi.fn();
render(
<AppShell
app={toolboxApp}
contextOptions={{ location: 'https://example.test/deep/nested/app/' }}
helpAction={{ onClick: onHelp, label: 'Help' }}
>
<p>XSLT workbench</p>
</AppShell>
);
expect(screen.getByText('XSLT workbench')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Help' }));
expect(onHelp).toHaveBeenCalledOnce();
expect(
screen
.getByRole('link', { name: 'Source for XSLT tools on Gitea' })
.getAttribute('href')
).toBe('https://git.add-ideas.de/lotobo/xslt-tools');
expect(screen.getByText('Personalize')).toBeTruthy();
expect(screen.getByText('Apps')).toBeTruthy();
});
it('persists explicit light, dark, and system theme choices', () => {
const { container } = render(
<AppShell app={toolboxApp}>
<p>XSLT workbench</p>
</AppShell>
);
const shell = container.firstElementChild;
expect(shell?.getAttribute('data-toolbox-theme')).toBe('system');
fireEvent.click(screen.getByRole('button', { name: 'Personalize' }));
fireEvent.click(screen.getByRole('button', { name: 'Dark' }));
expect(shell?.getAttribute('data-toolbox-theme')).toBe('dark');
fireEvent.click(screen.getByRole('button', { name: 'Light' }));
expect(shell?.getAttribute('data-toolbox-theme')).toBe('light');
fireEvent.click(screen.getByRole('button', { name: 'System' }));
expect(shell?.getAttribute('data-toolbox-theme')).toBe('system');
expect(
localStorage.getItem('@add-ideas/toolbox-portal:v1:preferences')
).toContain('"theme":"system"');
});
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>
);
fireEvent.click(screen.getByRole('button', { name: 'Apps' }));
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: 'add·ideas Toolbox' })
.getAttribute('href')
).toBe('https://example.test/');
const pdfLink = screen.getByRole('link', { name: 'PDF Workbench' });
const pdfUrl = new URL(pdfLink.getAttribute('href') ?? '');
expect(pdfUrl.pathname).toBe('/apps/pdf/');
expect(pdfUrl.searchParams.get('toolbox')).toBe(
'https://example.test/toolbox.catalog.json'
);
expect(fetch).toHaveBeenCalledTimes(3);
await waitFor(() => {
expect(
screen
.getByRole('link', { name: 'XSLT tools' })
.getAttribute('aria-current')
).toBe('page');
});
});
});
function createCatalogFetch() {
return async (input: string | URL | Request): Promise<Response> => {
const url = new URL(
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url
);
const documents = new Map<string, unknown>([
['/toolbox.catalog.json', catalog],
['/apps/xslt/toolbox-app.json', toolboxApp],
['/apps/pdf/toolbox-app.json', pdfApp],
]);
const document = documents.get(url.pathname);
if (document === undefined) {
return {
ok: false,
status: 404,
json: async () => ({}),
} as Response;
}
return {
ok: true,
status: 200,
json: async () => document,
} as Response;
};
}

View File

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

View File

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

95
todo.md
View File

@@ -3,50 +3,69 @@
Near-term useful features:
1. Engine UX polish
- Show “SaxonJS loading…” when the large compiler file is first loaded.
- Show compile time vs transform time separately.
- Cache compiled SEF by stylesheet hash to avoid recompiling unchanged XSLT.
- Show “SaxonJS loading…” when the large compiler file is first loaded.
- Show compile time vs transform time separately.
- Cache compiled SEF by stylesheet hash to avoid recompiling unchanged XSLT.
2. Parameters
- Add UI for xsl:param.
- Support string/number/boolean parameters first.
- Later support XPath expressions.
- Add UI for xsl:param.
- Support string/number/boolean parameters first.
- Later support XPath expressions.
3. Better diagnostics
- Convert SaxonJS compiler/runtime errors into editor diagnostics.
- Extract line/module info when available.
- Jump from diagnostic to editor line.
- Convert SaxonJS compiler/runtime errors into editor diagnostics.
- Extract line/module info when available.
- Jump from diagnostic to editor line.
4. Examples / templates
- Identity transform.
- Rename elements.
- Extract table.
- Namespace cleanup.
- Grouping example.
- XSLT 3.0 shallow-copy example.
- Identity transform.
- Rename elements.
- Extract table.
- Namespace cleanup.
- Grouping example.
- XSLT 3.0 shallow-copy example.
5. Project files
- Export/import a full local project JSON:
'''XML input
XSLT
output
engine
examples/tests
parameters'''
- No uploads needed.
- Export/import a full local project JSON:
'''XML input
XSLT
output
engine
examples/tests
parameters'''
- Use local import/export dialogs; keep executable-stylesheet privacy warnings.
6. Test cases inside the app
- Multiple XML inputs per stylesheet.
- Expected output assertions.
- Diff expected vs actual.
- “Run all tests” button.
- Multiple XML inputs per stylesheet.
- Expected output assertions.
- Diff expected vs actual.
- “Run all tests” button.
7. Diff views
- XML input vs output.
- Previous output vs current output.
- Pretty XML diff.
- XML input vs output.
- Previous output vs current output.
- Pretty XML diff.
8. Explain mode
- Start with static analysis:
'''template match → output node candidates'''
- Later add instrumentation around generated SEF or stylesheet templates.
- Start with static analysis:
'''template match → output node candidates'''
- Later add instrumentation around generated SEF or stylesheet templates.
9. Performance
- Web Worker for SaxonJS compile/transform.
- Prevent UI lockups on large XML.
- Progress indicator and cancellation if possible.
- Web Worker for SaxonJS compile/transform.
- Prevent UI lockups on large XML.
- Progress indicator and cancellation if possible.
10. Storage
- LocalStorage for small projects.
- IndexedDB for larger documents and histories.
- LocalStorage for small projects.
- IndexedDB for larger documents and histories.

View File

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

View File

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