Compare commits
3 Commits
cff254448c
...
d5574a0a44
| Author | SHA1 | Date | |
|---|---|---|---|
| d5574a0a44 | |||
| 8f8a59e76c | |||
| 3193cc395c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,44 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
94
README.md
94
README.md
@@ -1,6 +1,12 @@
|
||||
# XSLT tools
|
||||
|
||||
Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded; the app uses browser APIs, LocalStorage, and local file open/save dialogs where available.
|
||||
Browser-based MVP for testing XML/XSLT transformations. The application itself
|
||||
does not intentionally upload opened files, and it uses LocalStorage plus local
|
||||
file dialogs where available. XSLT is executable code, however: a trusted
|
||||
stylesheet may access browser or network resources, so processing is not
|
||||
described as guaranteed local-only.
|
||||
|
||||
The application is independently deployable and also implements Toolbox Contract v1. 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
|
||||
|
||||
Use **Personalize** in the shared top bar 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
10
SOURCE.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding application source for XSLT tools 0.4.0 is available at:
|
||||
|
||||
<https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.4.0>
|
||||
|
||||
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
18
THIRD_PARTY_NOTICES.md
Normal 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.
|
||||
@@ -9,6 +9,12 @@ export default tseslint.config(
|
||||
{ ignores: ['dist', 'public/vendor/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['scripts/**/*.{js,mjs,cjs}'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Browser-only XSLT tools for local XML and XSLT transformations."
|
||||
content="Browser-based XSLT tools with explicit executable-stylesheet trust controls."
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
59
package-lock.json
generated
59
package-lock.json
generated
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"name": "xsl-tools",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "xsl-tools",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.0",
|
||||
"@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.0",
|
||||
"@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.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.0/toolbox-contract-0.2.0.tgz",
|
||||
"integrity": "sha512-up+wY+ft1L7TbXrflcTeTOwEdcFwHBWFlGFj8skQvcLy5z2mDBpd6/7PVQKDvDTkiODu2npviHm6AFglFw+17Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.0/toolbox-shell-react-0.2.0.tgz",
|
||||
"integrity": "sha512-qYR+OBXX8rfB+BYCsUbpiCzo2xK12SIPWVNXHre158Da5uT6usr8rRvp5qa5u2oQJ50PNzlXyXMyTeRp0Vz5wg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
"react-dom": ">=18 <20"
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.0/toolbox-testkit-0.2.0.tgz",
|
||||
"integrity": "sha512-+BSiWgS0F3vpjPgty9B4UtfdZTRbo97ZElb2BSb0ry57fS3cE7w7pfJW1Mzk/gV1/JChspw3JBY+4ZJDBhkiFw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.0"
|
||||
},
|
||||
"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": {
|
||||
|
||||
19
package.json
19
package.json
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "xsl-tools",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Browser-only XSLT workbench for local XML/XSLT transformations.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Browser-based XSLT workbench with explicit executable-stylesheet trust controls.",
|
||||
"scripts": {
|
||||
"predev": "npm run manifest:generate",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run manifest:generate",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -14,16 +17,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.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.0",
|
||||
"@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 +43,7 @@
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"short_name": "XSLTools",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"src": "./web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"src": "./web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
@@ -18,4 +18,4 @@
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
}
|
||||
|
||||
34
public/toolbox-app.json
Normal file
34
public/toolbox-app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.xslt-tools",
|
||||
"name": "XSLT tools",
|
||||
"version": "0.4.0",
|
||||
"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/zemion/xslt-tools",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
}
|
||||
33
scripts/generate-toolbox-manifest.mjs
Normal file
33
scripts/generate-toolbox-manifest.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { format } from 'prettier';
|
||||
import { toolboxAppDefinition } from '../src/toolboxApp.ts';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(join(repositoryRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
const manifestPath = join(repositoryRoot, 'public', 'toolbox-app.json');
|
||||
|
||||
if (toolboxAppDefinition.version !== packageJson.version) {
|
||||
throw new Error(
|
||||
`Version mismatch: package.json is ${packageJson.version}, while src/toolboxApp.ts is ${toolboxAppDefinition.version}.`
|
||||
);
|
||||
}
|
||||
|
||||
const output = await format(JSON.stringify(toolboxAppDefinition), {
|
||||
parser: 'json',
|
||||
});
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const existing = await readFile(manifestPath, 'utf8').catch(() => '');
|
||||
if (existing !== output) {
|
||||
throw new Error(
|
||||
'public/toolbox-app.json is out of date. Run `npm run manifest:generate`.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await writeFile(manifestPath, output);
|
||||
}
|
||||
26
scripts/package-release.d.mts
Normal file
26
scripts/package-release.d.mts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ReleaseEntry {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface DosDateTime {
|
||||
time: number;
|
||||
date: number;
|
||||
}
|
||||
|
||||
export function createDeterministicZip(
|
||||
entries: ReleaseEntry[]
|
||||
): Buffer<ArrayBuffer>;
|
||||
|
||||
export function collectReleaseEntries(): ReleaseEntry[];
|
||||
export function collectRuntimeDependencyLicenseEntries(): ReleaseEntry[];
|
||||
export function listFiles(directory: string): string[];
|
||||
export function toDosDateTime(date: Date): DosDateTime;
|
||||
export function writeReleaseFiles(options: {
|
||||
archive: Uint8Array;
|
||||
archiveTarget: string;
|
||||
checksum: string;
|
||||
checksumTarget: string;
|
||||
force?: boolean;
|
||||
}): void;
|
||||
export function packageRelease(options?: { force?: boolean }): void;
|
||||
346
scripts/package-release.mjs
Normal file
346
scripts/package-release.mjs
Normal file
@@ -0,0 +1,346 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const distDirectory = join(repositoryRoot, 'dist');
|
||||
const releaseDirectory = join(repositoryRoot, 'release');
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(repositoryRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
const artifactName = `xslt-tools-${packageJson.version}`;
|
||||
const archivePath = join(releaseDirectory, `${artifactName}.zip`);
|
||||
const checksumPath = join(releaseDirectory, `${artifactName}.sha256`);
|
||||
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||
|
||||
export function listFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true })
|
||||
.sort((left, right) => compareText(left.name, right.name))
|
||||
.flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return listFiles(path);
|
||||
if (entry.isFile()) return [path];
|
||||
throw new Error(
|
||||
`Refusing to package symlink or special filesystem entry: ${path}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function zipPath(path) {
|
||||
return path.split(sep).join('/');
|
||||
}
|
||||
|
||||
export function collectReleaseEntries() {
|
||||
if (!existsSync(join(distDirectory, 'index.html'))) {
|
||||
throw new Error('dist/index.html is missing. Run `npm run build` first.');
|
||||
}
|
||||
|
||||
if (!existsSync(join(distDirectory, 'toolbox-app.json'))) {
|
||||
throw new Error(
|
||||
'dist/toolbox-app.json is missing. Generate and build the toolbox manifest first.'
|
||||
);
|
||||
}
|
||||
|
||||
const entries = listFiles(distDirectory).map((path) => ({
|
||||
name: zipPath(relative(distDirectory, path)),
|
||||
data: readFileSync(path),
|
||||
}));
|
||||
|
||||
entries.push(
|
||||
{
|
||||
name: 'CHANGELOG.md',
|
||||
data: readFileSync(join(repositoryRoot, 'CHANGELOG.md')),
|
||||
},
|
||||
{
|
||||
name: 'LICENSE',
|
||||
data: readFileSync(join(repositoryRoot, 'LICENSE')),
|
||||
},
|
||||
{
|
||||
name: 'LICENSES/xslt-tools-LICENSE.txt',
|
||||
data: readFileSync(join(repositoryRoot, 'LICENSE')),
|
||||
},
|
||||
{
|
||||
name: 'LICENSES/SaxonJS-LICENSE.txt',
|
||||
data: readFileSync(
|
||||
join(repositoryRoot, 'public/vendor/saxon/LICENSE.txt')
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'THIRD_PARTY_NOTICES.md',
|
||||
data: readFileSync(join(repositoryRoot, 'THIRD_PARTY_NOTICES.md')),
|
||||
},
|
||||
{
|
||||
name: 'SOURCE.md',
|
||||
data: readFileSync(join(repositoryRoot, 'SOURCE.md')),
|
||||
}
|
||||
);
|
||||
|
||||
entries.push(...collectRuntimeDependencyLicenseEntries());
|
||||
|
||||
return entries.sort((left, right) => compareText(left.name, right.name));
|
||||
}
|
||||
|
||||
export function collectRuntimeDependencyLicenseEntries() {
|
||||
const packages = collectRuntimePackages();
|
||||
const entries = [];
|
||||
|
||||
for (const runtimePackage of packages) {
|
||||
const legalFiles = readdirSync(runtimePackage.directory, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name)
|
||||
)
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
|
||||
if (legalFiles.length === 0) {
|
||||
throw new Error(
|
||||
`Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no licence file.`
|
||||
);
|
||||
}
|
||||
|
||||
const directoryName = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`;
|
||||
for (const legalFile of legalFiles) {
|
||||
entries.push({
|
||||
name: `LICENSES/npm/${directoryName}/${basename(legalFile.name)}`,
|
||||
data: readFileSync(join(runtimePackage.directory, legalFile.name)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((left, right) => compareText(left.name, right.name));
|
||||
}
|
||||
|
||||
function collectRuntimePackages() {
|
||||
const queue = Object.keys(packageJson.dependencies ?? {}).map((name) => ({
|
||||
name,
|
||||
fromDirectory: repositoryRoot,
|
||||
}));
|
||||
const visited = new Set();
|
||||
const packages = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const dependency = queue.shift();
|
||||
const directory = findInstalledPackageDirectory(
|
||||
dependency.name,
|
||||
dependency.fromDirectory
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(directory, 'package.json'), 'utf8')
|
||||
);
|
||||
const identity = `${manifest.name}@${manifest.version}`;
|
||||
if (visited.has(identity)) continue;
|
||||
|
||||
visited.add(identity);
|
||||
packages.push({
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
directory,
|
||||
});
|
||||
|
||||
const childNames = new Set([
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.optionalDependencies ?? {}),
|
||||
]);
|
||||
for (const name of [...childNames].sort(compareText)) {
|
||||
const childDirectory = tryFindInstalledPackageDirectory(name, directory);
|
||||
if (childDirectory) {
|
||||
queue.push({ name, fromDirectory: directory });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages.sort((left, right) =>
|
||||
compareText(
|
||||
`${left.name}@${left.version}`,
|
||||
`${right.name}@${right.version}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function findInstalledPackageDirectory(name, fromDirectory) {
|
||||
const directory = tryFindInstalledPackageDirectory(name, fromDirectory);
|
||||
if (!directory) {
|
||||
throw new Error(`Runtime dependency ${name} is not installed.`);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
function tryFindInstalledPackageDirectory(name, fromDirectory) {
|
||||
let current = resolve(fromDirectory);
|
||||
const packageSegments = name.split('/');
|
||||
|
||||
while (true) {
|
||||
const candidate = join(current, 'node_modules', ...packageSegments);
|
||||
if (existsSync(join(candidate, 'package.json'))) {
|
||||
return realpathSync(candidate);
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function safePackageName(name) {
|
||||
return name.replace(/^@/, '').replaceAll('/', '--');
|
||||
}
|
||||
|
||||
function compareText(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, value) => {
|
||||
let result = value;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
result = (result & 1) !== 0 ? 0xedb88320 ^ (result >>> 1) : result >>> 1;
|
||||
}
|
||||
return result >>> 0;
|
||||
});
|
||||
|
||||
function crc32(data) {
|
||||
let result = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
result = crcTable[(result ^ byte) & 0xff] ^ (result >>> 8);
|
||||
}
|
||||
return (result ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
export function createDeterministicZip(entries) {
|
||||
const localRecords = [];
|
||||
const centralRecords = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = Buffer.from(entry.name, 'utf8');
|
||||
const data = Buffer.from(entry.data);
|
||||
const checksum = crc32(data);
|
||||
|
||||
const localHeader = Buffer.alloc(30);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||
localHeader.writeUInt16LE(20, 4);
|
||||
localHeader.writeUInt16LE(0x0800, 6);
|
||||
localHeader.writeUInt16LE(0, 8);
|
||||
localHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 10);
|
||||
localHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 12);
|
||||
localHeader.writeUInt32LE(checksum, 14);
|
||||
localHeader.writeUInt32LE(data.length, 18);
|
||||
localHeader.writeUInt32LE(data.length, 22);
|
||||
localHeader.writeUInt16LE(name.length, 26);
|
||||
localHeader.writeUInt16LE(0, 28);
|
||||
|
||||
localRecords.push(localHeader, name, data);
|
||||
|
||||
const centralHeader = Buffer.alloc(46);
|
||||
centralHeader.writeUInt32LE(0x02014b50, 0);
|
||||
centralHeader.writeUInt16LE(20, 4);
|
||||
centralHeader.writeUInt16LE(20, 6);
|
||||
centralHeader.writeUInt16LE(0x0800, 8);
|
||||
centralHeader.writeUInt16LE(0, 10);
|
||||
centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 12);
|
||||
centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 14);
|
||||
centralHeader.writeUInt32LE(checksum, 16);
|
||||
centralHeader.writeUInt32LE(data.length, 20);
|
||||
centralHeader.writeUInt32LE(data.length, 24);
|
||||
centralHeader.writeUInt16LE(name.length, 28);
|
||||
centralHeader.writeUInt16LE(0, 30);
|
||||
centralHeader.writeUInt16LE(0, 32);
|
||||
centralHeader.writeUInt16LE(0, 34);
|
||||
centralHeader.writeUInt16LE(0, 36);
|
||||
centralHeader.writeUInt32LE(0, 38);
|
||||
centralHeader.writeUInt32LE(offset, 42);
|
||||
|
||||
centralRecords.push(centralHeader, name);
|
||||
offset += localHeader.length + name.length + data.length;
|
||||
}
|
||||
|
||||
const centralDirectory = Buffer.concat(centralRecords);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralDirectory.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
|
||||
return Buffer.concat([...localRecords, centralDirectory, end]);
|
||||
}
|
||||
|
||||
export function toDosDateTime(date) {
|
||||
const year = date.getFullYear();
|
||||
if (year < 1980 || year > 2107) {
|
||||
throw new RangeError('ZIP timestamps must be between 1980 and 2107.');
|
||||
}
|
||||
|
||||
return {
|
||||
time:
|
||||
(date.getHours() << 11) |
|
||||
(date.getMinutes() << 5) |
|
||||
Math.floor(date.getSeconds() / 2),
|
||||
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeReleaseFiles({
|
||||
archive,
|
||||
archiveTarget,
|
||||
checksum,
|
||||
checksumTarget,
|
||||
force = false,
|
||||
}) {
|
||||
mkdirSync(dirname(archiveTarget), { recursive: true });
|
||||
mkdirSync(dirname(checksumTarget), { recursive: true });
|
||||
|
||||
const existingTargets = [archiveTarget, checksumTarget].filter(existsSync);
|
||||
if (!force && existingTargets.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to overwrite existing release file${existingTargets.length === 1 ? '' : 's'}: ${existingTargets.join(', ')}. Pass --force to replace the same-version artifact.`
|
||||
);
|
||||
}
|
||||
|
||||
const writeOptions = force ? undefined : { flag: 'wx' };
|
||||
writeFileSync(archiveTarget, archive, writeOptions);
|
||||
writeFileSync(checksumTarget, checksum, {
|
||||
encoding: 'utf8',
|
||||
...(writeOptions ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function packageRelease({ force = false } = {}) {
|
||||
const archive = createDeterministicZip(collectReleaseEntries());
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
|
||||
writeReleaseFiles({
|
||||
archive,
|
||||
archiveTarget: archivePath,
|
||||
checksum: `${checksum} ${artifactName}.zip\n`,
|
||||
checksumTarget: checksumPath,
|
||||
force,
|
||||
});
|
||||
|
||||
console.log(relative(repositoryRoot, archivePath));
|
||||
console.log(relative(repositoryRoot, checksumPath));
|
||||
}
|
||||
|
||||
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
|
||||
const arguments_ = process.argv.slice(2);
|
||||
const unknownArguments = arguments_.filter(
|
||||
(argument) => argument !== '--force'
|
||||
);
|
||||
if (unknownArguments.length > 0) {
|
||||
throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`);
|
||||
}
|
||||
packageRelease({ force: arguments_.includes('--force') });
|
||||
}
|
||||
151
scripts/smoke-static-build.mjs
Normal file
151
scripts/smoke-static-build.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const distDirectory = join(repositoryRoot, 'dist');
|
||||
const nestedPath = '/deep/nested/app/';
|
||||
const mimeTypes = {
|
||||
'.css': 'text/css',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/x-icon',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
|
||||
if (requestUrl.pathname === '/toolbox.catalog.json') {
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'nested-smoke-test',
|
||||
name: 'Nested smoke test',
|
||||
home: './',
|
||||
theme: {
|
||||
mode: 'system',
|
||||
brand: 'Nested smoke test',
|
||||
},
|
||||
apps: [
|
||||
{
|
||||
manifest: `${nestedPath}toolbox-app.json`,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requestUrl.pathname.startsWith(nestedPath)) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPath = decodeURIComponent(
|
||||
requestUrl.pathname.slice(nestedPath.length)
|
||||
);
|
||||
const filePath = resolve(
|
||||
distDirectory,
|
||||
requestedPath === '' ? 'index.html' : requestedPath
|
||||
);
|
||||
|
||||
if (
|
||||
filePath !== distDirectory &&
|
||||
!filePath.startsWith(`${distDirectory}${sep}`)
|
||||
) {
|
||||
response.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await stat(filePath)).isFile()) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader(
|
||||
'content-type',
|
||||
mimeTypes[extname(filePath)] ?? 'application/octet-stream'
|
||||
);
|
||||
response.end(await readFile(filePath));
|
||||
} catch {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolveListening) => {
|
||||
server.listen(0, '127.0.0.1', resolveListening);
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Static smoke server did not expose a TCP port.');
|
||||
}
|
||||
|
||||
const appUrl = `http://127.0.0.1:${address.port}${nestedPath}`;
|
||||
const standaloneHtml = await fetchText(appUrl);
|
||||
const toolboxHtml = await fetchText(
|
||||
`${appUrl}?toolbox=${encodeURIComponent('/toolbox.catalog.json')}`
|
||||
);
|
||||
|
||||
if (toolboxHtml !== standaloneHtml) {
|
||||
throw new Error('Toolbox mode did not serve the same static app artifact.');
|
||||
}
|
||||
|
||||
const assetReferences = Array.from(
|
||||
standaloneHtml.matchAll(/(?:href|src)="([^"]+)"/g),
|
||||
(match) => match[1]
|
||||
).filter(
|
||||
(reference) =>
|
||||
!reference.startsWith('#') &&
|
||||
!reference.startsWith('data:') &&
|
||||
!reference.startsWith('http:') &&
|
||||
!reference.startsWith('https:')
|
||||
);
|
||||
|
||||
for (const reference of assetReferences) {
|
||||
const resolved = new URL(reference, appUrl);
|
||||
if (!resolved.pathname.startsWith(nestedPath)) {
|
||||
throw new Error(
|
||||
`Built asset escaped the nested app path: ${reference} -> ${resolved.pathname}`
|
||||
);
|
||||
}
|
||||
await fetchOk(resolved);
|
||||
}
|
||||
|
||||
const manifestUrl = new URL('toolbox-app.json', appUrl);
|
||||
const manifest = JSON.parse(await fetchText(manifestUrl));
|
||||
await fetchOk(new URL(manifest.entry, appUrl));
|
||||
await fetchOk(new URL(manifest.icon, manifestUrl));
|
||||
await fetchOk(new URL('vendor/saxon/SaxonJS2.js', appUrl));
|
||||
await fetchOk(`http://127.0.0.1:${address.port}/toolbox.catalog.json`);
|
||||
|
||||
console.log(
|
||||
`Static smoke test passed at ${nestedPath} in standalone and toolbox modes.`
|
||||
);
|
||||
} finally {
|
||||
await new Promise((resolveClosed, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolveClosed()));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOk(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Expected ${url} to be available, received HTTP ${response.status}.`
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
return (await fetchOk(url)).text();
|
||||
}
|
||||
167
src/App.tsx
167
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Layout from './components/Layout';
|
||||
import { AppShell } from '@add-ideas/toolbox-shell-react';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import EditorPanel from './components/EditorPanel';
|
||||
import ActionDialog, {
|
||||
@@ -19,8 +19,14 @@ import {
|
||||
} from './validation/validationTypes';
|
||||
import { runTransformation } from './transform/transformService';
|
||||
import { createTransformationRun } from './transform/nativeXsltEngine';
|
||||
import { createSha256Hash } from './transform/hash';
|
||||
import {
|
||||
isStylesheetExecutionTrusted,
|
||||
type TrustedStylesheet,
|
||||
} from './transform/stylesheetTrust';
|
||||
import type { TransformEngineId } from './transform/transformTypes';
|
||||
import { createApproximateTrace } from './transform/traceAnalyzer';
|
||||
import { toolboxApp } from './toolboxApp';
|
||||
|
||||
interface DialogState {
|
||||
title: string;
|
||||
@@ -52,6 +58,19 @@ const App: React.FC = () => {
|
||||
DiagnosticMessage[]
|
||||
>([]);
|
||||
const [validationTouched, setValidationTouched] = useState(false);
|
||||
const [trustedStylesheet, setTrustedStylesheet] =
|
||||
useState<TrustedStylesheet | null>(null);
|
||||
const stylesheetRevisionRef = useRef(0);
|
||||
const stateRef = useRef(state);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const invalidateStylesheetTrust = () => {
|
||||
stylesheetRevisionRef.current += 1;
|
||||
setTrustedStylesheet(null);
|
||||
};
|
||||
|
||||
const xmlDiagnostics = useMemo(
|
||||
() => validateXml(state.xmlInput.text, 'XML input'),
|
||||
@@ -161,6 +180,7 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
replaceDocument(kind, opened.text, opened.name, false);
|
||||
if (kind === 'xsltCode') invalidateStylesheetTrust();
|
||||
setStatusMessage(`Loaded ${opened.name}.`);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) return;
|
||||
@@ -203,6 +223,16 @@ const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!trustedStylesheet ||
|
||||
trustedStylesheet.revision !== stylesheetRevisionRef.current
|
||||
) {
|
||||
setStatusMessage(
|
||||
'Review and trust this exact stylesheet before transforming.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.options.askBeforeOverwritingOutput) {
|
||||
const mayOverwriteOutput = await confirmOverwrite(
|
||||
'xmlOutput',
|
||||
@@ -213,13 +243,33 @@ const App: React.FC = () => {
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const currentRevision = stylesheetRevisionRef.current;
|
||||
const currentState = stateRef.current;
|
||||
const currentHash = await createSha256Hash(currentState.xsltCode.text);
|
||||
if (
|
||||
currentRevision !== stylesheetRevisionRef.current ||
|
||||
!isStylesheetExecutionTrusted(
|
||||
trustedStylesheet,
|
||||
currentHash,
|
||||
currentRevision
|
||||
)
|
||||
) {
|
||||
setTrustedStylesheet(null);
|
||||
setStatusMessage(
|
||||
'The stylesheet changed. Review and trust it again before transforming.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = {
|
||||
xmlText: state.xmlInput.text,
|
||||
xsltText: state.xsltCode.text,
|
||||
engine: state.selectedEngine,
|
||||
xmlText: currentState.xmlInput.text,
|
||||
xsltText: currentState.xsltCode.text,
|
||||
engine: currentState.selectedEngine,
|
||||
};
|
||||
|
||||
const result = await runTransformation(request);
|
||||
const result = await runTransformation(request, {
|
||||
trustedStylesheet,
|
||||
currentRevision,
|
||||
});
|
||||
setRuntimeDiagnostics(result.diagnostics);
|
||||
|
||||
if (hasErrors(result.diagnostics)) {
|
||||
@@ -229,11 +279,16 @@ const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const output = state.options.prettifyOutputAfterTransform
|
||||
const output = currentState.options.prettifyOutputAfterTransform
|
||||
? tryFormatXml(result.output)
|
||||
: result.output;
|
||||
|
||||
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
|
||||
replaceDocument(
|
||||
'xmlOutput',
|
||||
output,
|
||||
currentState.xmlOutput.fileName,
|
||||
true
|
||||
);
|
||||
const run = await createTransformationRun(request, output);
|
||||
|
||||
setLastTransformation(run);
|
||||
@@ -253,6 +308,66 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrustStylesheet = async () => {
|
||||
const revision = stylesheetRevisionRef.current;
|
||||
const stylesheetText = stateRef.current.xsltCode.text;
|
||||
try {
|
||||
const hash = await createSha256Hash(stylesheetText);
|
||||
const confirmed = await askForConfirmation({
|
||||
title: 'Trust this executable stylesheet?',
|
||||
content: (
|
||||
<>
|
||||
<p>
|
||||
XSLT is executable code. Both engines may access browser or
|
||||
network resources. Only continue if you trust the source of this
|
||||
exact stylesheet.
|
||||
</p>
|
||||
<p>
|
||||
A trusted SaxonJS stylesheet may access data stored by other
|
||||
Toolbox apps on this same site, including PDF workspaces and
|
||||
portal preferences. It may send that data over the network or
|
||||
navigate this page away.
|
||||
</p>
|
||||
<p>
|
||||
Size limits and a best-effort asynchronous deadline reduce
|
||||
accidental overload, but the engines do not provide complete
|
||||
process or network isolation. Synchronous work cannot be forcibly
|
||||
stopped. Any edit, opened replacement, formatting change, or reset
|
||||
revokes trust.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
proceedLabel: 'Trust this exact stylesheet',
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
setStatusMessage('Stylesheet remains untrusted.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
revision !== stylesheetRevisionRef.current ||
|
||||
stylesheetText !== stateRef.current.xsltCode.text
|
||||
) {
|
||||
setTrustedStylesheet(null);
|
||||
setStatusMessage(
|
||||
'The stylesheet changed during review. Review it again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTrustedStylesheet({ hash, revision });
|
||||
setStatusMessage(
|
||||
'This exact stylesheet is trusted for the current session.'
|
||||
);
|
||||
} catch (error) {
|
||||
setTrustedStylesheet(null);
|
||||
setStatusMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Could not fingerprint the stylesheet.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveOutputToInput = async () => {
|
||||
if (!state.xmlOutput.text.trim()) {
|
||||
setStatusMessage('The output editor is empty. Nothing to move.');
|
||||
@@ -292,6 +407,7 @@ const App: React.FC = () => {
|
||||
|
||||
if (!mayProceed) return;
|
||||
replaceDocument(kind, formatted, documentState.fileName, true);
|
||||
if (kind === 'xsltCode') invalidateStylesheetTrust();
|
||||
setStatusMessage(`Formatted ${documentState.label}.`);
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
@@ -336,6 +452,7 @@ const App: React.FC = () => {
|
||||
|
||||
if (!mayProceed) return;
|
||||
resetWorkspace();
|
||||
invalidateStylesheetTrust();
|
||||
setRuntimeDiagnostics([]);
|
||||
setValidationTouched(false);
|
||||
setStatusMessage('Workspace reset.');
|
||||
@@ -402,7 +519,8 @@ const App: React.FC = () => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!busy) {
|
||||
void handleApplyTransformation();
|
||||
if (trustedStylesheet) void handleApplyTransformation();
|
||||
else void handleTrustStylesheet();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -412,10 +530,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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { APP_VERSION } from '../version';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
onOpenHelp?: () => void;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<header className="app-header">
|
||||
<div className="app-header-content">
|
||||
<div className="app-header-title">
|
||||
<span className="app-logo" aria-hidden="true">
|
||||
⚙️
|
||||
</span>
|
||||
<div>
|
||||
<h1>XSLT tools</h1>
|
||||
<small>All transformations stay in your browser</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-header-actions">
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-help-button"
|
||||
onClick={onOpenHelp}
|
||||
aria-haspopup="dialog"
|
||||
title="Open help and keyboard shortcuts (F1)"
|
||||
aria-label="Open help"
|
||||
>
|
||||
<i className="bi bi-question-circle" aria-hidden="true" /> Help
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="app-version" title={`Version ${APP_VERSION}`}>
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -6,6 +6,7 @@ interface ToolbarProps {
|
||||
selectedEngine: TransformEngineId;
|
||||
onEngineChange: (engine: TransformEngineId) => void;
|
||||
onApplyTransformation: () => void;
|
||||
onTrustStylesheet: () => void;
|
||||
onMoveOutputToInput: () => void;
|
||||
onValidate: () => void;
|
||||
onReset: () => void;
|
||||
@@ -14,12 +15,14 @@ interface ToolbarProps {
|
||||
askBeforeOverwritingOutput: boolean;
|
||||
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
|
||||
busy?: boolean;
|
||||
stylesheetTrusted: boolean;
|
||||
}
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
selectedEngine,
|
||||
onEngineChange,
|
||||
onApplyTransformation,
|
||||
onTrustStylesheet,
|
||||
onMoveOutputToInput,
|
||||
onValidate,
|
||||
onReset,
|
||||
@@ -28,6 +31,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
askBeforeOverwritingOutput = true,
|
||||
onAskBeforeOverwritingOutputChange,
|
||||
busy = false,
|
||||
stylesheetTrusted,
|
||||
}) => {
|
||||
return (
|
||||
<section className="toolbar card" aria-label="Workbench actions">
|
||||
@@ -36,11 +40,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
type="button"
|
||||
className="button primary"
|
||||
onClick={onApplyTransformation}
|
||||
disabled={busy}
|
||||
title="Apply transformation (Ctrl/Cmd+Enter)"
|
||||
disabled={busy || !stylesheetTrusted}
|
||||
title={
|
||||
stylesheetTrusted
|
||||
? 'Apply transformation (Ctrl/Cmd+Enter)'
|
||||
: 'Review and trust this exact stylesheet before running it'
|
||||
}
|
||||
>
|
||||
{busy ? 'Transforming…' : 'Apply transformation'}
|
||||
</button>
|
||||
{!stylesheetTrusted && (
|
||||
<button
|
||||
type="button"
|
||||
className="button danger-outline"
|
||||
onClick={onTrustStylesheet}
|
||||
disabled={busy}
|
||||
>
|
||||
Review & trust stylesheet
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
@@ -61,6 +79,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`stylesheet-trust-status ${
|
||||
stylesheetTrusted ? 'is-trusted' : 'is-untrusted'
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
<strong>
|
||||
{stylesheetTrusted
|
||||
? 'This exact stylesheet is trusted for this session.'
|
||||
: 'Transformation is disabled until this exact stylesheet is reviewed and trusted.'}
|
||||
</strong>{' '}
|
||||
Stylesheets are executable code and may access browser or network
|
||||
resources. Any edit revokes trust.
|
||||
</div>
|
||||
|
||||
<div className="toolbar-row toolbar-options">
|
||||
<label className="toggle-option">
|
||||
<input
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
341
src/styles.css
341
src/styles.css
@@ -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
42
src/toolboxApp.ts
Normal 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/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.xslt-tools',
|
||||
name: 'XSLT tools',
|
||||
version: APP_VERSION,
|
||||
description:
|
||||
'Browser-based XML and XSLT transformations with executable-stylesheet trust controls.',
|
||||
entry: './',
|
||||
icon: './favicon.svg',
|
||||
categories: ['documents', 'xml', 'xslt'],
|
||||
tags: ['transform', 'validate', 'format', 'explain'],
|
||||
assets: ['./vendor/saxon/SaxonJS2.js', './vendor/saxon/LICENSE.txt'],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ['navigate', 'new-tab'],
|
||||
embedding: 'unsupported',
|
||||
},
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: false,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: 'mixed',
|
||||
fileUploads: true,
|
||||
telemetry: false,
|
||||
label:
|
||||
'Stylesheets are executable: after explicit trust they may read same-site app data, contact the network, or navigate away.',
|
||||
},
|
||||
source: {
|
||||
repository: 'https://git.add-ideas.de/zemion/xslt-tools',
|
||||
license: 'AGPL-3.0-only',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const toolboxApp = defineToolboxApp(toolboxAppDefinition);
|
||||
@@ -1,6 +1,8 @@
|
||||
export async function createSha256Hash(text: string): Promise<string> {
|
||||
if (!window.crypto?.subtle) {
|
||||
return createFallbackHash(text);
|
||||
throw new Error(
|
||||
'SHA-256 is unavailable. Open this app in a secure browser context before trusting or running a stylesheet.'
|
||||
);
|
||||
}
|
||||
|
||||
const data = new TextEncoder().encode(text);
|
||||
@@ -8,13 +10,3 @@ export async function createSha256Hash(text: string): Promise<string> {
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function createFallbackHash(text: string): string {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash = (hash << 5) - hash + text.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
|
||||
return `fallback-${Math.abs(hash).toString(16)}`;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ type SaxonXdmMap = {
|
||||
inSituPut: (key: unknown, value: unknown[]) => void;
|
||||
};
|
||||
|
||||
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
|
||||
|
||||
let saxonLoadPromise: Promise<void> | null = null;
|
||||
|
||||
export function resolveSaxonScriptUrl(
|
||||
baseUrl = import.meta.env.BASE_URL,
|
||||
pageUrl = window.location.href
|
||||
): string {
|
||||
const appBase = new URL(baseUrl, pageUrl);
|
||||
return new URL('vendor/saxon/SaxonJS2.js', appBase).href;
|
||||
}
|
||||
|
||||
type SaxonPrivateRuntime = {
|
||||
getPlatform?: () => {
|
||||
resource?: (name: string) => unknown;
|
||||
@@ -60,8 +66,9 @@ async function ensureSaxonLoaded(): Promise<void> {
|
||||
if (window.SaxonJS) return;
|
||||
|
||||
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
|
||||
const saxonScriptUrl = resolveSaxonScriptUrl();
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${SAXON_SCRIPT_URL}"]`
|
||||
`script[src="${saxonScriptUrl}"]`
|
||||
);
|
||||
|
||||
if (existingScript) {
|
||||
@@ -75,7 +82,7 @@ async function ensureSaxonLoaded(): Promise<void> {
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = SAXON_SCRIPT_URL;
|
||||
script.src = saxonScriptUrl;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));
|
||||
|
||||
16
src/transform/stylesheetTrust.ts
Normal file
16
src/transform/stylesheetTrust.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface TrustedStylesheet {
|
||||
hash: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export function isStylesheetExecutionTrusted(
|
||||
trust: TrustedStylesheet | null,
|
||||
currentHash: string,
|
||||
currentRevision: number
|
||||
): boolean {
|
||||
return (
|
||||
trust !== null &&
|
||||
trust.hash === currentHash &&
|
||||
trust.revision === currentRevision
|
||||
);
|
||||
}
|
||||
65
src/transform/transformLimits.ts
Normal file
65
src/transform/transformLimits.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { TransformRequest, TransformResult } from './transformTypes';
|
||||
|
||||
export const TRANSFORM_LIMITS = {
|
||||
maxXmlBytes: 5 * 1024 * 1024,
|
||||
maxXsltBytes: 2 * 1024 * 1024,
|
||||
maxOutputBytes: 10 * 1024 * 1024,
|
||||
timeoutMs: 20_000,
|
||||
} as const;
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
export function assertTransformInputWithinLimits(
|
||||
request: TransformRequest
|
||||
): void {
|
||||
const xmlBytes = utf8Bytes(request.xmlText);
|
||||
if (xmlBytes > TRANSFORM_LIMITS.maxXmlBytes) {
|
||||
throw new Error(
|
||||
`XML input exceeds the ${TRANSFORM_LIMITS.maxXmlBytes}-byte execution limit.`
|
||||
);
|
||||
}
|
||||
|
||||
const xsltBytes = utf8Bytes(request.xsltText);
|
||||
if (xsltBytes > TRANSFORM_LIMITS.maxXsltBytes) {
|
||||
throw new Error(
|
||||
`XSLT stylesheet exceeds the ${TRANSFORM_LIMITS.maxXsltBytes}-byte execution limit.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertTransformOutputWithinLimits(
|
||||
result: TransformResult
|
||||
): void {
|
||||
if (utf8Bytes(result.output) > TRANSFORM_LIMITS.maxOutputBytes) {
|
||||
throw new Error(
|
||||
`Transformation output exceeds the ${TRANSFORM_LIMITS.maxOutputBytes}-byte execution limit.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTransformationTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number = TRANSFORM_LIMITS.timeoutMs
|
||||
): Promise<T> {
|
||||
let timer: number | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(operation),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = window.setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Transformation exceeded the ${timeoutMs}-millisecond execution limit.`
|
||||
)
|
||||
),
|
||||
timeoutMs
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,21 @@ import type {
|
||||
TransformRequest,
|
||||
TransformResult,
|
||||
} from './transformTypes';
|
||||
import {
|
||||
assertTransformInputWithinLimits,
|
||||
assertTransformOutputWithinLimits,
|
||||
withTransformationTimeout,
|
||||
} from './transformLimits';
|
||||
import { createSha256Hash } from './hash';
|
||||
import {
|
||||
isStylesheetExecutionTrusted,
|
||||
type TrustedStylesheet,
|
||||
} from './stylesheetTrust';
|
||||
|
||||
export interface TransformationAuthorization {
|
||||
trustedStylesheet: TrustedStylesheet;
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
const engines: Record<TransformEngineId, TransformEngine> = {
|
||||
'saxon-js-dynamic': saxonJsDynamicEngine,
|
||||
@@ -18,10 +33,30 @@ export function getTransformEngine(id: TransformEngineId): TransformEngine {
|
||||
}
|
||||
|
||||
export async function runTransformation(
|
||||
request: TransformRequest
|
||||
request: TransformRequest,
|
||||
authorization: TransformationAuthorization
|
||||
): Promise<TransformResult> {
|
||||
const stylesheetHash = await createSha256Hash(request.xsltText);
|
||||
if (
|
||||
!authorization ||
|
||||
!isStylesheetExecutionTrusted(
|
||||
authorization.trustedStylesheet,
|
||||
stylesheetHash,
|
||||
authorization.currentRevision
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Transformation refused: review and trust this exact stylesheet first.'
|
||||
);
|
||||
}
|
||||
|
||||
assertTransformInputWithinLimits(request);
|
||||
const engine = getTransformEngine(request.engine);
|
||||
return engine.transform(request);
|
||||
const result = await withTransformationTimeout(() =>
|
||||
engine.transform(request)
|
||||
);
|
||||
assertTransformOutputWithinLimits(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const availableTransformEngines = Object.values(engines);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '0.3.1';
|
||||
export const APP_VERSION = '0.4.0';
|
||||
|
||||
48
tests/editorTheme.test.tsx
Normal file
48
tests/editorTheme.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
130
tests/releasePackaging.test.ts
Normal file
130
tests/releasePackaging.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
51
tests/stylesheetTrustUi.test.tsx
Normal file
51
tests/stylesheetTrustUi.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import Toolbar from '../src/components/Toolbar';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('stylesheet trust UI gate', () => {
|
||||
it.each(['saxon-js-dynamic', 'native-xsltprocessor'] as const)(
|
||||
'keeps the %s engine disabled until the exact stylesheet is trusted',
|
||||
(selectedEngine) => {
|
||||
const applyTransformation = vi.fn();
|
||||
const trustStylesheet = vi.fn();
|
||||
const props = {
|
||||
selectedEngine,
|
||||
onEngineChange: vi.fn(),
|
||||
onApplyTransformation: applyTransformation,
|
||||
onTrustStylesheet: trustStylesheet,
|
||||
onMoveOutputToInput: vi.fn(),
|
||||
onValidate: vi.fn(),
|
||||
onReset: vi.fn(),
|
||||
prettifyOutputAfterTransform: true,
|
||||
onPrettifyOutputAfterTransformChange: vi.fn(),
|
||||
askBeforeOverwritingOutput: true,
|
||||
onAskBeforeOverwritingOutputChange: vi.fn(),
|
||||
stylesheetTrusted: false,
|
||||
};
|
||||
const { rerender } = render(<Toolbar {...props} />);
|
||||
|
||||
const applyButton = screen.getByRole('button', {
|
||||
name: 'Apply transformation',
|
||||
}) as HTMLButtonElement;
|
||||
expect(applyButton.disabled).toBe(true);
|
||||
fireEvent.click(applyButton);
|
||||
expect(applyTransformation).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Review & trust stylesheet' })
|
||||
);
|
||||
expect(trustStylesheet).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(<Toolbar {...props} stylesheetTrusted />);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: 'Apply transformation',
|
||||
}) as HTMLButtonElement
|
||||
).disabled
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
177
tests/toolboxIntegration.test.tsx
Normal file
177
tests/toolboxIntegration.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
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/zemion/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.getByText('Personalize').closest('summary')!);
|
||||
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.getByText('Apps').closest('summary')!);
|
||||
const navigation = await screen.findByRole('navigation', {
|
||||
name: 'Toolbox applications',
|
||||
});
|
||||
expect(navigation).toBeTruthy();
|
||||
expect(
|
||||
container.firstElementChild?.getAttribute('data-toolbox-context')
|
||||
).toBe('connected');
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Test Toolbox' }).getAttribute('href')
|
||||
).toBe('https://example.test/');
|
||||
|
||||
const pdfLink = screen.getByRole('link', { name: 'PDF Workbench' });
|
||||
const pdfUrl = new URL(pdfLink.getAttribute('href') ?? '');
|
||||
expect(pdfUrl.pathname).toBe('/apps/pdf/');
|
||||
expect(pdfUrl.searchParams.get('toolbox')).toBe(
|
||||
'https://example.test/toolbox.catalog.json'
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByRole('link', { name: 'XSLT tools' })
|
||||
.getAttribute('aria-current')
|
||||
).toBe('page');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createCatalogFetch() {
|
||||
return async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = new URL(
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url
|
||||
);
|
||||
const documents = new Map<string, unknown>([
|
||||
['/toolbox.catalog.json', catalog],
|
||||
['/apps/xslt/toolbox-app.json', toolboxApp],
|
||||
['/apps/pdf/toolbox-app.json', pdfApp],
|
||||
]);
|
||||
const document = documents.get(url.pathname);
|
||||
|
||||
if (document === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => document,
|
||||
} as Response;
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
availableTransformEngines,
|
||||
runTransformation,
|
||||
} from '../src/transform/transformService';
|
||||
import { compileXsltTextToSefJson } from '../src/transform/saxonJsDynamicCompiler';
|
||||
import { createSha256Hash } from '../src/transform/hash';
|
||||
import type { TransformRequest } from '../src/transform/transformTypes';
|
||||
import {
|
||||
compileXsltTextToSefJson,
|
||||
resolveSaxonScriptUrl,
|
||||
} from '../src/transform/saxonJsDynamicCompiler';
|
||||
|
||||
const xmlText = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<library>
|
||||
@@ -36,6 +41,17 @@ const xslt30Text = `<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>`;
|
||||
|
||||
async function runTrustedTransformation(request: TransformRequest) {
|
||||
const revision = 1;
|
||||
return runTransformation(request, {
|
||||
trustedStylesheet: {
|
||||
hash: await createSha256Hash(request.xsltText),
|
||||
revision,
|
||||
},
|
||||
currentRevision: revision,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (window.SaxonJS) return;
|
||||
|
||||
@@ -59,6 +75,12 @@ describe('transform engine registry', () => {
|
||||
});
|
||||
|
||||
describe('SaxonJS dynamic compiler', () => {
|
||||
it('resolves the vendored runtime relative to a nested app deployment', () => {
|
||||
expect(
|
||||
resolveSaxonScriptUrl('./', 'https://example.test/deep/nested/app/')
|
||||
).toBe('https://example.test/deep/nested/app/vendor/saxon/SaxonJS2.js');
|
||||
});
|
||||
|
||||
it('compiles raw XSLT text to SEF JSON', async () => {
|
||||
const sef = await compileXsltTextToSefJson(xslt30Text);
|
||||
const parsed = JSON.parse(sef) as { N?: string; target?: string };
|
||||
@@ -68,7 +90,7 @@ describe('SaxonJS dynamic compiler', () => {
|
||||
});
|
||||
|
||||
it('runs an XSLT 1.0 stylesheet', async () => {
|
||||
const result = await runTransformation({
|
||||
const result = await runTrustedTransformation({
|
||||
xmlText,
|
||||
xsltText: xslt10Text,
|
||||
engine: 'saxon-js-dynamic',
|
||||
@@ -82,7 +104,7 @@ describe('SaxonJS dynamic compiler', () => {
|
||||
});
|
||||
|
||||
it('runs an XSLT 2.0 stylesheet with XPath 2.0 functions', async () => {
|
||||
const result = await runTransformation({
|
||||
const result = await runTrustedTransformation({
|
||||
xmlText,
|
||||
xsltText: xslt20Text,
|
||||
engine: 'saxon-js-dynamic',
|
||||
@@ -95,7 +117,7 @@ describe('SaxonJS dynamic compiler', () => {
|
||||
});
|
||||
|
||||
it('runs an XSLT 3.0 stylesheet with xsl:mode on-no-match', async () => {
|
||||
const result = await runTransformation({
|
||||
const result = await runTrustedTransformation({
|
||||
xmlText,
|
||||
xsltText: xslt30Text,
|
||||
engine: 'saxon-js-dynamic',
|
||||
@@ -109,7 +131,7 @@ describe('SaxonJS dynamic compiler', () => {
|
||||
});
|
||||
|
||||
it('returns diagnostics for invalid XSLT', async () => {
|
||||
const result = await runTransformation({
|
||||
const result = await runTrustedTransformation({
|
||||
xmlText,
|
||||
xsltText: '<xsl:stylesheet version="3.0">',
|
||||
engine: 'saxon-js-dynamic',
|
||||
|
||||
80
tests/transformationSecurity.test.ts
Normal file
80
tests/transformationSecurity.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isStylesheetExecutionTrusted } from '../src/transform/stylesheetTrust';
|
||||
import { runTransformation } from '../src/transform/transformService';
|
||||
import {
|
||||
TRANSFORM_LIMITS,
|
||||
assertTransformInputWithinLimits,
|
||||
assertTransformOutputWithinLimits,
|
||||
withTransformationTimeout,
|
||||
} from '../src/transform/transformLimits';
|
||||
|
||||
describe('stylesheet execution trust', () => {
|
||||
it('requires both the exact SHA-256 and the current edit revision', () => {
|
||||
const trust = { hash: 'sha256-a', revision: 4 };
|
||||
expect(isStylesheetExecutionTrusted(trust, 'sha256-a', 4)).toBe(true);
|
||||
expect(isStylesheetExecutionTrusted(trust, 'sha256-b', 4)).toBe(false);
|
||||
expect(isStylesheetExecutionTrusted(trust, 'sha256-a', 5)).toBe(false);
|
||||
expect(isStylesheetExecutionTrusted(null, 'sha256-a', 4)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['saxon-js-dynamic', 'native-xsltprocessor'] as const)(
|
||||
'refuses the %s service path when authorization does not match',
|
||||
async (engine) => {
|
||||
await expect(
|
||||
runTransformation(
|
||||
{
|
||||
engine,
|
||||
xmlText: '<root/>',
|
||||
xsltText: '<xsl:stylesheet/>',
|
||||
},
|
||||
{
|
||||
trustedStylesheet: { hash: 'not-the-stylesheet-hash', revision: 7 },
|
||||
currentRevision: 7,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(/review and trust this exact stylesheet/i);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('transformation resource limits', () => {
|
||||
it('rejects oversized XML, XSLT, and result strings', () => {
|
||||
expect(() =>
|
||||
assertTransformInputWithinLimits({
|
||||
engine: 'native-xsltprocessor',
|
||||
xmlText: 'x'.repeat(TRANSFORM_LIMITS.maxXmlBytes + 1),
|
||||
xsltText: '<xsl:stylesheet/>',
|
||||
})
|
||||
).toThrow(/XML input exceeds/i);
|
||||
expect(() =>
|
||||
assertTransformInputWithinLimits({
|
||||
engine: 'saxon-js-dynamic',
|
||||
xmlText: '<root/>',
|
||||
xsltText: 'x'.repeat(TRANSFORM_LIMITS.maxXsltBytes + 1),
|
||||
})
|
||||
).toThrow(/stylesheet exceeds/i);
|
||||
expect(() =>
|
||||
assertTransformOutputWithinLimits({
|
||||
engine: 'saxon-js-dynamic',
|
||||
output: 'x'.repeat(TRANSFORM_LIMITS.maxOutputBytes + 1),
|
||||
diagnostics: [],
|
||||
transformedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
).toThrow(/output exceeds/i);
|
||||
});
|
||||
|
||||
it('rejects an asynchronous execution that exceeds its deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const result = withTransformationTimeout(
|
||||
() => new Promise<never>(() => undefined),
|
||||
25
|
||||
);
|
||||
const rejection = expect(result).rejects.toThrow(/25-millisecond/i);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await rejection;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
95
todo.md
95
todo.md
@@ -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.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['xslttools.add-ideas.de'], // ← ADD THIS
|
||||
|
||||
Reference in New Issue
Block a user