From bbe957191f7c04ecd9521d2e6c683b4b6da51d2d Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Fri, 3 Jul 2026 13:34:40 -0500 Subject: [PATCH 01/15] feat: remove jQuery from activecode components; add vitest test suite Convert the entire activecode family (activecode.js, livecode.js, audiotour.js, timed_activecode.js, activecode_html/js/sql.js, acfactory.js) and common/js/runestonebase.js to plain DOM APIs. Key replacements: - jQuery UI resizable -> CSS resize:both (.ac-resizable) + ResizeObserver - jQuery UI slider history scrubber -> native - Bootstrap .modal() grade summary -> native - sync $.ajax in the Skulpt file reader -> sync XHR (hook can't await) - jQuery.Deferred in activecode_sql -> externally-resolved Promise - DOMNodeInserted mutation event -> MutationObserver - new common/js/domutil.js: getDataValue() replicates jQuery .data() type coercion; toggleDisplay() replaces .toggle() Add a vitest + jsdom test harness (npm test) with 39 passing tests: characterization tests written against the jQuery implementation first, then held green through the refactor and run with no jQuery defined. test-support/jquery-globals.js provides $ for tests of not-yet-converted components; converted components' tests must not import it. Verified in-browser against the PTXSB book: all activecodes render, skulpt runs, scrubber and resize work, no console errors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- bases/rsptx/interactives/CONTRIBUTING.rst | 14 +- bases/rsptx/interactives/package-lock.json | 3552 ++++++++++++++++- bases/rsptx/interactives/package.json | 6 +- .../runestone/activecode/css/activecode.css | 9 + .../runestone/activecode/js/acfactory.js | 1 - .../runestone/activecode/js/activecode.js | 591 +-- .../activecode/js/activecode_html.js | 20 +- .../runestone/activecode/js/activecode_js.js | 16 +- .../runestone/activecode/js/activecode_sql.js | 54 +- .../runestone/activecode/js/audiotour.js | 218 +- .../runestone/activecode/js/livecode.js | 109 +- .../activecode/js/timed_activecode.js | 33 +- .../activecode/test/activecode.test.js | 296 ++ .../test/activecode_subclasses.test.js | 78 + .../runestone/common/js/domutil.js | 58 + .../runestone/common/js/runestonebase.js | 29 +- .../runestone/common/test/domutil.test.js | 74 + .../test-support/jquery-globals.js | 44 + .../rsptx/interactives/test-support/setup.js | 76 + .../test-support/stubs/renderComponent.js | 10 + bases/rsptx/interactives/vitest.config.js | 34 + docs/source/javascript_feature.rst | 67 + 22 files changed, 4751 insertions(+), 638 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/activecode/test/activecode.test.js create mode 100644 bases/rsptx/interactives/runestone/activecode/test/activecode_subclasses.test.js create mode 100644 bases/rsptx/interactives/runestone/common/js/domutil.js create mode 100644 bases/rsptx/interactives/runestone/common/test/domutil.test.js create mode 100644 bases/rsptx/interactives/test-support/jquery-globals.js create mode 100644 bases/rsptx/interactives/test-support/setup.js create mode 100644 bases/rsptx/interactives/test-support/stubs/renderComponent.js create mode 100644 bases/rsptx/interactives/vitest.config.js diff --git a/bases/rsptx/interactives/CONTRIBUTING.rst b/bases/rsptx/interactives/CONTRIBUTING.rst index 30e2dc5cf..ddcf7dcac 100644 --- a/bases/rsptx/interactives/CONTRIBUTING.rst +++ b/bases/rsptx/interactives/CONTRIBUTING.rst @@ -19,8 +19,11 @@ Coding Standards * We use black to format all of our Python code. * No Tabs in Javascript files (4 spaces = 1 indentation) * We use Prettier to format our Javascript - please make sure you configure prettier to use 4 spaces as the tab size. -* Avoid proliferation of jQuery versions. Make your stuff compatible with the version - of jQuery in the common folder. +* Do not use jQuery in new code, and do not add new jQuery plugins. We are + actively migrating components to plain DOM APIs (activecode and + runestonebase are already jQuery-free). The helpers in + ``runestone/common/js/domutil.js`` (``getDataValue``, ``toggleDisplay``) + cover the common jQuery idioms. * Avoid proliferation of additional third party javascript modules. We are already out of control in this regard and it would be nice to rein it in. * To add JavaScript for a new component, follow the directions in `webpack.index.js` for dynamically-loaded components. @@ -36,7 +39,12 @@ Coding Standards Unit Testing ------------ -We are using Selenium to create unit tests for each of the components. Nearly every component has selenium tests now. If you add a feature or a new component, please make sure to include a selenium test that verifies it works. +JavaScript unit tests use `vitest `_ with a jsdom +environment; run them with ``npm test`` from ``bases/rsptx/interactives``. +Full instructions for running and writing tests are in the *Unit tests* +section of ``docs/source/javascript_feature.rst``. If you add a feature or +a new component, please include tests that verify it works. There are also +older Selenium-based tests for many components; new tests should use vitest. Provide an example ------------------ diff --git a/bases/rsptx/interactives/package-lock.json b/bases/rsptx/interactives/package-lock.json index 50bd10e17..3a4e86d83 100644 --- a/bases/rsptx/interactives/package-lock.json +++ b/bases/rsptx/interactives/package-lock.json @@ -36,11 +36,14 @@ "css-minimizer-webpack-plugin": "^3.0.0", "html-loader": "^3.0.0", "html-webpack-plugin": "^5.0.0", + "jquery": "^3.7.1", + "jsdom": "^29.1.1", "less": "^4.1.3", "less-loader": "^11.1.0", "mini-css-extract-plugin": "^2.0.0", "ts-loader": "^9.5.7", "typescript": "^6.0.3", + "vitest": "^4.1.9", "webpack": "^5.97.1", "webpack-bundle-analyzer": "^4.0.0", "webpack-cli": "^4.10.0" @@ -64,6 +67,72 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -1934,6 +2003,147 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -1943,6 +2153,54 @@ "node": ">=10.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@handsontable/formulajs": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@handsontable/formulajs/-/formulajs-2.0.2.tgz", @@ -1998,9 +2256,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { @@ -2258,6 +2516,24 @@ "node": ">=10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2293,99 +2569,471 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], "dev": true, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10.13.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "dev": true + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-stable-stringify": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.34.tgz", - "integrity": "sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==" - }, - "node_modules/@types/node": { - "version": "18.7.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz", - "integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==", - "dev": true - }, - "node_modules/@types/pikaday": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@types/pikaday/-/pikaday-1.6.0.tgz", - "integrity": "sha512-cnKjF7i6oA1ADxQdSWHcEStLZeiH8qbf6l7B9O88PhLgnmbUMM62ali0/PaDtINm6ezpNcqtERWL6Y+pAgHKQQ==", - "dependencies": { - "moment": ">=2.14.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-stable-stringify": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.34.tgz", + "integrity": "sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pikaday": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@types/pikaday/-/pikaday-1.6.0.tgz", + "integrity": "sha512-cnKjF7i6oA1ADxQdSWHcEStLZeiH8qbf6l7B9O88PhLgnmbUMM62ali0/PaDtINm6ezpNcqtERWL6Y+pAgHKQQ==", + "dependencies": { + "moment": ">=2.14.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", @@ -2746,6 +3394,15 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/babel-loader": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", @@ -2891,6 +3548,28 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, "node_modules/bessel": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz", @@ -2899,6 +3578,15 @@ "node": ">=0.8" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -3018,6 +3706,35 @@ "node": ">=12" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3091,6 +3808,32 @@ "resolved": "https://registry.npmjs.org/btm-expressions/-/btm-expressions-0.1.12.tgz", "integrity": "sha512-X/IK6RxA+vm9XPvoweK2AYFLxpHcpfExM15v12eA4+0ibf4irI/FBz2iDcllOIWnIBmRVeoXZww72GHQXTiqOw==" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3155,18 +3898,19 @@ "license": "CC-BY-4.0" }, "node_modules/canvas": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", - "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "peer": true, "dependencies": { - "nan": "^2.10.0" + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" }, "engines": { - "node": ">=0.8.0" + "node": "^18.12.0 || >= 20.9.0" } }, "node_modules/canvas-prebuilt": { @@ -3181,6 +3925,15 @@ "node-pre-gyp": "^0.10.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3953,6 +4706,54 @@ "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -3967,7 +4768,30 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "engines": { - "node": ">=0.10.0" + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/deep-extend": { @@ -4235,6 +5059,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4331,6 +5164,26 @@ "which": "bin/which" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4455,6 +5308,14 @@ "node": ">=8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -4484,6 +5345,20 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -4559,6 +5434,14 @@ "node": ">=6" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4713,6 +5596,18 @@ "tiny-emitter": "^2.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-loader": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-3.1.2.tgz", @@ -4852,6 +5747,28 @@ "postcss": "^8.1.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, "node_modules/ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -5013,6 +5930,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -5071,6 +5994,12 @@ "jsuites": "^2.7.1" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "dev": true + }, "node_modules/jquery-ui": { "version": "1.10.4", "resolved": "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.10.4.tgz", @@ -5082,6 +6011,157 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -5263,7 +6343,265 @@ "needle": "bin/needle" }, "engines": { - "node": ">= 4.4.x" + "node": ">= 4.4.x" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" } }, "node_modules/lilconfig": { @@ -5363,6 +6701,15 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -5498,6 +6845,20 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.1.tgz", @@ -5584,6 +6945,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/moment": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", @@ -5608,18 +6977,23 @@ "optional": true }, "node_modules/nan": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", - "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", - "license": "MIT", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "optional": true, "peer": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -5627,6 +7001,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/needle": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", @@ -5667,6 +7049,28 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -5869,6 +7273,19 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6076,6 +7493,12 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6126,9 +7549,9 @@ } }, "node_modules/postcss": { - "version": "8.4.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", - "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -6138,12 +7561,16 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6632,6 +8059,46 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -6667,9 +8134,9 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -6936,6 +8403,39 @@ "rimraf": "bin.js" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6994,6 +8494,18 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", @@ -7081,11 +8593,66 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true, + "peer": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "1.0.19", "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz", @@ -7124,9 +8691,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -7154,6 +8721,18 @@ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7302,6 +8881,12 @@ "node": ">= 10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -7331,6 +8916,54 @@ "node": ">=4.5" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tar/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7393,17 +9026,104 @@ } } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", "dev": true }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" - }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -7452,6 +9172,18 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -7497,6 +9229,20 @@ "resolved": "https://registry.npmjs.org/tspan/-/tspan-0.4.0.tgz", "integrity": "sha512-0ELL9tpLpTqLliFyQySaxgCO43buCML+j3TI4E1LuSI8wkzITGEVhZCyMvv/A+3ek9KpgALhhgnZESRLTbN+iw==" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -7510,6 +9256,21 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -8157,6 +9918,259 @@ "vega-util": "^1.7.0" } }, + "node_modules/vega/node_modules/canvas": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", + "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "nan": "^2.10.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -8348,6 +10362,15 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -8377,6 +10400,22 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -8448,6 +10487,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -8604,6 +10658,62 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "requires": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "requires": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "dependencies": { + "css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "requires": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + } + }, + "mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + } + } + }, + "@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true + }, + "@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, "@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -9917,12 +12027,113 @@ "to-fast-properties": "^2.0.0" } }, + "@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "requires": { + "css-tree": "^3.0.0" + }, + "dependencies": { + "css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "requires": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + } + }, + "mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + } + } + }, + "@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true + }, + "@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "requires": {} + }, + "@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "requires": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + } + }, + "@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "requires": {} + }, + "@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true + }, "@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "requires": {} + }, "@handsontable/formulajs": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@handsontable/formulajs/-/formulajs-2.0.2.tgz", @@ -9966,9 +12177,9 @@ } }, "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, "@jridgewell/trace-mapping": { @@ -10161,6 +12372,16 @@ } } }, + "@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "optional": true, + "requires": { + "@tybys/wasm-util": "^0.10.3" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10187,18 +12408,172 @@ "fastq": "^1.6.0" } }, + "@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true + }, "@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, + "@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + } + }, + "@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "dev": true, + "optional": true + }, + "@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "dev": true }, + "@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "requires": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -10243,10 +12618,13 @@ "integrity": "sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==" }, "@types/node": { - "version": "18.7.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz", - "integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==", - "dev": true + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "requires": { + "undici-types": "~8.3.0" + } }, "@types/pikaday": { "version": "1.6.0", @@ -10256,6 +12634,68 @@ "moment": ">=2.14.0" } }, + "@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "requires": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "requires": { + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "requires": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + } + }, + "@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + } + }, + "@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true + }, + "@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + } + }, "@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -10549,6 +12989,12 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, "babel-loader": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", @@ -10657,11 +13103,28 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "optional": true, + "peer": true + }, "bessel": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz", "integrity": "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==" }, + "bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "requires": { + "require-from-string": "^2.0.2" + } + }, "big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -10750,6 +13213,34 @@ } } }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -10796,6 +13287,18 @@ "resolved": "https://registry.npmjs.org/btm-expressions/-/btm-expressions-0.1.12.tgz", "integrity": "sha512-X/IK6RxA+vm9XPvoweK2AYFLxpHcpfExM15v12eA4+0ibf4irI/FBz2iDcllOIWnIBmRVeoXZww72GHQXTiqOw==" }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -10842,13 +13345,15 @@ "dev": true }, "canvas": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", - "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "dev": true, "optional": true, "peer": true, "requires": { - "nan": "^2.10.0" + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" } }, "canvas-prebuilt": { @@ -10861,6 +13366,12 @@ "node-pre-gyp": "^0.10.0" } }, + "chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11452,6 +13963,44 @@ "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" }, + "data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "requires": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "dependencies": { + "tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } + }, + "webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true + }, + "whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "requires": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + } + } + } + }, "debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -11466,6 +14015,23 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" }, + "decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -11662,6 +14228,15 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -11733,6 +14308,20 @@ } } }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "optional": true, + "peer": true + }, + "expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11831,6 +14420,14 @@ "path-exists": "^4.0.0" } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "optional": true, + "peer": true + }, "fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -11856,6 +14453,13 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -11917,6 +14521,14 @@ "pump": "^3.0.0" } }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "optional": true, + "peer": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -12032,6 +14644,15 @@ "tiny-emitter": "^2.1.0" } }, + "html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "requires": { + "@exodus/bytes": "^1.6.0" + } + }, "html-loader": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-3.1.2.tgz", @@ -12121,6 +14742,14 @@ "dev": true, "requires": {} }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "optional": true, + "peer": true + }, "ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -12238,6 +14867,12 @@ "isobject": "^3.0.1" } }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -12286,6 +14921,12 @@ "jsuites": "^2.7.1" } }, + "jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "dev": true + }, "jquery-ui": { "version": "1.10.4", "resolved": "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.10.4.tgz", @@ -12297,6 +14938,107 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "requires": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "dependencies": { + "@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "requires": {} + }, + "css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "requires": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + } + }, + "entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true + }, + "lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true + }, + "mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "requires": { + "entities": "^8.0.0" + } + }, + "tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } + }, + "webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true + }, + "whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "requires": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + } + } + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -12429,6 +15171,111 @@ "klona": "^2.0.4" } }, + "lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "requires": { + "detect-libc": "^2.0.3", + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + }, + "dependencies": { + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true + } + } + }, + "lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "dev": true, + "optional": true + }, + "lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "dev": true, + "optional": true + }, + "lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "dev": true, + "optional": true + }, + "lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "dev": true, + "optional": true + }, "lilconfig": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", @@ -12511,6 +15358,15 @@ "yallist": "^4.0.0" } }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -12609,6 +15465,14 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "optional": true, + "peer": true + }, "mini-css-extract-plugin": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.1.tgz", @@ -12673,6 +15537,14 @@ "minimist": "^1.2.6" } }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "optional": true, + "peer": true + }, "moment": { "version": "2.20.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", @@ -12691,18 +15563,26 @@ "optional": true }, "nan": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", - "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "optional": true, "peer": true }, "nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "optional": true, + "peer": true + }, "needle": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", @@ -12736,6 +15616,25 @@ "tslib": "^2.0.3" } }, + "node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "semver": "^7.3.5" + } + }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true, + "peer": true + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -12885,6 +15784,12 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, + "obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -13040,6 +15945,12 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13077,14 +15988,14 @@ } }, "postcss": { - "version": "8.4.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", - "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "requires": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" } }, "postcss-calc": { @@ -13390,6 +16301,38 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "optional": true, + "peer": true + } + } + }, "pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -13424,9 +16367,9 @@ } }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, "queue-microtask": { @@ -13629,6 +16572,31 @@ "glob": "^7.1.3" } }, + "rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "requires": { + "@oxc-project/types": "=0.138.0", + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4", + "@rolldown/pluginutils": "^1.0.0" + } + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13659,6 +16627,15 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, "schema-utils": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", @@ -13722,11 +16699,38 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "optional": true, + "peer": true + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "sirv": { "version": "1.0.19", "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.19.tgz", @@ -13756,9 +16760,9 @@ "dev": true }, "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, "source-map-support": { @@ -13782,6 +16786,18 @@ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "dev": true }, + "stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -13890,6 +16906,12 @@ } } }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -13921,6 +16943,50 @@ } } }, + "tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "terser": { "version": "5.37.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", @@ -13959,6 +17025,64 @@ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true + } + } + }, + "tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true + }, + "tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "requires": { + "tldts-core": "^7.4.6" + } + }, + "tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -13995,6 +17119,15 @@ "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==", "dev": true }, + "tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "requires": { + "tldts": "^7.0.5" + } + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14032,12 +17165,35 @@ "resolved": "https://registry.npmjs.org/tspan/-/tspan-0.4.0.tgz", "integrity": "sha512-0ELL9tpLpTqLliFyQySaxgCO43buCML+j3TI4E1LuSI8wkzITGEVhZCyMvv/A+3ek9KpgALhhgnZESRLTbN+iw==" }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, "typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true }, + "undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true + }, + "undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -14113,6 +17269,18 @@ "vega-parser": "3.9.0", "vega-typings": "*", "yargs": "12" + }, + "dependencies": { + "canvas": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", + "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", + "optional": true, + "peer": true, + "requires": { + "nan": "^2.10.0" + } + } } }, "vega-canvas": { @@ -14600,6 +17768,90 @@ "vega-util": "^1.7.0" } }, + "vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "requires": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "dependencies": { + "@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "requires": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, + "es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true + }, + "picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true + }, + "vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "requires": { + "fsevents": "~2.3.3", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + } + }, + "yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "optional": true, + "peer": true + } + } + }, + "w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "requires": { + "xml-name-validator": "^5.0.0" + } + }, "watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -14728,6 +17980,12 @@ "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true }, + "whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true + }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -14751,6 +18009,16 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" }, + "why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, "wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -14801,6 +18069,18 @@ "dev": true, "requires": {} }, + "xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/bases/rsptx/interactives/package.json b/bases/rsptx/interactives/package.json index 94e867d5b..0135058b7 100644 --- a/bases/rsptx/interactives/package.json +++ b/bases/rsptx/interactives/package.json @@ -3,7 +3,8 @@ "version": "2.0.0", "description": "", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", + "test:watch": "vitest", "check-env": "node -e 'console.log(process.env)' | grep npm", "build": "webpack --mode=development", "buildj": "webpack --mode=development --profile --json > stats.json", @@ -26,11 +27,14 @@ "css-minimizer-webpack-plugin": "^3.0.0", "html-loader": "^3.0.0", "html-webpack-plugin": "^5.0.0", + "jquery": "^3.7.1", + "jsdom": "^29.1.1", "less": "^4.1.3", "less-loader": "^11.1.0", "mini-css-extract-plugin": "^2.0.0", "ts-loader": "^9.5.7", "typescript": "^6.0.3", + "vitest": "^4.1.9", "webpack": "^5.97.1", "webpack-bundle-analyzer": "^4.0.0", "webpack-cli": "^4.10.0" diff --git a/bases/rsptx/interactives/runestone/activecode/css/activecode.css b/bases/rsptx/interactives/runestone/activecode/css/activecode.css index c3cdd9725..d75eac8a6 100644 --- a/bases/rsptx/interactives/runestone/activecode/css/activecode.css +++ b/bases/rsptx/interactives/runestone/activecode/css/activecode.css @@ -147,6 +147,15 @@ height: auto; } +/* Editors are user-resizable via the native CSS resize handle (a + * ResizeObserver in activecode.js refreshes CodeMirror on change). + * Once the user drags the handle the browser sets an explicit + * height, which overrides height: auto above. */ +.CodeMirror.ac-resizable { + resize: both; + overflow: hidden; +} + .CodeMirror-scroll { /* calculated height is designed so editor panel * will fit on screen (with space for navbar and diff --git a/bases/rsptx/interactives/runestone/activecode/js/acfactory.js b/bases/rsptx/interactives/runestone/activecode/js/acfactory.js index bde8d34e4..ee34679df 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/acfactory.js +++ b/bases/rsptx/interactives/runestone/activecode/js/acfactory.js @@ -10,7 +10,6 @@ import { TimedHTMLActiveCode, TimedSQLActiveCode, } from "./timed_activecode"; -import "../../common/js/jquery.highlight.js"; export default class ACFactory { constructor() { diff --git a/bases/rsptx/interactives/runestone/activecode/js/activecode.js b/bases/rsptx/interactives/runestone/activecode/js/activecode.js index a4f735001..914dbcc52 100755 --- a/bases/rsptx/interactives/runestone/activecode/js/activecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/activecode.js @@ -9,6 +9,7 @@ import RunestoneBase from "../../common/js/runestonebase.js"; import { t } from "../../common/js/rsi18n.js"; +import { getDataValue, toggleDisplay } from "../../common/js/domutil.js"; import AudioTour from "./audiotour.js"; import "./activecode-i18n.en.js"; import "./activecode-i18n.pt-br.js"; @@ -72,7 +73,7 @@ const codeIndent = () => export class ActiveCode extends RunestoneBase { constructor(opts) { super(opts); - var orig = $(opts.orig).find("textarea")[0]; + var orig = opts.orig.querySelector("textarea"); this.containerDiv = opts.orig; this.useRunestoneServices = opts.useRunestoneServices; this.python3 = true; @@ -81,25 +82,25 @@ export class ActiveCode extends RunestoneBase { this.codeCoachList = []; //list of CodeCoaches that will be used to provide feedback this.divid = opts.orig.id; this.problem_description = - $(opts.orig).find(".ac_question").text() || ""; - this.code = $(orig).text() || "\n\n\n\n\n"; - this.parsonspersonalize = $(orig).data("parsonspersonalize"); // CodeTailor: Allows the instructor to choose personalization level: solution-level or block-and-solution level - this.parsonsexample = $(orig).data("parsonsexample"); // CodeTailor: Allows the instructor to choose an example Parsons problem or LLM-example (default auto-fill, means the example is generated by LLM) + opts.orig.querySelector(".ac_question")?.textContent || ""; + this.code = orig.textContent || "\n\n\n\n\n"; + this.parsonspersonalize = getDataValue(orig, "parsonspersonalize"); // CodeTailor: Allows the instructor to choose personalization level: solution-level or block-and-solution level + this.parsonsexample = getDataValue(orig, "parsonsexample"); // CodeTailor: Allows the instructor to choose an example Parsons problem or LLM-example (default auto-fill, means the example is generated by LLM) // true by default; only false when explicitly set this.parsonsPersonalized = - $(orig).data("parsons-personalized") !== false && - $(orig).data("parsons-personalized") !== "false"; + getDataValue(orig, "parsons-personalized") !== false && + getDataValue(orig, "parsons-personalized") !== "false"; this.puzzleScaffoldingDivid = `help_puzzle_${this.parsonspersonalize}_${this.divid}`; // CodeTailor: the div id for the puzzle scaffolding help window - this.language = $(orig).data("lang"); - this.timelimit = $(orig).data("timelimit"); - this.highlightLines = $(orig).data("highlight-lines"); - this.includes = $(orig).data("include"); - this.hidecode = $(orig).data("hidecode"); - this.chatcodes = $(orig).data("chatcodes"); - this.hidehistory = $(orig).data("hidehistory"); - this.question = $(opts.orig).find(`#${this.divid}_question`)[0]; - this.tie = $(orig).data("tie"); - this.dburl = $(orig).data("dburl"); + this.language = getDataValue(orig, "lang"); + this.timelimit = getDataValue(orig, "timelimit"); + this.highlightLines = getDataValue(orig, "highlight-lines"); + this.includes = getDataValue(orig, "include"); + this.hidecode = getDataValue(orig, "hidecode"); + this.chatcodes = getDataValue(orig, "chatcodes"); + this.hidehistory = getDataValue(orig, "hidehistory"); + this.question = document.getElementById(`${this.divid}_question`); + this.tie = getDataValue(orig, "tie"); + this.dburl = getDataValue(orig, "dburl"); this.runButton = null; this.enabledownload = this.parseBooleanAttribute( orig, @@ -224,8 +225,8 @@ export class ActiveCode extends RunestoneBase { this.createEditor(); this.createOutput(); this.createControls(); - if ($(orig).data("caption")) { - this.caption = $(orig).data("caption"); + if (getDataValue(orig, "caption")) { + this.caption = getDataValue(orig, "caption"); } else { this.caption = "ActiveCode"; } @@ -246,7 +247,12 @@ export class ActiveCode extends RunestoneBase { this.decorateStatus(); if (this.autorun) { // Simulate pressing the run button, since this will also prevent the user from clicking it until the initial run is complete, and also help the user understand why they're waiting. - $(this.runButtonHandler.bind(this)); + const autorunHandler = this.runButtonHandler.bind(this); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", autorunHandler); + } else { + setTimeout(autorunHandler, 0); + } } this.indicate_component_ready(); if (typeof Prism !== "undefined") { @@ -321,14 +327,16 @@ export class ActiveCode extends RunestoneBase { }); }); - // Make the editor resizable - let ac = this; - $(editor.getWrapperElement()).resizable({ - resize: function () { - editor.setSize($(this).width(), $(this).height()); + // Make the editor resizable via the CSS resize property (see + // activecode.less); CodeMirror just needs a refresh when its + // wrapper changes size. + let wrapper = editor.getWrapperElement(); + wrapper.classList.add("ac-resizable"); + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(() => { editor.refresh(); - }, - }); + }).observe(wrapper); + } editor.on("keydown", (cm, event) => { // give the user a visual cue that they have changed but not saved editor.getWrapperElement().style.borderTopColor = "#b43232"; @@ -448,7 +456,7 @@ export class ActiveCode extends RunestoneBase { this.setHighlightLines(); if (this.hidecode) { - $(this.codeDiv).css("display", "none"); + this.codeDiv.style.display = "none"; } } @@ -750,13 +758,23 @@ export class ActiveCode extends RunestoneBase { const loadingBarDiv = document.getElementById( "scaffolding-loading-prompt-" + this.divid, ); - $(loadingBarDiv).find("#loading-spinner").remove(); - $(loadingBarDiv) - .find("#encouragement-text") - .text("⚠️ " + timeoutMessage); - $(loadingBarDiv) - .find("#countdown-text") - .text("🫶 Please contact the teaching staff for help!"); + if (loadingBarDiv) { + loadingBarDiv + .querySelector("#loading-spinner") + ?.remove(); + const encouragement = loadingBarDiv.querySelector( + "#encouragement-text", + ); + if (encouragement) { + encouragement.textContent = "⚠️ " + timeoutMessage; + } + const countdown = + loadingBarDiv.querySelector("#countdown-text"); + if (countdown) { + countdown.textContent = + "🫶 Please contact the teaching staff for help!"; + } + } reject("Error in Parsons Scaffolding: " + err); }); }); @@ -817,7 +835,9 @@ export class ActiveCode extends RunestoneBase { } if (window.latestParsonsHelpID == this.divid) { - $(`#scaffolding-container-${this.divid}`).removeClass("hidden"); + document + .getElementById(`scaffolding-container-${this.divid}`) + ?.classList.remove("hidden"); let reopen_act = { type: "receive_help", reopen: true, @@ -931,7 +951,10 @@ export class ActiveCode extends RunestoneBase { // Log the data in parts logActInParts(act); - let probDescHTML = $(this.outerDiv).find(".ac_question").last().html(); + let questionDivs = this.outerDiv.querySelectorAll(".ac_question"); + let probDescHTML = questionDivs.length + ? questionDivs[questionDivs.length - 1].innerHTML + : undefined; if (puzzle_rst !== "correctCode" && puzzle_rst !== "emptyHelpParsons") { var puzzleCode = ` @@ -1010,7 +1033,9 @@ export class ActiveCode extends RunestoneBase { act: JSON.stringify({ ...act }), // Clone to avoid future changes affecting the log div_id: close_divId, }); - $(`#scaffolding-container-${this.divid}`).addClass("hidden"); + document + .getElementById(`scaffolding-container-${this.divid}`) + ?.classList.add("hidden"); }; scaffoldingContainer.appendChild(closeScaffoldingButton); @@ -1035,7 +1060,9 @@ export class ActiveCode extends RunestoneBase { scaffoldingContainer.appendChild(parsonsDiv); } else { // if already exists: remove hidden - $(`#scaffolding-container-${this.divid}`).removeClass("hidden"); + document + .getElementById(`scaffolding-container-${this.divid}`) + ?.classList.remove("hidden"); } if ( @@ -1099,15 +1126,20 @@ export class ActiveCode extends RunestoneBase { act: JSON.stringify({ ...act }), // Clone to avoid future changes affecting the log div_id: this.divid, }); - $(`#copy-answer-button-${this.puzzleScaffoldingDivid}`) - .text("Copied!") - .prop("disabled", true); + let copyButton = document.getElementById( + `copy-answer-button-${this.puzzleScaffoldingDivid}`, + ); + if (copyButton) { + copyButton.textContent = "Copied!"; + copyButton.disabled = true; + } // Set it back to "Copy Answers to Clipboard" after 2 seconds setTimeout(() => { - $(`#copy-answer-button-${this.puzzleScaffoldingDivid}`) - .text("Copy Answer to Clipboard") - .prop("disabled", false); + if (copyButton) { + copyButton.textContent = "Copy Answer to Clipboard"; + copyButton.disabled = false; + } }, 2000); } catch (err) { alert( @@ -1168,12 +1200,9 @@ export class ActiveCode extends RunestoneBase { this.prevHelpedCode = this.editor.getValue(); // Remove any existing loading prompt to reset the countdown - let existingLoadingPrompt = $(this.outerDiv).find( - "#scaffolding-loading-prompt-" + this.divid, - ); - if (existingLoadingPrompt.length) { - existingLoadingPrompt.remove(); - } + this.outerDiv + .querySelector(`[id="scaffolding-loading-prompt-${this.divid}"]`) + ?.remove(); // Create a new loading prompt let loadingPrompt = document.createElement("div"); @@ -1198,7 +1227,7 @@ export class ActiveCode extends RunestoneBase { countdownText.id = "countdown-text"; loadingPrompt.appendChild(countdownText); - $(loadingPrompt).addClass("loading"); + loadingPrompt.classList.add("loading"); loadingPrompt.scrollIntoView(); // Display encouragement message @@ -1240,22 +1269,26 @@ export class ActiveCode extends RunestoneBase { } this.helpText = await this.getParsonsCodeTailor(); - $(this.outerDiv) - .find("#scaffolding-loading-prompt-" + this.divid) - .removeClass("loading"); + this.outerDiv + .querySelector(`[id="scaffolding-loading-prompt-${this.divid}"]`) + ?.classList.remove("loading"); window.latestParsonsHelpID = ""; this.reopenHelpBtnHandler(true); // check whether hold the regenerating button if (!this.helpLoaded) { this.helpLoaded = true; - $(this.outerDiv).find(".reopen-help-btn").removeClass("hide"); + this.outerDiv + .querySelectorAll(".reopen-help-btn") + .forEach((el) => el.classList.remove("hide")); if ( this.parsonspersonalize === "movable" || this.parsonspersonalize === "partial" ) { - $(this.outerDiv) - .find(".parsons-scaffolding-btn") - .text("Regenerate Help"); + this.outerDiv + .querySelectorAll(".parsons-scaffolding-btn") + .forEach((el) => { + el.textContent = "Regenerate Help"; + }); } } } @@ -1264,17 +1297,17 @@ export class ActiveCode extends RunestoneBase { createControls() { var ctrlDiv = document.createElement("div"); var butt; - $(ctrlDiv).addClass("ac_actions"); + ctrlDiv.classList.add("ac_actions"); // Run butt = document.createElement("button"); butt.title = t("msg_activecode_run_button_title"); - $(butt).text(t("msg_activecode_run_code")); - $(butt).addClass("btn btn-success run-button"); + butt.textContent = t("msg_activecode_run_code"); + butt.classList.add("btn", "btn-success", "run-button"); ctrlDiv.appendChild(butt); this.runButton = butt; // console.log("adding click function for run"); this.runButton.onclick = this.runButtonHandler.bind(this); - $(butt).attr("type", "button"); + butt.setAttribute("type", "button"); if (this.enabledownload || eBookConfig.downloadsEnabled) { this.addDownloadButton(ctrlDiv); @@ -1331,7 +1364,7 @@ export class ActiveCode extends RunestoneBase { } // Audio Tour - if ($(this.origElem).data("audio")) { + if (getDataValue(this.origElem, "audio")) { this.enableAudioTours(ctrlDiv); } if (eBookConfig.isInstructor) { @@ -1344,12 +1377,12 @@ export class ActiveCode extends RunestoneBase { this.enableChatCodes(ctrlDiv); } - $(this.outerDiv).prepend(ctrlDiv); + this.outerDiv.prepend(ctrlDiv); if (this.question) { - if ($(this.question).html().match(/^\s+$/)) { - $(this.question).remove(); + if (this.question.innerHTML.match(/^\s+$/)) { + this.question.remove(); } else { - $(this.outerDiv).prepend(this.question); + this.outerDiv.prepend(this.question); } } this.controlDiv = ctrlDiv; @@ -1357,92 +1390,98 @@ export class ActiveCode extends RunestoneBase { addFeedbackButton(ctrlDiv) { let butt = document.createElement("button"); - $(butt).addClass("ac_opt btn btn-default"); - $(butt).text(t("msg_activecode_show_feedback")); - $(butt).css("margin-left", "10px"); - $(butt).attr("type", "button"); + butt.classList.add("ac_opt", "btn", "btn-default"); + butt.textContent = t("msg_activecode_show_feedback"); + butt.style.marginLeft = "10px"; + butt.setAttribute("type", "button"); this.gradeButton = butt; ctrlDiv.appendChild(butt); - $(butt).click(this.createGradeSummary.bind(this)); + butt.addEventListener("click", this.createGradeSummary.bind(this)); } addDownloadButton(ctrlDiv) { let butt = document.createElement("button"); - $(butt).text("Download"); - $(butt).addClass("btn save-button"); + butt.textContent = "Download"; + butt.classList.add("btn", "save-button"); ctrlDiv.appendChild(butt); this.downloadButton = butt; - $(butt).click(this.downloadFile.bind(this, this.language)); - $(butt).attr("type", "button"); + butt.addEventListener( + "click", + this.downloadFile.bind(this, this.language), + ); + butt.setAttribute("type", "button"); } enableHideShow(ctrlDiv) { - $(this.runButton).attr("disabled", "disabled"); + this.runButton.disabled = true; let butt = document.createElement("button"); - $(butt).addClass("ac_opt btn btn-default"); - $(butt).text(t("msg_activecode_show_code")); - $(butt).css("margin-left", "10px"); - $(butt).attr("type", "button"); + butt.classList.add("ac_opt", "btn", "btn-default"); + butt.textContent = t("msg_activecode_show_code"); + butt.style.marginLeft = "10px"; + butt.setAttribute("type", "button"); this.showHideButt = butt; ctrlDiv.appendChild(butt); - $(butt).click( + butt.addEventListener( + "click", function () { - $(this.codeDiv).toggle(); + toggleDisplay(this.codeDiv); if (this.historyScrubber == null) { this.addHistoryScrubber(true); } else { - $(this.historyScrubber.parentElement).toggle(); + toggleDisplay(this.historyScrubber.parentElement); } if ( - $(this.showHideButt).text() == t("msg_activecode_show_code") + this.showHideButt.textContent == + t("msg_activecode_show_code") ) { - $(this.showHideButt).text(t("msg_activecode_hide_code")); - } else { - $(this.showHideButt).text(t("msg_activecode_show_code")); - } - if ($(this.runButton).attr("disabled")) { - $(this.runButton).removeAttr("disabled"); + this.showHideButt.textContent = t( + "msg_activecode_hide_code", + ); } else { - $(this.runButton).attr("disabled", "disabled"); + this.showHideButt.textContent = t( + "msg_activecode_show_code", + ); } + this.runButton.disabled = !this.runButton.disabled; }.bind(this), ); } enableCodeLens(ctrlDiv) { let butt = document.createElement("button"); - $(butt).addClass("ac_opt btn btn-default"); - $(butt).text(t("msg_activecode_show_codelens")); - $(butt).css("margin-left", "10px"); + butt.classList.add("ac_opt", "btn", "btn-default"); + butt.textContent = t("msg_activecode_show_codelens"); + butt.style.marginLeft = "10px"; this.clButton = butt; ctrlDiv.appendChild(butt); - $(butt).click(this.showCodelens.bind(this)); + butt.addEventListener("click", this.showCodelens.bind(this)); } enableReformat(ctrlDiv) { let butt = document.createElement("button"); - $(butt).addClass("ac_opt btn btn-default"); - $(butt).text(t("msg_activecode_reformat")); - $(butt).css("margin-left", "10px"); + butt.classList.add("ac_opt", "btn", "btn-default"); + butt.textContent = t("msg_activecode_reformat"); + butt.style.marginLeft = "10px"; this.reformatButton = butt; ctrlDiv.appendChild(butt); - $(butt).click(this.reformat.bind(this)); + butt.addEventListener("click", this.reformat.bind(this)); } enableAudioTours(ctrlDiv) { let butt = document.createElement("button"); - $(butt).addClass("ac_opt btn btn-default"); - $(butt).text(t("msg_activecode_audio_tour")); - $(butt).css("margin-left", "10px"); + butt.classList.add("ac_opt", "btn", "btn-default"); + butt.textContent = t("msg_activecode_audio_tour"); + butt.style.marginLeft = "10px"; this.atButton = butt; ctrlDiv.appendChild(butt); - $(butt).click( + butt.addEventListener( + "click", function () { new AudioTour( this.divid, this.code, 1, - $(this.origElem).data("audio"), + getDataValue(this.origElem, "audio"), ); }.bind(this), ); @@ -1450,12 +1489,13 @@ export class ActiveCode extends RunestoneBase { enableInstructorSharing(ctrlDiv) { let butt = document.createElement("button"); - $(butt).addClass("btn btn-info"); - $(butt).text("Share Code"); - $(butt).css("margin-left", "10px"); + butt.classList.add("btn", "btn-info"); + butt.textContent = "Share Code"; + butt.style.marginLeft = "10px"; this.shareButt = butt; ctrlDiv.appendChild(butt); - $(butt).click( + butt.addEventListener( + "click", async function () { if ( !confirm( @@ -1508,16 +1548,17 @@ export class ActiveCode extends RunestoneBase { ctrlDiv.appendChild(checkPartner); var plabel = document.createElement("label"); plabel.for = `${this.divid}_part`; - $(plabel).text("Pair?"); + plabel.textContent = "Pair?"; ctrlDiv.appendChild(plabel); - $(checkPartner).click( + checkPartner.addEventListener( + "click", function () { if (this.partner) { this.partner = false; - $(partnerTextBox).hide(); + partnerTextBox.style.display = "none"; this.partner = ""; partnerTextBox.value = ""; - $(plabel).text("Pair?"); + plabel.textContent = "Pair?"; } else { let didAgree = localStorage.getItem("partnerAgree"); if (!didAgree) { @@ -1533,16 +1574,17 @@ export class ActiveCode extends RunestoneBase { } } this.partner = true; - $(plabel).text("with: "); - $(partnerTextBox).show(); + plabel.textContent = "with: "; + partnerTextBox.style.display = ""; } }.bind(this), ); var partnerTextBox = document.createElement("input"); partnerTextBox.type = "text"; ctrlDiv.appendChild(partnerTextBox); - $(partnerTextBox).hide(); - $(partnerTextBox).change( + partnerTextBox.style.display = "none"; + partnerTextBox.addEventListener( + "change", function () { this.partner = partnerTextBox.value; }.bind(this), @@ -1556,47 +1598,46 @@ export class ActiveCode extends RunestoneBase { var channels = document.createElement("span"); var topic = window.location.host + "-" + this.divid; ctrlDiv.appendChild(chatBar); - $(chatBar).text("Chat: "); - $(chatBar).append(channels); + chatBar.textContent = "Chat: "; + chatBar.appendChild(channels); let butt = document.createElement("a"); - $(butt).addClass("ac_opt btn btn-default"); - $(butt).text("Create Channel"); - $(butt).css("margin-left", "10px"); - $(butt).attr("type", "button"); - $(butt).attr("target", "_blank"); - $(butt).attr( + butt.classList.add("ac_opt", "btn", "btn-default"); + butt.textContent = "Create Channel"; + butt.style.marginLeft = "10px"; + butt.setAttribute("type", "button"); + butt.setAttribute("target", "_blank"); + butt.setAttribute( "href", "http://" + chatcodesServer + "/new?" + - $.param({ + new URLSearchParams({ topic: window.location.host + "-" + this.divid, code: this.editor.getValue(), lang: "Python", - }), + }).toString(), ); this.chatButton = butt; chatBar.appendChild(butt); var updateChatCodesChannels = function () { var data = doc.data; var i = 1; - $(channels).html(""); + channels.innerHTML = ""; data["channels"].forEach(function (channel) { if (!channel.archived && topic === channel.topic) { - var link = $(""); - var href = + var link = document.createElement("a"); + link.href = "http://" + chatcodesServer + "/" + channel.channelName; - link.attr({ - href: href, - target: "_blank", - }); - link.text(" " + channel.channelName + "(" + i + ") "); - $(channels).append(link); + link.target = "_blank"; + link.textContent = + " " + channel.channelName + "(" + i + ") "; + channels.appendChild(link); i++; } }); if (i === 1) { - $(channels).text("(no active converstations on this problem)"); + channels.textContent = + "(no active converstations on this problem)"; } }; doc.subscribe(updateChatCodesChannels); @@ -1604,7 +1645,7 @@ export class ActiveCode extends RunestoneBase { } enableSaveLoad() { - $(this.runButton).text(t("msg_activecode_save_run")); + this.runButton.textContent = t("msg_activecode_save_run"); } // _`addHistoryScrubber` @@ -1668,27 +1709,35 @@ export class ActiveCode extends RunestoneBase { renderScrubber(pos_last) { console.log("making a new scrubber"); var scrubberDiv = document.createElement("div"); - $(scrubberDiv).css("display", "inline-block"); - $(scrubberDiv).css("margin-left", "10px"); - $(scrubberDiv).css("margin-right", "10px"); - $(scrubberDiv).css({ - "min-width": "200px", - "max-width": "300px", - }); - var scrubber = document.createElement("div"); + scrubberDiv.classList.add("ac_history_scrubber_box"); + scrubberDiv.style.display = "inline-block"; + scrubberDiv.style.marginLeft = "10px"; + scrubberDiv.style.marginRight = "10px"; + scrubberDiv.style.minWidth = "200px"; + scrubberDiv.style.maxWidth = "300px"; + // A native range input replaces the old jQuery UI slider. + var scrubber = document.createElement("input"); + scrubber.type = "range"; + scrubber.min = 0; + scrubber.max = this.history.length - 1; + scrubber.value = this.history.length - 1; + scrubber.step = 1; + scrubber.classList.add("ac_history_scrubber"); + scrubber.setAttribute("aria-label", "History slider"); this.timestampP = document.createElement("span"); - this.slideit = function (ev, el) { - let submittedCode = this.history[$(scrubber).slider("value")]; + this.slideit = function (ev) { + let pos = Number(scrubber.value); + let submittedCode = this.history[pos]; let code = this.readdLockedCode(submittedCode); this.editor.setValue(code); this.setLockedRegions(); - var curVal = this.timestamps[$(scrubber).slider("value")]; - let pos = $(scrubber).slider("value"); + var curVal = this.timestamps[pos]; let outOf = this.history.length; - $(this.timestampP).text(`${curVal} - ${pos + 1} of ${outOf}`); - // a slide will also result in a slidechange event we don't want to double - // log these events. So do not log the slide until it stops and creates the changed - if (ev !== null && ev.type != "slide") { + this.timestampP.textContent = `${curVal} - ${pos + 1} of ${outOf}`; + // "input" events fire continuously while dragging; only log once + // the position settles (the "change" event, or a programmatic + // call passing null). + if (ev !== null && ev.type != "input") { this.logBookEvent({ event: "activecode", act: "slide:" + curVal, @@ -1697,16 +1746,10 @@ export class ActiveCode extends RunestoneBase { } this.setHighlightLines(); }; - $(scrubber).slider({ - max: this.history.length - 1, - value: this.history.length - 1, - }); - $(scrubber).css("margin", "10px"); - $(scrubber).on("slide", this.slideit.bind(this)); - $(scrubber).on("slidechange", this.slideit.bind(this)); + scrubber.style.margin = "10px"; + scrubber.addEventListener("input", this.slideit.bind(this)); + scrubber.addEventListener("change", this.slideit.bind(this)); scrubberDiv.appendChild(scrubber); - // Add aria-label to the otherwise empty child for scrubber: - scrubber.childNodes[0].setAttribute("aria-label", "History slider"); scrubberDiv.appendChild(this.timestampP); // If there is a deadline set then position the scrubber at the last submission // prior to the deadline @@ -1722,14 +1765,13 @@ export class ActiveCode extends RunestoneBase { } i = i - 1; scrubber.value = Math.max(i, 0); - let submittedCode = this.history[scrubber.value]; + let submittedCode = this.history[Number(scrubber.value)]; let code = this.readdLockedCode(submittedCode); this.editor.setValue(code); this.setLockedRegions(); - $(scrubber).slider("value", scrubber.value); } else if (pos_last) { scrubber.value = this.history.length - 1; - let submittedCode = this.history[scrubber.value]; + let submittedCode = this.history[Number(scrubber.value)]; let code = this.readdLockedCode(submittedCode); this.editor.setValue(code); this.setLockedRegions(); @@ -1737,12 +1779,12 @@ export class ActiveCode extends RunestoneBase { scrubber.value = 0; } this.setHighlightLines(); - let pos = $(scrubber).slider("value"); + let pos = Number(scrubber.value); let outOf = this.history.length; - let ts = this.timestamps[$(scrubber).slider("value")]; - $(this.timestampP).text(`${ts} - ${pos + 1} of ${outOf}`); + let ts = this.timestamps[pos]; + this.timestampP.textContent = `${ts} - ${pos + 1} of ${outOf}`; this.historyScrubber = scrubber; - $(scrubberDiv).insertAfter(this.runButton); + this.runButton.insertAdjacentElement("afterend", scrubberDiv); } // end definition of helper createOutput() { @@ -1750,7 +1792,7 @@ export class ActiveCode extends RunestoneBase { // to hold turtle graphics output. We use a div in case the turtle changes from // using a canvas to using some other element like svg in the future. var outDiv = document.createElement("div"); - $(outDiv).addClass("ac_output"); + outDiv.classList.add("ac_output"); this.outDiv = outDiv; this.output = document.createElement("pre"); this.output.id = this.divid + "_stdout"; @@ -1759,28 +1801,30 @@ export class ActiveCode extends RunestoneBase { this.output.setAttribute("aria-atomic", "true"); this.output.setAttribute("role", "log"); this.output.innerHTML = ""; - $(this.output).css("max-height", "400px"); - $(this.output).css("overflow", "auto"); + this.output.style.maxHeight = "400px"; + this.output.style.overflow = "auto"; this.graphics = document.createElement("div"); this.graphics.id = this.divid + "_graphics"; - $(this.graphics).addClass("ac-canvas"); - // This bit of magic adds an event which waits for a canvas child to be created on our - // newly created div. When a canvas child is added we add a new class so that the visible - // canvas can be styled in CSS. Which a the moment means just adding a border. - $(this.graphics).on( - "DOMNodeInserted", - "canvas", - function () { - $(this.graphics).addClass("visible-ac-canvas"); - }.bind(this), - ); + this.graphics.classList.add("ac-canvas"); + // Watch for a canvas child to be created on our newly created div. + // When a canvas child is added we add a new class so that the visible + // canvas can be styled in CSS. Which at the moment means just adding a border. + const canvasObserver = new MutationObserver(() => { + if (this.graphics.querySelector("canvas")) { + this.graphics.classList.add("visible-ac-canvas"); + } + }); + canvasObserver.observe(this.graphics, { + childList: true, + subtree: true, + }); var coachDiv = document.createElement("div"); coachDiv.classList.add("alert", "alert-warning", "codecoach"); coachDiv.setAttribute("aria-live", "polite"); coachDiv.setAttribute("aria-atomic", "true"); coachDiv.setAttribute("role", "log"); - $(coachDiv).css("display", "none"); + coachDiv.style.display = "none"; let coachHead = coachDiv.appendChild(document.createElement("h3")); coachHead.textContent = t("msg_activecode_code_coach"); this.outerDiv.appendChild(coachDiv); @@ -1792,7 +1836,7 @@ export class ActiveCode extends RunestoneBase { var lensDiv = document.createElement("div"); lensDiv.classList.add("codelens"); lensDiv.id = `${this.divid}_codelens`; - $(lensDiv).css("display", "none"); + lensDiv.style.display = "none"; this.codelens = lensDiv; this.outerDiv.appendChild(lensDiv); // Put the outer container for error messages in the dom @@ -1807,10 +1851,14 @@ export class ActiveCode extends RunestoneBase { } disableSaveLoad() { - $(this.saveButton).addClass("disabled"); - $(this.saveButton).attr("title", "Login to save your code"); - $(this.loadButton).addClass("disabled"); - $(this.loadButton).attr("title", "Login to load your code"); + if (this.saveButton) { + this.saveButton.classList.add("disabled"); + this.saveButton.title = "Login to save your code"; + } + if (this.loadButton) { + this.loadButton.classList.add("disabled"); + this.loadButton.title = "Login to load your code"; + } } computeChangesPerSecond() { @@ -1965,21 +2013,27 @@ export class ActiveCode extends RunestoneBase { } else { body = "

The server did not return any grade information

"; } - var html = ``; + dialog + .querySelector("button.close") + .addEventListener("click", () => dialog.close()); + dialog.addEventListener("close", () => dialog.remove()); + document.body.appendChild(dialog); + if (typeof dialog.showModal === "function") { + dialog.showModal(); + } else { + dialog.setAttribute("open", ""); + } return response; } @@ -2022,7 +2076,7 @@ export class ActiveCode extends RunestoneBase { myVars.codeDivWidth = 350; myVars.codeDivHeight = 400; var srcURL = "https://pythontutor.com/iframe-embed.html"; - var srcVars = $.param(myVars); + var srcVars = new URLSearchParams(myVars).toString(); var embedUrlStr = `${srcURL}#${srcVars}`; var myIframe = document.createElement("iframe"); myIframe.setAttribute("id", this.divid + "_codelens"); @@ -2055,8 +2109,9 @@ export class ActiveCode extends RunestoneBase { // Add the error message this.errLastRun = true; console.log(err); - var errHead = $("

").html("Error"); - this.eContainer.appendChild(errHead[0]); + var errHead = document.createElement("h3"); + errHead.innerHTML = "Error"; + this.eContainer.appendChild(errHead); var errText = this.eContainer.appendChild( document.createElement("pre"), ); @@ -2086,10 +2141,10 @@ Yet another is that there is an internal error. The internal error message is: var to = errString.indexOf(":"); var errName = errString.substring(0, to); errText.innerHTML = errString; - $(this.eContainer).append("

Description

"); + this.eContainer.insertAdjacentHTML("beforeend", "

Description

"); var errDesc = this.eContainer.appendChild(document.createElement("p")); errDesc.innerHTML = errorText[errName]; - $(this.eContainer).append("

To Fix

"); + this.eContainer.insertAdjacentHTML("beforeend", "

To Fix

"); var errFix = this.eContainer.appendChild(document.createElement("p")); errFix.innerHTML = errorText[errName + "Fix"]; var moreInfo = "../ErrorHelp/" + errName.toLowerCase() + ".html"; @@ -2179,13 +2234,22 @@ Yet another is that there is an internal error. The internal error message is: // have these extensions. We don't want to try to load them from the DB. if (!(fileName.endsWith(".js") || fileName.endsWith(".py"))) { let result = null; - $.ajax({ - async: false, - url: `/ns/logger/get_source_code?course_id=${eBookConfig.course}&filename=${fileName}`, - success: function (data) { - result = data.detail.file_contents; - }, - }); + // Skulpt's read hook is synchronous, so this request must be + // synchronous as well (as the old $.ajax async:false call was). + let xhr = new XMLHttpRequest(); + xhr.open( + "GET", + `/ns/logger/get_source_code?course_id=${eBookConfig.course}&filename=${fileName}`, + false, + ); + try { + xhr.send(); + if (xhr.status >= 200 && xhr.status < 300) { + result = JSON.parse(xhr.responseText).detail.file_contents; + } + } catch (e) { + console.log(`failed to fetch ${fileName}: ${e}`); + } if (result) { return result; } else { @@ -2210,12 +2274,18 @@ Yet another is that there is an internal error. The internal error message is: setTimeout( function () { if (this.outputLineCount < 1000) { - $(this.output).append(text); + this.output.insertAdjacentHTML( + "beforeend", + text, + ); this.outputLineCount += 1; resolve(Sk.builtin.none.none$); } else { if (this.outputLineCount == 1000) { - $(this.output).append("Too Much output"); + this.output.insertAdjacentHTML( + "beforeend", + "Too Much output", + ); this.outputLineCount += 1; stopExecution = true; resolve(Sk.builtin.none.none$); @@ -2233,28 +2303,28 @@ Yet another is that there is an internal error. The internal error message is: let filecomponent = document.getElementById(fobj.name); if (!filecomponent) { let container = document.createElement("div"); - $(container).addClass("runestone"); + container.classList.add("runestone"); let tab = document.createElement("div"); - $(tab).addClass("datafile_caption"); + tab.classList.add("datafile_caption"); tab.innerHTML = `Data file: ${fobj.name}`; filecomponent = document.createElement("textarea"); filecomponent.rows = 10; filecomponent.cols = 50; filecomponent.id = fobj.name; - $(filecomponent).css("margin-bottom", "5px"); - $(filecomponent).addClass("ac_output"); + filecomponent.style.marginBottom = "5px"; + filecomponent.classList.add("ac_output"); container.appendChild(tab); container.appendChild(filecomponent); this.outerDiv.appendChild(container); } else { if (fobj.pos$ == 0) { - $(filecomponent).val(""); + filecomponent.value = ""; } } - let current = $(filecomponent).val(); + let current = filecomponent.value; current = current + bytes.v; - $(filecomponent).val(current); - $(filecomponent).css("display", "block"); + filecomponent.value = current; + filecomponent.style.display = "block"; fobj.pos$ = current.length; return current.length; } @@ -2333,24 +2403,16 @@ Yet another is that there is an internal error. The internal error message is: let userCode = this.trimLockedCode(this.editor.getValue()); if ( this.historyScrubber && - this.history[$(this.historyScrubber).slider("value")] != userCode + this.history[Number(this.historyScrubber.value)] != userCode ) { saveCode = "True"; this.history.push(userCode); this.timestamps.push(new Date().toLocaleString()); - $(this.historyScrubber).slider( - "option", - "max", - this.history.length - 1, - ); - $(this.historyScrubber).slider( - "option", - "value", - this.history.length - 1, - ); - // is this needed? changing value in previous statement - // already triggers slideit function - //this.slideit(null); + this.historyScrubber.max = this.history.length - 1; + this.historyScrubber.value = this.history.length - 1; + // Unlike the old jQuery UI slider, setting .value does not fire + // an event, so update the editor and label explicitly. + this.slideit(null); } else { saveCode = "False"; } @@ -2407,8 +2469,10 @@ Yet another is that there is an internal error. The internal error message is: //Run all available code coaches and update code coach div //clear anything after header in codecoach div and hide it - $(this.codecoach).children().slice(1).remove(); - $(this.codecoach).css("display", "none"); + while (this.codecoach.children.length > 1) { + this.codecoach.lastElementChild.remove(); + } + this.codecoach.style.display = "none"; //get code, run coaches //let code = await this.buildProg(false); @@ -2432,7 +2496,7 @@ Yet another is that there is an internal error. The internal error message is: document.createElement("pre"), ); checkPre.textContent = p.value; - $(this.codecoach).css("display", "block"); + this.codecoach.style.display = "block"; // screenreaders seem to miss error message without the delay setTimeout(() => { this.codecoach.append(checkDiv); @@ -2451,12 +2515,13 @@ Yet another is that there is an internal error. The internal error message is: let urDivid = `${this.divid}_offscreen_unit_results`; let unitFeedback = document.getElementById(urDivid); let tmp = document.body.removeChild(unitFeedback); - if ($(this.outerDiv).find(`#${urDivid}`).length > 0) { - tmp = $(this.outerDiv).find(`#${urDivid}`)[0]; + let existing = this.outerDiv.querySelector(`[id="${urDivid}"]`); + if (existing) { + tmp = existing; } else { this.outerDiv.appendChild(tmp); } - $(tmp).show(); + tmp.style.display = ""; } else { let urDivid = this.divid + "_unit_results"; @@ -2475,9 +2540,10 @@ Yet another is that there is an internal error. The internal error message is: } if ( - $(this.outerDiv).find(`#${urDivid}`).length == 0 && - $(this.outerDiv).find(`#${urDivid}_offscreen_unit_results`) - .length == 0 + !this.outerDiv.querySelector(`[id="${urDivid}"]`) && + !this.outerDiv.querySelector( + `[id="${urDivid}_offscreen_unit_results"]`, + ) ) { let urResults = document.getElementById(urDivid); // why do we do this? This doesn't seem to matter for grading... @@ -2536,7 +2602,7 @@ Yet another is that there is an internal error. The internal error message is: var prog = await this.buildProg(true); this.coachCode = prog; this.saveCode = "True"; - $(this.output).text(""); + this.output.textContent = ""; if (this.unit_results_divid) { let urdiv = document.getElementById( this.unit_results_divid + "_unit_results", @@ -2581,9 +2647,10 @@ Yet another is that there is an internal error. The internal error message is: Sk.canvas = this.graphics.id; //todo: get rid of this here and in image if (!noUI) { this.saveCode = await this.manage_scrubber(this.saveCode); - $(this.runButton).attr("disabled", "disabled"); - $(this.historyScrubber).off("slidechange"); - $(this.historyScrubber).slider("disable"); + this.runButton.disabled = true; + if (this.historyScrubber) { + this.historyScrubber.disabled = true; + } this.outDiv.style.visibility = "visible"; } try { @@ -2602,23 +2669,17 @@ Yet another is that there is an internal error. The internal error message is: }, ); if (!noUI) { - if (this.slideit) { - $(this.historyScrubber).on( - "slidechange", - this.slideit.bind(this), - ); + if (this.historyScrubber) { + this.historyScrubber.disabled = false; } - $(this.historyScrubber).slider("enable"); } this.errLastRun = false; this.errinfo = "success"; } catch (err) { if (!noUI) { - $(this.historyScrubber).on( - "slidechange", - this.slideit.bind(this), - ); - $(this.historyScrubber).slider("enable"); + if (this.historyScrubber) { + this.historyScrubber.disabled = false; + } } this.errinfo = err.toString(); this.eContainer.style.visibility = "visible"; @@ -2626,20 +2687,20 @@ Yet another is that there is an internal error. The internal error message is: this.addErrorMessage(err); }, 10); } finally { - $(this.runButton).removeAttr("disabled"); + this.runButton.disabled = false; this.firstAfterRun = true; if (typeof window.allVisualizers != "undefined") { - $.each(window.allVisualizers, function (i, e) { + for (const e of Object.values(window.allVisualizers)) { e.redrawConnectors(); - }); + } } this.editor.acEditEvent = false; } } disableInteraction() { - $(this.runButton).hide(); - $(this.codeDiv).addClass("ac-disabled"); + this.runButton.style.display = "none"; + this.codeDiv.classList.add("ac-disabled"); } } diff --git a/bases/rsptx/interactives/runestone/activecode/js/activecode_html.js b/bases/rsptx/interactives/runestone/activecode/js/activecode_html.js index deecec280..caa576955 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/activecode_html.js +++ b/bases/rsptx/interactives/runestone/activecode/js/activecode_html.js @@ -4,11 +4,11 @@ import { t } from "../../common/js/rsi18n.js"; export default class HTMLActiveCode extends ActiveCode { constructor(opts) { super(opts); - // The base class reads code via $(orig).text() which already decodes HTML entities + // The base class reads code via textContent which already decodes HTML entities // (the browser HTML parser decodes < etc. in textarea content). // We must NOT re-read from origElem.innerHTML here — that would re-include the // suffix that the base class already stripped into this.suffix. - $(this.runButton).text("Render"); + this.runButton.textContent = "Render"; this.editor.setValue(this.code); this._messageHandler = null; } @@ -53,7 +53,7 @@ export default class HTMLActiveCode extends ActiveCode { prog; } - $(this.output).text(""); + this.output.textContent = ""; this.output.srcdoc = prog; if (this.unit_results) { @@ -309,13 +309,13 @@ export default class HTMLActiveCode extends ActiveCode { createOutput() { var outDiv = document.createElement("div"); - $(outDiv).addClass("ac_output"); + outDiv.classList.add("ac_output"); this.outDiv = outDiv; this.output = document.createElement("iframe"); - $(this.output).css("background-color", "white"); - $(this.output).css("position", "relative"); - $(this.output).css("height", "400px"); - $(this.output).css("width", "100%"); + this.output.style.backgroundColor = "white"; + this.output.style.position = "relative"; + this.output.style.height = "400px"; + this.output.style.width = "100%"; outDiv.appendChild(this.output); this.testResultsDiv = document.createElement("div"); @@ -325,11 +325,11 @@ export default class HTMLActiveCode extends ActiveCode { this.outerDiv.appendChild(outDiv); var clearDiv = document.createElement("div"); - $(clearDiv).css("clear", "both"); // needed to make parent div resize properly + clearDiv.style.clear = "both"; // needed to make parent div resize properly this.outerDiv.appendChild(clearDiv); } enableSaveLoad() { - $(this.runButton).text(t("msg_activecode_render")); + this.runButton.textContent = t("msg_activecode_render"); } } diff --git a/bases/rsptx/interactives/runestone/activecode/js/activecode_js.js b/bases/rsptx/interactives/runestone/activecode/js/activecode_js.js index 6a2a3c8be..748b4e868 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/activecode_js.js +++ b/bases/rsptx/interactives/runestone/activecode/js/activecode_js.js @@ -5,7 +5,7 @@ export default class JSActiveCode extends ActiveCode { super(opts); } outputfun(a) { - $(this.output).css("visibility", "visible"); + this.output.style.visibility = "visible"; var str = "["; if (typeof a == "object" && a.length) { for (var i = 0; i < a.length; i++) @@ -39,9 +39,12 @@ export default class JSActiveCode extends ActiveCode { _this.output.innerHTML += _this.outputfun(str) + "
"; }; this.saveCode = await this.manage_scrubber(saveCode); - $(this.eContainer).remove(); - $(this.output).text(""); - $(this.outDiv).show({ duration: 700, queue: false }); + if (this.eContainer) { + this.eContainer.remove(); + } + this.output.textContent = ""; + this.outDiv.style.display = ""; + this.outDiv.style.visibility = "visible"; try { eval(prog); this.errinfo = "success"; @@ -54,7 +57,8 @@ export default class JSActiveCode extends ActiveCode { addErrorMessage(err) { // Add the error message this.errLastRun = true; - var errHead = $("

").html("Error"); + var errHead = document.createElement("h3"); + errHead.innerHTML = "Error"; this.eContainer = this.outerDiv.appendChild( document.createElement("div"), ); @@ -63,7 +67,7 @@ export default class JSActiveCode extends ActiveCode { this.eContainer.setAttribute("role", "log"); this.eContainer.className = "error alert alert-danger"; this.eContainer.id = this.divid + "_errinfo"; - this.eContainer.appendChild(errHead[0]); + this.eContainer.appendChild(errHead); var errText = this.eContainer.appendChild( document.createElement("pre"), ); diff --git a/bases/rsptx/interactives/runestone/activecode/js/activecode_sql.js b/bases/rsptx/interactives/runestone/activecode/js/activecode_sql.js index dbd289fac..5b2a55f04 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/activecode_sql.js +++ b/bases/rsptx/interactives/runestone/activecode/js/activecode_sql.js @@ -1,5 +1,6 @@ import { ActiveCode } from "./activecode.js"; import { t } from "../../common/js/rsi18n.js"; +import { getDataValue } from "../../common/js/domutil.js"; import Handsontable from "handsontable"; import "handsontable/dist/handsontable.full.css"; import initSqlJs from "sql.js/dist/sql-wasm.js"; @@ -27,7 +28,7 @@ export default class SQLActiveCode extends ActiveCode { this.config = { locateFile: (filename) => `${fnprefix}/${filename}`, }; - this.showLast = $(this.origElem).data("showlastsql"); + this.showLast = getDataValue(this.origElem, "showlastsql"); var self = this; initSqlJs(this.config).then(function (SQL) { // set up call to load database asynchronously if given @@ -48,26 +49,33 @@ export default class SQLActiveCode extends ActiveCode { self.dburl = `${self.dburl}`; } } - $(self.runButton).attr("disabled", "disabled"); - let buttonText = $(self.runButton).text(); - $(self.runButton).text(t("msg_activecode_load_db")); + self.runButton.disabled = true; + let buttonText = self.runButton.textContent; + self.runButton.textContent = t("msg_activecode_load_db"); if (!(self.dburl in allDburls)) { + // an externally-resolvable promise other instances can + // wait on while the first instance loads the database + let resolveWait; + let waitFor = new Promise((resolve) => { + resolveWait = resolve; + }); allDburls[self.dburl] = { status: "loading", - xWaitFor: jQuery.Deferred(), + xWaitFor: waitFor, + resolveWait: resolveWait, }; } else { if (allDburls[self.dburl].status == "loading") { - allDburls[self.dburl].xWaitFor.done(function () { + allDburls[self.dburl].xWaitFor.then(function () { self.db = allDburls[self.dburl].dbObject; - $(self.runButton).removeAttr("disabled"); - $(self.runButton).text(buttonText); + self.runButton.disabled = false; + self.runButton.textContent = buttonText; }); return; } self.db = allDburls[self.dburl].dbObject; - $(self.runButton).removeAttr("disabled"); - $(self.runButton).text(buttonText); + self.runButton.disabled = false; + self.runButton.textContent = buttonText; return; } var xhr = new XMLHttpRequest(); @@ -78,11 +86,11 @@ export default class SQLActiveCode extends ActiveCode { var uInt8Array = new Uint8Array(xhr.response); self.db = new SQL.Database(uInt8Array); allDburls[self.dburl].dbObject = self.db; - $(self.runButton).text(buttonText); - $(self.runButton).removeAttr("disabled"); + self.runButton.textContent = buttonText; + self.runButton.disabled = false; allDburls[self.dburl].db = uInt8Array; allDburls[self.dburl].status = "ready"; - allDburls[self.dburl].xWaitFor.resolve(); + allDburls[self.dburl].resolveWait(); // contents is now [{columns:['col1','col2',...], values:[[first row], [second row], ...]}] }; xhr.send(); @@ -107,13 +115,11 @@ export default class SQLActiveCode extends ActiveCode { if (respDiv) { respDiv.parentElement.removeChild(respDiv); } - $(this.output).text(""); + this.output.textContent = ""; // Run this query let query = await this.buildProg(false); // false --> Do not include suffix if (!this.db) { - $(this.output).text( - `Error: Database not initialized! DBURL: ${this.dburl}`, - ); + this.output.textContent = `Error: Database not initialized! DBURL: ${this.dburl}`; return; } @@ -174,13 +180,9 @@ export default class SQLActiveCode extends ActiveCode { try { this.saveCode = await this.manage_scrubber(this.saveCode); - if (this.slideit) { - $(this.historyScrubber).on( - "slidechange", - this.slideit.bind(this), - ); + if (this.historyScrubber) { + this.historyScrubber.disabled = false; } - $(this.historyScrubber).slider("enable"); } catch (e) { console.log(`Failed to update scrubber ${e}`); } @@ -252,7 +254,7 @@ export default class SQLActiveCode extends ActiveCode { this.results[this.results.length - 1], ); } else { - $(this.output).css("visibility", "hidden"); + this.output.style.visibility = "hidden"; } return Promise.resolve("done"); @@ -290,8 +292,8 @@ export default class SQLActiveCode extends ActiveCode { renderFeedback() { if (this.testResult) { - $(this.output).text(this.testResult); - $(this.output).css("visibility", "visible"); + this.output.textContent = this.testResult; + this.output.style.visibility = "visible"; } } diff --git a/bases/rsptx/interactives/runestone/activecode/js/audiotour.js b/bases/rsptx/interactives/runestone/activecode/js/audiotour.js index 7b825ad5f..24659d4b3 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/audiotour.js +++ b/bases/rsptx/interactives/runestone/activecode/js/audiotour.js @@ -105,7 +105,7 @@ export default class AudioTour extends RunestoneBase { this.audio_code = document.createElement("p"); this.windowcode = document.createElement("div"); this.windowcode.align = "left"; - $(this.windowcode).html(first); + this.windowcode.innerHTML = first; this.first_audio = document.createElement("button"); this.prev_audio = document.createElement("button"); this.pause_audio = document.createElement("button"); @@ -169,7 +169,7 @@ export default class AudioTour extends RunestoneBase { this.stop_button = document.createElement("button"); this.stop_button.className = "btn btn-default"; this.stop_button.innerHTML = "Stop tour"; - $(this.audio_tour).append( + this.audio_tour.append( this.audio_code, this.windowcode, document.createElement("br"), @@ -181,14 +181,25 @@ export default class AudioTour extends RunestoneBase { document.createElement("br"), this.status, document.createElement("br"), - this.tourButtons, + ...this.tourButtons, this.stop_button, ); - $("#" + divid + " .ac_code_div").append(this.audio_tour); - $("#" + divid + " .ac_code_div").css("width", "100%"); - $("#" + divid + " .CodeMirror.cm-s-default.ui-resizable").hide(); - $("#" + divid + " .ac_opt.btn.btn-default:last-child").hide(); - $(this.stop_button).click( + let acContainer = document.getElementById(divid); + let codeDiv = acContainer.querySelector(".ac_code_div"); + let editorEl = acContainer.querySelector(".CodeMirror.ac-resizable"); + let lastOptButton = acContainer.querySelector( + ".ac_opt.btn.btn-default:last-child", + ); + codeDiv.appendChild(this.audio_tour); + codeDiv.style.width = "100%"; + if (editorEl) { + editorEl.style.display = "none"; + } + if (lastOptButton) { + lastOptButton.style.display = "none"; + } + this.stop_button.addEventListener( + "click", function () { if (this.playing) { this.elem.pause(); @@ -199,75 +210,67 @@ export default class AudioTour extends RunestoneBase { act: "closeWindow", div_id: divid, }); - $(this.audio_tour).remove(); - $( - "#" + divid + " .CodeMirror.cm-s-default.ui-resizable", - ).show(); - $("#" + divid + " .ac_opt.btn.btn-default:last-child").show(); - $("#" + divid + " .ac_code_div").css("width", ""); - }.bind(this), - ); - $(this.tourButtons[0]).click( - function () { - this.tour(divid, audio_hash[0], bcount); - }.bind(this), - ); - $(this.tourButtons[1]).click( - function () { - this.tour(divid, audio_hash[1], bcount); - }.bind(this), - ); - $(this.tourButtons[2]).click( - function () { - this.tour(divid, audio_hash[2], bcount); - }.bind(this), - ); - $(this.tourButtons[3]).click( - function () { - this.tour(divid, audio_hash[3], bcount); - }.bind(this), - ); - $(this.tourButtons[4]).click( - function () { - this.tour(divid, audio_hash[4], bcount); + this.audio_tour.remove(); + if (editorEl) { + editorEl.style.display = ""; + } + if (lastOptButton) { + lastOptButton.style.display = ""; + } + codeDiv.style.width = ""; }.bind(this), ); - // handle the click to go to the next audio - $(this.first_audio).click( + for (let i = 0; i < 5; i++) { + if (this.tourButtons[i]) { + this.tourButtons[i].addEventListener( + "click", + function () { + this.tour(divid, audio_hash[i], bcount); + }.bind(this), + ); + } + } + // handle the click to go to the first audio + this.first_audio.addEventListener( + "click", function () { this.firstAudio(); }.bind(this), ); - // handle the click to go to the next audio - $(this.prev_audio).click( + // handle the click to go to the previous audio + this.prev_audio.addEventListener( + "click", function () { this.prevAudio(); }.bind(this), ); // handle the click to pause or play the audio - $(this.pause_audio).click( + this.pause_audio.addEventListener( + "click", function () { this.pauseAndPlayAudio(divid); }.bind(this), ); // handle the click to go to the next audio - $(this.next_audio).click( + this.next_audio.addEventListener( + "click", function () { this.nextAudio(); }.bind(this), ); - // handle the click to go to the next audio - $(this.last_audio).click( + // handle the click to go to the last audio + this.last_audio.addEventListener( + "click", function () { this.lastAudio(); }.bind(this), ); // make the image buttons look disabled - $(this.first_audio).css("opacity", 0.25); - $(this.prev_audio).css("opacity", 0.25); - $(this.pause_audio).css("opacity", 0.25); - $(this.next_audio).css("opacity", 0.25); - $(this.last_audio).css("opacity", 0.25); + this.first_audio.style.opacity = 0.25; + this.prev_audio.style.opacity = 0.25; + this.pause_audio.style.opacity = 0.25; + this.next_audio.style.opacity = 0.25; + this.last_audio.style.opacity = 0.25; } tour(divid, audio_type, bcount) { // set globals @@ -278,23 +281,22 @@ export default class AudioTour extends RunestoneBase { "display: inline-block; margin-top: 7px; margin-bottom: 3px;", ); // enable prev, pause/play and next buttons and make visible - $(this.first_audio).removeAttr("disabled"); - $(this.prev_audio).removeAttr("disabled"); - $(this.pause_audio).removeAttr("disabled"); - $(this.next_audio).removeAttr("disabled"); - $(this.last_audio).removeAttr("disabled"); - $(this.first_audio).css("opacity", 1.0); - $(this.prev_audio).css("opacity", 1.0); - $(this.pause_audio).css("opacity", 1.0); - $(this.next_audio).css("opacity", 1.0); - $(this.last_audio).css("opacity", 1.0); + this.first_audio.disabled = false; + this.prev_audio.disabled = false; + this.pause_audio.disabled = false; + this.next_audio.disabled = false; + this.last_audio.disabled = false; + this.first_audio.style.opacity = 1.0; + this.prev_audio.style.opacity = 1.0; + this.pause_audio.style.opacity = 1.0; + this.next_audio.style.opacity = 1.0; + this.last_audio.style.opacity = 1.0; // disable tour buttons - for (var i = 0; i < bcount; i++) - $(this.tourButtons[i]).attr("disabled", "disabled"); + for (var i = 0; i < bcount; i++) this.tourButtons[i].disabled = true; var atype = audio_type.split(";"); var name = atype[0].replaceAll('"', " "); this.tourName = name; - $(this.status).html(t("msg_activecode_starting", name)); + this.status.innerHTML = t("msg_activecode_starting", name); //log tour type to db this.logBookEvent({ event: "Audio", act: name, div_id: divid }); var max = atype.length; @@ -325,7 +327,7 @@ export default class AudioTour extends RunestoneBase { this.ahash[akey] = lnums; this.aname.push(akey); } - $(this.audio_code).html(str); + this.audio_code.innerHTML = str; this.len = this.aname.length; // set the number of audio file in the tour this.currIndex = 0; this.playCurrIndexAudio(); @@ -333,7 +335,7 @@ export default class AudioTour extends RunestoneBase { handlePlaying() { this.elem.pause(); // unbind current ended - $("#" + this.afile).unbind("ended"); + this.elem.onended = null; // unhighlight the prev lines this.unhighlightLines( this.theDivid, @@ -412,28 +414,30 @@ export default class AudioTour extends RunestoneBase { } // handle the end of the tour handleTourEnd() { - $(this.status).html("The " + this.tourName + " has ended."); + this.status.innerHTML = "The " + this.tourName + " has ended."; this.pause_audio.className = "btn-default glyphicon glyphicon-pause"; this.pause_audio.title = "Pause audio"; this.pause_audio.setAttribute("aria-label", "Pause audio"); - $(this.first_audio).attr("disabled", "disabled"); - $(this.prev_audio).attr("disabled", "disabled"); - $(this.pause_audio).attr("disabled", "disabled"); - $(this.next_audio).attr("disabled", "disabled"); - $(this.last_audio).attr("disabled", "disabled"); - $(this.first_audio).css("opacity", 0.25); - $(this.prev_audio).css("opacity", 0.25); - $(this.pause_audio).css("opacity", 0.25); - $(this.next_audio).css("opacity", 0.25); - $(this.last_audio).css("opacity", 0.25); + this.first_audio.disabled = true; + this.prev_audio.disabled = true; + this.pause_audio.disabled = true; + this.next_audio.disabled = true; + this.last_audio.disabled = true; + this.first_audio.style.opacity = 0.25; + this.prev_audio.style.opacity = 0.25; + this.pause_audio.style.opacity = 0.25; + this.next_audio.style.opacity = 0.25; + this.last_audio.style.opacity = 0.25; // enable the tour buttons for (var j = 0; j < this.buttonCount; j++) - $(this.tourButtons[j]).removeAttr("disabled"); + this.tourButtons[j].disabled = false; } // only call this one after the first time outerAudio() { // unbind ended - $("#" + this.afile).unbind("ended"); + if (this.elem) { + this.elem.onended = null; + } // set this.playing to false this.playing = false; // unhighlight previous lines from the last audio @@ -456,30 +460,20 @@ export default class AudioTour extends RunestoneBase { // play the audio now that it is ready playWhenReady(afile, divid, ahash) { // unbind current - $("#" + afile).unbind("canplaythrough"); + this.elem.oncanplaythrough = null; this.elem.currentTime = 0; this.playing = true; //console.log("in playWhenReady " + elem.duration); this.highlightLines(divid, ahash[afile]); + this.elem.onended = function () { + this.outerAudio(); + }.bind(this); if ( this.pause_audio.className === "btn-default glyphicon glyphicon-pause" ) { - $(this.status).html(t("msg_activecode_playing", this.tourName)); - $("#" + afile).bind( - "ended", - function () { - this.outerAudio(); - }.bind(this), - ); + this.status.innerHTML = t("msg_activecode_playing", this.tourName); this.elem.play(); - } else { - $("#" + afile).bind( - "ended", - function () { - this.outerAudio(); - }.bind(this), - ); } } // play the audio at the specified index i and set the duration and highlight the lines @@ -490,13 +484,10 @@ export default class AudioTour extends RunestoneBase { //console.log("in playaudio " + elem.duration); if (isNaN(this.elem.duration) || this.elem.duration == 0) { // set the status - $(this.status).html(t("msg_activecode_loading_audio")); - $("#" + this.afile).bind( - "canplaythrough", - function () { - this.playWhenReady(this.afile, divid, ahash); - }.bind(this), - ); + this.status.innerHTML = t("msg_activecode_loading_audio"); + this.elem.oncanplaythrough = function () { + this.playWhenReady(this.afile, divid, ahash); + }.bind(this); } // otherwise it is ready so play it else { @@ -518,7 +509,7 @@ export default class AudioTour extends RunestoneBase { "aria-label", t("msg_activecode_pause_audio"), ); - $(this.status).html(t("msg_activecode_playing", this.tourName)); + this.status.innerHTML = t("msg_activecode_playing", this.tourName); //log change to db this.logBookEvent({ event: "Audio", @@ -535,8 +526,9 @@ export default class AudioTour extends RunestoneBase { "aria-label", t("msg_activecode_play_paused_audio"), ); - $(this.status).html( - t("msg_activecode_audio_paused", this.tourName), + this.status.innerHTML = t( + "msg_activecode_audio_paused", + this.tourName, ); //log change to db this.logBookEvent({ @@ -574,18 +566,16 @@ export default class AudioTour extends RunestoneBase { var start = parseInt(hyphen[0]); var end = parseInt(hyphen[1]) + 1; for (var k = start; k < end; k++) { - //alert(k); - str = "#" + divid + "_l" + k; - if ($(str).text() != "") { - $(str).css("background-color", color); + let line = document.getElementById(divid + "_l" + k); + if (line && line.textContent != "") { + line.style.backgroundColor = color; } - //$(str).effect("highlight",{},(dur*1000)+4500); } } else { - //alert(lnum); - str = "#" + divid + "_l" + lnum; - $(str).css("background-color", color); - //$(str).effect("highlight",{},(dur*1000)+4500); + let line = document.getElementById(divid + "_l" + lnum); + if (line) { + line.style.backgroundColor = color; + } } } } diff --git a/bases/rsptx/interactives/runestone/activecode/js/livecode.js b/bases/rsptx/interactives/runestone/activecode/js/livecode.js index fbfae391e..d61fa6652 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/livecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/livecode.js @@ -1,5 +1,6 @@ import { ActiveCode } from "./activecode.js"; import { t } from "../../common/js/rsi18n.js"; +import { getDataValue } from "../../common/js/domutil.js"; import MD5 from "./md5.js"; import JUnitTestParser from "./extractUnitResults-JUnit.js"; import DoctestTestParser from "./extractUnitResults-Doctest.js"; @@ -8,18 +9,21 @@ import { base64encode } from "byte-base64"; export default class LiveCode extends ActiveCode { constructor(opts) { - var orig = $(opts.orig).find("textarea")[0]; + var orig = opts.orig.querySelector("textarea"); super(opts); - this.stdin = $(orig).data("stdin"); - this.additional_files = $(orig).data("add-files"); + this.stdin = getDataValue(orig, "stdin"); + this.additional_files = getDataValue(orig, "add-files"); // Accept older "datafile" attribute for backwards compatibility - this.datafiles = $(orig).data("datafile"); - this.sourcefile = $(orig).data("sourcefile"); - this.compileargs = unescapeHtml($(orig).data("compileargs")) || ""; - this.compileAlso = unescapeHtml($(orig).data("compile-also")); - this.linkargs = unescapeHtml($(orig).data("linkargs")); - this.runargs = unescapeHtml($(orig).data("runargs")); - this.interpreterargs = unescapeHtml($(orig).data("interpreterargs")); + this.datafiles = getDataValue(orig, "datafile"); + this.sourcefile = getDataValue(orig, "sourcefile"); + this.compileargs = + unescapeHtml(getDataValue(orig, "compileargs")) || ""; + this.compileAlso = unescapeHtml(getDataValue(orig, "compile-also")); + this.linkargs = unescapeHtml(getDataValue(orig, "linkargs")); + this.runargs = unescapeHtml(getDataValue(orig, "runargs")); + this.interpreterargs = unescapeHtml( + getDataValue(orig, "interpreterargs"), + ); this.API_KEY = "67033pV7eUUvqo07OJDIV8UZ049aLEK1"; this.USE_API_KEY = true; this.JOBE_SERVER = eBookConfig.jobehost || eBookConfig.host; @@ -40,7 +44,7 @@ export default class LiveCode extends ActiveCode { let inputContainer = document.createElement("div"); var label = document.createElement("label"); label.for = this.divid + "_stdin"; - $(label).text(t("msg_activecode_input_prg")); + label.textContent = t("msg_activecode_input_prg"); var input = document.createElement("textarea"); input.id = this.divid + "_stdin"; input.classList.add("activecode__stdin"); @@ -99,7 +103,7 @@ export default class LiveCode extends ActiveCode { this.addJobeErrorMessage( t(`Server Error: ${res.statusText}`), ); - $(this.runButton).removeAttr("disabled"); + this.runButton.disabled = false; return "fail"; } let runResults = await res.json(); @@ -120,7 +124,7 @@ export default class LiveCode extends ActiveCode { this.addJobeErrorMessage( t(`Server Error: ${iores.statusText}`), ); - $(this.runButton).removeAttr("disabled"); + this.runButton.disabled = false; return "fail"; } let result = await iores.json(); @@ -134,7 +138,7 @@ export default class LiveCode extends ActiveCode { this.addJobeErrorMessage( t("msg_activecode_server_comm_err") + e.toString(), ); - $(this.runButton).removeAttr("disabled"); + this.runButton.disabled = false; return `fail: ${e}`; } return "success"; @@ -239,13 +243,13 @@ export default class LiveCode extends ActiveCode { } if (this.stdin) { - stdin = $(this.stdin_el).val(); + stdin = this.stdin_el.value; } if (!this.sourcefile) { this.sourcefile = sfilemap[this.language]; } - $(this.output).html(t("msg_activecode_compiling_running")); + this.output.innerHTML = t("msg_activecode_compiling_running"); // Need to handle additional files // that come from additional_files (based on ids) and datafiles (based on filenames) @@ -337,14 +341,17 @@ export default class LiveCode extends ActiveCode { // do nothing, we will just use the original code } } - // Now make request for source_code - $.ajax({ - async: false, - url: url, - success: function (data) { - result = data.detail; - }, - }); + // Now make request for source_code. This lookup was + // historically synchronous ($.ajax async:false), but we are + // in an async function, so a normal fetch works here. + try { + let response = await fetch(url); + if (response.ok) { + result = (await response.json()).detail; + } + } catch (e) { + console.log(`failed to fetch ${url}: ${e}`); + } if (result) { // favor student code if it exists content = studentCode || result.file_contents; @@ -476,10 +483,13 @@ export default class LiveCode extends ActiveCode { async submitToJobe() { var data = this.json_runspec; let host = this.JOBE_SERVER + this.resource; - $(this.runButton).attr("disabled", "disabled"); - $(this.outDiv).show({ duration: 700, queue: false }); - $(this.errDiv).remove(); - $(this.output).css("visibility", "visible"); + this.runButton.disabled = true; + this.outDiv.style.display = ""; + this.outDiv.style.visibility = "visible"; + if (this.errDiv) { + this.errDiv.remove(); + } + this.output.style.visibility = "visible"; let headers = new Headers({ "Content-type": "application/json; charset=utf-8", @@ -501,7 +511,7 @@ export default class LiveCode extends ActiveCode { var logresult; var odiv = this.output; this.parsedOutput = {}; - $(this.runButton).removeAttr("disabled"); + this.runButton.disabled = false; if (result.outcome === 15) { logresult = "success"; } else { @@ -515,7 +525,7 @@ export default class LiveCode extends ActiveCode { result.stdout, this.divid, ); - $(odiv).html(this.parsedOutput.stdout); + odiv.innerHTML = this.parsedOutput.stdout; } else if ( this.language === "cpp" && result.stdout.includes("[doctest]") @@ -524,10 +534,10 @@ export default class LiveCode extends ActiveCode { result.stdout, this.divid, ); - $(odiv).html(this.parsedOutput.stdout); + odiv.innerHTML = this.parsedOutput.stdout; } else { let output = result.stdout ? result.stdout : ""; - $(odiv).html(output); + odiv.innerHTML = output; } if (this.hasUnitTests() || this.iotests) { if (this.parsedOutput.pct === undefined) { @@ -541,7 +551,7 @@ export default class LiveCode extends ActiveCode { break; } case 11: // compiler error - $(odiv).html(t("msg_activecode_were_compiling_err")); + odiv.innerHTML = t("msg_activecode_were_compiling_err"); this.addJobeErrorMessage(result.cmpinfo); this.errinfo = result.cmpinfo; break; @@ -553,22 +563,25 @@ export default class LiveCode extends ActiveCode { this.compileargs && this.compileargs.includes("-c") ) { - $(odiv).html(t("msg_activecode_compile_only")); + odiv.innerHTML = t("msg_activecode_compile_only"); } else { // any other case is real run time error - $(odiv).html(result.stdout.replace(/\n/g, "
")); + odiv.innerHTML = result.stdout.replace(/\n/g, "
"); if (result.stderr) { this.addJobeErrorMessage(result.stderr); } } break; case 13: // time limit - $(odiv).html(escapeHtml(result.stdout).replace(/\n/g, "
")); + odiv.innerHTML = escapeHtml(result.stdout).replace( + /\n/g, + "
", + ); this.addJobeErrorMessage(t("msg_activecode_time_limit_exc")); break; default: if (result.stderr) { - $(odiv).html(result.stderr.replace(/\n/g, "
")); + odiv.innerHTML = result.stderr.replace(/\n/g, "
"); } else { this.addJobeErrorMessage(t("msg_activecode_server_err")); } @@ -580,8 +593,8 @@ export default class LiveCode extends ActiveCode { processJobeIOResponses(resultList) { //process a series of IO test results into one unittest-like result - $(this.output).html(t("msg_activecode_iotest_results")); - $(this.runButton).removeAttr("disabled"); + this.output.innerHTML = t("msg_activecode_iotest_results"); + this.runButton.disabled = false; const odiv = this.output; this.parsedOutput = {}; @@ -651,20 +664,21 @@ export default class LiveCode extends ActiveCode { } break; case 11: // compiler error - $(odiv).html(result.cmpinfo.replace(/\n/g, "
")); + odiv.innerHTML = result.cmpinfo.replace(/\n/g, "
"); td4.innerHTML = t("msg_activecode_test_compile_error"); td4.classList.add("ac-feedback-fail"); this.errinfo = result.cmpinfo; break; case 12: // run time error - $(odiv).html(result.stderr.replace(/\n/g, "
")); + odiv.innerHTML = result.stderr.replace(/\n/g, "
"); td4.innerHTML = t("msg_activecode_test_run_error"); td4.classList.add("ac-feedback-fail"); this.errinfo = result.stderr; break; case 13: // time limit - $(odiv).html( - escapeHtml(result.stdout).replace(/\n/g, "
"), + odiv.innerHTML = escapeHtml(result.stdout).replace( + /\n/g, + "
", ); td4.innerHTML = t("msg_activecode_time_limit_exc"); td4.classList.add("ac-feedback-fail"); @@ -672,7 +686,7 @@ export default class LiveCode extends ActiveCode { break; default: if (result.stderr) { - $(odiv).html(result.stderr.replace(/\n/g, "
")); + odiv.innerHTML = result.stderr.replace(/\n/g, "
"); } td4.innerHTML = t("msg_activecode_server_err"); td4.classList.add("ac-feedback-fail"); @@ -714,7 +728,8 @@ export default class LiveCode extends ActiveCode { if (this.errDiv) { this.errDiv.remove(); } - var errHead = $("

").html("Error"); + var errHead = document.createElement("h3"); + errHead.innerHTML = "Error"; var eContainer = this.outerDiv.appendChild( document.createElement("div"), ); @@ -724,7 +739,7 @@ export default class LiveCode extends ActiveCode { eContainer.setAttribute("role", "log"); eContainer.className = "error alert alert-danger"; eContainer.id = this.divid + "_errinfo"; - eContainer.appendChild(errHead[0]); + eContainer.appendChild(errHead); var errText = eContainer.appendChild(document.createElement("pre")); // screenreaders seem to miss error message without the delay setTimeout(() => { @@ -851,7 +866,7 @@ export default class LiveCode extends ActiveCode { myVars.code = code; myVars.lang = this.language; if (this.stdin) { - myVars.stdin = $(this.stdin_el).val(); + myVars.stdin = this.stdin_el.value; } var targetDiv = this.codelens.id; diff --git a/bases/rsptx/interactives/runestone/activecode/js/timed_activecode.js b/bases/rsptx/interactives/runestone/activecode/js/timed_activecode.js index 56ae9e565..72d576071 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/timed_activecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/timed_activecode.js @@ -30,7 +30,7 @@ var TimedActiveCodeMixin = { ]; for (var i = 0; i < buttonList.length; i++) { if (buttonList[i] !== undefined && buttonList[i] !== null) - $(buttonList[i]).hide(); + buttonList[i].style.display = "none"; } }, @@ -40,14 +40,12 @@ var TimedActiveCodeMixin = { // is the element that the icon should be appended to. var timeIconDiv = document.createElement("div"); var timeIcon = document.createElement("img"); - $(timeIcon).attr({ - src: "../_static/clock.png", - style: "width:15px;height:15px", - }); + timeIcon.setAttribute("src", "../_static/clock.png"); + timeIcon.setAttribute("style", "width:15px;height:15px"); timeIconDiv.className = "timeTip"; timeIconDiv.title = ""; timeIconDiv.appendChild(timeIcon); - $(component).prepend(timeIconDiv); + component.prepend(timeIconDiv); }, checkCorrectTimed: function () { @@ -65,7 +63,7 @@ var TimedActiveCodeMixin = { }, hideFeedback: function () { - $(this.output).css("visibility", "hidden"); + this.output.style.visibility = "hidden"; }, reinitializeListeners: function (taken) { @@ -73,20 +71,23 @@ var TimedActiveCodeMixin = { console.log("reattaching runbuttonhandler"); this.runButton.onclick = this.runButtonHander.bind(this); } - $(this.codeDiv).show(); + this.codeDiv.style.display = ""; this.runButton.disabled = false; - $(this.codeDiv).removeClass("ac-disabled"); + this.codeDiv.classList.remove("ac-disabled"); this.editor.refresh(); if (this.historyScrubber !== null) { - $(this.historyScrubber).slider({ - max: this.history.length - 1, - value: this.history.length - 1, - slide: this.slideit.bind(this), - change: this.slideit.bind(this), - }); + // the scrubber is a range input; reset its bounds and position + this.historyScrubber.max = this.history.length - 1; + this.historyScrubber.value = this.history.length - 1; + this.slideit(null); } if (taken) { - $(`#${this.divid}_unit_results`).show(); + let unitResults = document.getElementById( + `${this.divid}_unit_results`, + ); + if (unitResults) { + unitResults.style.display = ""; + } } }, }; diff --git a/bases/rsptx/interactives/runestone/activecode/test/activecode.test.js b/bases/rsptx/interactives/runestone/activecode/test/activecode.test.js new file mode 100644 index 000000000..a3d8453a2 --- /dev/null +++ b/bases/rsptx/interactives/runestone/activecode/test/activecode.test.js @@ -0,0 +1,296 @@ +// Characterization tests for the ActiveCode component. These describe the +// behavior of the component as observed on a book page. Note: deliberately +// NO jquery-globals import here -- activecode must work without jQuery. +import { describe, it, expect, beforeEach } from "vitest"; +import { ActiveCode } from "../js/activecode.js"; + +// Build the same DOM a book page provides: a div.runestone wrapper around the +// [data-component=activecode] div containing a textarea with the starter code. +function makeFixture({ + id = "test_ac_1", + code = "print('hello world')", + lang = "python", + attrs = "", + question = "", +} = {}) { + document.body.innerHTML = ` +
+
+ ${question} + +
+
`; + return document.getElementById(id); +} + +function makeActiveCode(fixtureOpts = {}, acOpts = {}) { + const orig = makeFixture(fixtureOpts); + return new ActiveCode({ + orig: orig, + useRunestoneServices: false, + python3: true, + ...acOpts, + }); +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); +}); + +describe("construction", () => { + it("creates a CodeMirror editor holding the starter code", () => { + const ac = makeActiveCode({ code: "x = 40 + 2\nprint(x)" }); + expect(ac.divid).toBe("test_ac_1"); + expect(ac.language).toBe("python"); + expect(ac.editor.getValue()).toBe("x = 40 + 2\nprint(x)"); + // The editor lives inside the component's outer div. + expect(ac.outerDiv.querySelector(".CodeMirror")).toBeTruthy(); + expect(ac.codeDiv.classList.contains("ac_code_div")).toBe(true); + }); + + it("marks the component ready on the container div", () => { + const ac = makeActiveCode(); + expect( + ac.containerDiv.classList.contains("runestone-component-ready"), + ).toBe(true); + return ac.component_ready_promise; + }); + + it("adds a caption below the component", () => { + const ac = makeActiveCode(); + const cap = ac.containerDiv.querySelector("p.runestone_caption"); + expect(cap).toBeTruthy(); + expect(cap.textContent).toContain("ActiveCode"); + }); + + it("registers itself for the CSS status decoration", () => { + const ac = makeActiveCode(); + const rsDiv = ac.containerDiv.closest("div.runestone"); + expect(rsDiv.classList.contains("notAnswered")).toBe(true); + }); +}); + +describe("data attribute parsing", () => { + it("coerces numeric attributes the way jQuery .data() did", () => { + const ac = makeActiveCode({ attrs: 'data-timelimit="30000"' }); + expect(ac.timelimit).toBe(30000); + }); + + it("splits data-include on whitespace", () => { + const ac = makeActiveCode({ attrs: 'data-include="inc_1 inc_2"' }); + expect(ac.includes).toEqual(["inc_1", "inc_2"]); + }); + + it("keeps parsonsPersonalized true unless explicitly false", () => { + let ac = makeActiveCode(); + expect(ac.parsonsPersonalized).toBe(true); + ac = makeActiveCode({ + id: "test_ac_2", + attrs: 'data-parsons-personalized="false"', + }); + expect(ac.parsonsPersonalized).toBe(false); + }); +}); + +describe("prefix/suffix handling", () => { + it("strips an invisible suffix (====) from the editor and stores it", () => { + const ac = makeActiveCode({ + code: "print('visible')\n====\nprint('hidden test')\n", + }); + expect(ac.editor.getValue()).toBe("print('visible')\n"); + expect(ac.suffix).toBe("print('hidden test')\n"); + }); + + it("strips an invisible prefix (^^^^) from the editor and stores it", () => { + const ac = makeActiveCode({ + code: "print('hidden setup')\n^^^^\nprint('visible')\n", + }); + expect(ac.editor.getValue()).toBe("print('visible')\n"); + expect(ac.prefix).toBe("print('hidden setup')\n"); + }); + + it("keeps a visible suffix (===!) in the editor, marked read-only", () => { + const ac = makeActiveCode({ + code: "print('editable')\n===!\nprint('locked')\n", + }); + expect(ac.editor.getValue()).toBe( + "print('editable')\nprint('locked')\n", + ); + expect(ac.visibleSuffix).toBe("print('locked')\n"); + expect(ac.lockTextMarkers.length).toBeGreaterThan(0); + }); + + it("assembles prefix + editor + suffix in buildProg", async () => { + const ac = makeActiveCode({ + code: "setup()\n^^^^\nuser_code()\n====\ntests()\n", + }); + const prog = await ac.buildProg(true); + expect(prog).toBe("setup()\nuser_code()\n\ntests()\n"); + }); +}); + +describe("controls", () => { + it("creates a Run button wired to the runButtonHandler", () => { + const ac = makeActiveCode(); + expect(ac.runButton).toBeTruthy(); + expect(ac.runButton.textContent).toBe("Run"); + expect(ac.runButton.title).toBe("Save & Run (ctrl-s)"); + expect(ac.runButton.getAttribute("type")).toBe("button"); + expect(ac.runButton.classList.contains("run-button")).toBe(true); + expect(ac.controlDiv.classList.contains("ac_actions")).toBe(true); + }); + + it("adds a Download button when data-enabledownload is set", () => { + const ac = makeActiveCode({ attrs: "data-enabledownload" }); + expect(ac.downloadButton).toBeTruthy(); + expect(ac.downloadButton.textContent).toBe("Download"); + }); + + it("adds a Reformat button only for curly-brace languages", () => { + let ac = makeActiveCode(); + expect(ac.reformatButton).toBeUndefined(); + // livecode langs construct LiveCode normally; ActiveCode still honors + // the reformatable set for e.g. javascript + ac = makeActiveCode({ id: "test_ac_2", lang: "javascript" }); + expect(ac.reformatButton).toBeTruthy(); + }); + + it("relabels the Run button when save/load is enabled", () => { + const ac = makeActiveCode(); + ac.enableSaveLoad(); + expect(ac.runButton.textContent).toBe("Save & Run"); + }); +}); + +describe("hidecode", () => { + it("hides the editor and shows a Show Code button", () => { + const ac = makeActiveCode({ attrs: 'data-hidecode="true"' }); + expect(ac.codeDiv.style.display).toBe("none"); + expect(ac.showHideButt).toBeTruthy(); + expect(ac.showHideButt.textContent).toBe("Show Code"); + expect(ac.runButton.disabled).toBe(true); + }); + + it("toggles editor visibility and run button on click", () => { + const ac = makeActiveCode({ attrs: 'data-hidecode="true"' }); + ac.showHideButt.click(); + expect(ac.codeDiv.style.display).not.toBe("none"); + expect(ac.showHideButt.textContent).toBe("Hide Code"); + expect(ac.runButton.disabled).toBe(false); + ac.showHideButt.click(); + expect(ac.codeDiv.style.display).toBe("none"); + expect(ac.showHideButt.textContent).toBe("Show Code"); + expect(ac.runButton.disabled).toBe(true); + }); +}); + +describe("output area", () => { + it("creates stdout, graphics, coach, codelens and error containers", () => { + const ac = makeActiveCode(); + expect(ac.output.id).toBe("test_ac_1_stdout"); + expect(ac.output.getAttribute("role")).toBe("log"); + expect(ac.graphics.id).toBe("test_ac_1_graphics"); + expect(ac.codecoach.style.display).toBe("none"); + expect(ac.codelens.style.display).toBe("none"); + expect(ac.eContainer.id).toBe("test_ac_1_errinfo"); + expect(ac.eContainer.style.visibility).toBe("hidden"); + }); +}); + +describe("running python with skulpt", () => { + it("runs the program and writes escaped output to the stdout pre", async () => { + const ac = makeActiveCode({ + code: "print('2 < 3')\nprint('done')", + }); + await ac.runProg(); + expect(ac.errinfo).toBe("success"); + // stdout writes are appended from 1ms timeouts; let them flush + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(ac.output.innerHTML).toContain("2 < 3"); + expect(ac.output.textContent).toContain("done"); + }); + + it("reports errors in the error container", async () => { + const ac = makeActiveCode({ + id: "test_ac_err", + code: "print(undefined_name)", + }); + await ac.runProg(); + expect(ac.errinfo).toContain("NameError"); + expect(ac.eContainer.style.visibility).toBe("visible"); + // the detailed message is added on a timeout + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(ac.eContainer.textContent).toContain("Error"); + }); + + it("records history when the code changed", async () => { + const ac = makeActiveCode({ code: "print('one')" }); + ac.editor.setValue("print('two')"); + const before = ac.history.length; + await ac.runProg(); + expect(ac.history.length).toBe(before + 1); + expect(ac.history[ac.history.length - 1]).toBe("print('two')"); + }); +}); + +describe("error message formatting", () => { + it("names the error and offers a description and fix", () => { + const ac = makeActiveCode(); + ac.pretextLines = 0; + ac.progLines = 100; + ac.addErrorMessage(new Error("NameError: name 'x' is not defined")); + const text = ac.eContainer.textContent; + expect(text).toContain("Error"); + expect(text).toContain("Description"); + expect(text).toContain("To Fix"); + expect(text).toContain("NameError: name 'x' is not defined"); + }); +}); + +describe("history scrubber", () => { + it("builds a range-input scrubber after the run button", async () => { + const ac = makeActiveCode(); + await ac.addHistoryScrubber(true); + expect(ac.historyScrubber).toBeTruthy(); + expect(ac.historyScrubber.tagName).toBe("INPUT"); + expect(ac.historyScrubber.type).toBe("range"); + expect(ac.historyScrubber.getAttribute("aria-label")).toBe( + "History slider", + ); + expect(ac.timestampP.textContent).toContain("1 of 1"); + }); + + it("restores older code when the scrubber moves", async () => { + const ac = makeActiveCode({ code: "print('v1')" }); + ac.editor.setValue("print('v2')"); + await ac.manage_scrubber("False"); + expect(ac.history).toEqual(["print('v1')", "print('v2')"]); + expect(ac.timestampP.textContent).toContain("2 of 2"); + // drag back to the first revision + ac.historyScrubber.value = 0; + ac.historyScrubber.dispatchEvent(new Event("input")); + expect(ac.editor.getValue()).toBe("print('v1')"); + expect(ac.timestampP.textContent).toContain("1 of 2"); + }); +}); + +describe("resizable editor", () => { + it("tags the CodeMirror wrapper with the ac-resizable class", () => { + const ac = makeActiveCode(); + expect( + ac.editor.getWrapperElement().classList.contains("ac-resizable"), + ).toBe(true); + }); +}); + +describe("disableInteraction", () => { + it("hides the run button and disables the editor area", () => { + const ac = makeActiveCode(); + ac.disableInteraction(); + expect(ac.runButton.style.display).toBe("none"); + expect(ac.codeDiv.classList.contains("ac-disabled")).toBe(true); + }); +}); diff --git a/bases/rsptx/interactives/runestone/activecode/test/activecode_subclasses.test.js b/bases/rsptx/interactives/runestone/activecode/test/activecode_subclasses.test.js new file mode 100644 index 000000000..375ea7edf --- /dev/null +++ b/bases/rsptx/interactives/runestone/activecode/test/activecode_subclasses.test.js @@ -0,0 +1,78 @@ +// Tests for the JavaScript and HTML flavors of ActiveCode. +// No jquery-globals import -- these components must work without jQuery. +import { describe, it, expect, beforeEach } from "vitest"; +import JSActiveCode from "../js/activecode_js.js"; +import HTMLActiveCode from "../js/activecode_html.js"; + +function makeFixture({ id, code, lang }) { + document.body.innerHTML = ` +
+
+ +
+
`; + return document.getElementById(id); +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); +}); + +describe("JSActiveCode", () => { + it("runs a JavaScript program and writes to the output", async () => { + const orig = makeFixture({ + id: "test_js_1", + code: "writeln('hello from js');\nwriteln(6 * 7);", + lang: "javascript", + }); + const ac = new JSActiveCode({ orig, useRunestoneServices: false }); + await ac.runProg(); + expect(ac.errinfo).toBe("success"); + expect(ac.output.textContent).toContain("hello from js"); + expect(ac.output.textContent).toContain("42"); + }); + + it("shows an error container for a broken program", async () => { + const orig = makeFixture({ + id: "test_js_2", + code: "no_such_function();", + lang: "javascript", + }); + const ac = new JSActiveCode({ orig, useRunestoneServices: false }); + await ac.runProg(); + expect(ac.errinfo).not.toBe("success"); + expect(ac.eContainer.className).toContain("error"); + // the message is added after a short screenreader-friendly delay + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(ac.eContainer.textContent).toContain("no_such_function"); + }); +}); + +describe("HTMLActiveCode", () => { + it("labels the run button Render and targets an iframe", () => { + const orig = makeFixture({ + id: "test_html_1", + code: "<h1>Hello</h1>", + lang: "html", + }); + const ac = new HTMLActiveCode({ orig, useRunestoneServices: false }); + expect(ac.runButton.textContent).toBe("Render"); + expect(ac.output.tagName).toBe("IFRAME"); + // entities were decoded by the textarea parser + expect(ac.editor.getValue()).toContain("

Hello

"); + }); + + it("renders the editor contents into the iframe srcdoc", async () => { + const orig = makeFixture({ + id: "test_html_2", + code: "<p>content</p>", + lang: "html", + }); + const ac = new HTMLActiveCode({ orig, useRunestoneServices: false }); + await ac.runProg(); + expect(ac.output.srcdoc).toContain("

content

"); + }); +}); diff --git a/bases/rsptx/interactives/runestone/common/js/domutil.js b/bases/rsptx/interactives/runestone/common/js/domutil.js new file mode 100644 index 000000000..5e4475b2b --- /dev/null +++ b/bases/rsptx/interactives/runestone/common/js/domutil.js @@ -0,0 +1,58 @@ +/* *********************************** + * |docname| - small DOM helpers + * *********************************** + * Helpers that replace common jQuery idioms as components are migrated to + * plain DOM APIs. + */ + +const rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/; + +/** + * Read a data-* attribute with the same type coercion jQuery's .data() + * performed: "true"/"false"/"null" become their JS values, numeric strings + * become numbers, JSON-looking strings are parsed, everything else is + * returned as a string. Returns undefined when the attribute is absent. + * + * @param {Element} element + * @param {string} name - the part of the attribute name after "data-" + */ +export function getDataValue(element, name) { + const data = element.getAttribute(`data-${name}`); + if (data === null) { + return undefined; + } + if (data === "true") return true; + if (data === "false") return false; + if (data === "null") return null; + // Only convert to a number if it doesn't change the string. + if (data === +data + "") return +data; + if (rbrace.test(data)) { + try { + return JSON.parse(data); + } catch (e) { + return data; + } + } + return data; +} + +/** + * Show or hide an element by toggling inline display, like jQuery's .toggle(). + */ +export function toggleDisplay(element) { + if (!element) return; + if (isHidden(element)) { + element.style.display = ""; + if (isHidden(element)) { + element.style.display = "block"; + } + } else { + element.style.display = "none"; + } +} + +function isHidden(element) { + if (element.style.display === "none") return true; + const win = element.ownerDocument.defaultView; + return win.getComputedStyle(element).display === "none"; +} diff --git a/bases/rsptx/interactives/runestone/common/js/runestonebase.js b/bases/rsptx/interactives/runestone/common/js/runestonebase.js index 85d0836f0..7b238cf04 100644 --- a/bases/rsptx/interactives/runestone/common/js/runestonebase.js +++ b/bases/rsptx/interactives/runestone/common/js/runestonebase.js @@ -16,6 +16,7 @@ */ import { pageProgressTracker } from "./bookfuncs.js"; +import { getDataValue } from "./domutil.js"; //import "./../styles/runestone-custom-sphinx-bootstrap.css"; var NO_DECORATE = [ @@ -73,13 +74,15 @@ export default class RunestoneBase { // is to look for doAssignment in the URL and then grab // the assignment name from the heading. if (location.href.indexOf("doAssignment") >= 0) { - this.timedWrapper = $("h1#assignment_name").text(); + this.timedWrapper = + document.querySelector("h1#assignment_name") + ?.textContent || ""; } else { this.timedWrapper = null; } } - if ($(opts.orig).data("question_label")) { - this.question_label = $(opts.orig).data("question_label"); + if (opts.orig && getDataValue(opts.orig, "question_label")) { + this.question_label = getDataValue(opts.orig, "question_label"); } this.is_toggle = true ? opts.is_toggle : false; this.is_select = true ? opts.is_select : false; @@ -539,17 +542,15 @@ export default class RunestoneBase { this.caption = eBookConfig.useRunestoneServices ? `Activity: ${this.question_label} ${this.caption} (${this.divid})` : `Activity: ${this.question_label} ${this.caption}`; // Without runestone - $(capDiv).html(this.caption); - $(capDiv).addClass(`${elType}_caption`); + capDiv.innerHTML = this.caption; + capDiv.classList.add(`${elType}_caption`); } else { // Display caption based on whether Runestone services have been detected - $(capDiv).html( - eBookConfig.useRunestoneServices - ? this.caption + " (" + this.divid + ")" - : this.caption, - ); // Without runestone - $(capDiv).addClass(`${elType}_caption`); - $(capDiv).addClass(`${elType}_caption_text`); + capDiv.innerHTML = eBookConfig.useRunestoneServices + ? this.caption + " (" + this.divid + ")" + : this.caption; // Without runestone + capDiv.classList.add(`${elType}_caption`); + capDiv.classList.add(`${elType}_caption_text`); } this.capDiv = capDiv; //this.outerDiv.parentNode.insertBefore(capDiv, this.outerDiv.nextSibling); @@ -620,7 +621,9 @@ export default class RunestoneBase { decorateStatus() { if (this.isTimed || eBookConfig.peer) return; - let rsDiv = $(this.containerDiv).closest("div.runestone")[0]; + let rsDiv = this.containerDiv + ? this.containerDiv.closest("div.runestone") + : null; if (!rsDiv) return; rsDiv.classList.remove("notAnswered"); rsDiv.classList.remove("isInCorrect"); diff --git a/bases/rsptx/interactives/runestone/common/test/domutil.test.js b/bases/rsptx/interactives/runestone/common/test/domutil.test.js new file mode 100644 index 000000000..f08de3400 --- /dev/null +++ b/bases/rsptx/interactives/runestone/common/test/domutil.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { getDataValue, toggleDisplay } from "../js/domutil.js"; + +function makeEl(attrs) { + const el = document.createElement("div"); + for (const [k, v] of Object.entries(attrs)) { + el.setAttribute(k, v); + } + return el; +} + +describe("getDataValue (jQuery .data() compatible coercion)", () => { + it("returns undefined for missing attributes", () => { + expect(getDataValue(makeEl({}), "lang")).toBeUndefined(); + }); + + it("returns strings as-is", () => { + expect(getDataValue(makeEl({ "data-lang": "python" }), "lang")).toBe( + "python", + ); + }); + + it("coerces booleans and null", () => { + expect(getDataValue(makeEl({ "data-x": "true" }), "x")).toBe(true); + expect(getDataValue(makeEl({ "data-x": "false" }), "x")).toBe(false); + expect(getDataValue(makeEl({ "data-x": "null" }), "x")).toBe(null); + }); + + it("coerces numbers only when lossless", () => { + expect(getDataValue(makeEl({ "data-x": "30000" }), "x")).toBe(30000); + expect(getDataValue(makeEl({ "data-x": "1.5" }), "x")).toBe(1.5); + // leading zeros / version strings stay strings + expect(getDataValue(makeEl({ "data-x": "007" }), "x")).toBe("007"); + expect(getDataValue(makeEl({ "data-x": "1.2.3" }), "x")).toBe("1.2.3"); + }); + + it("parses JSON-looking values", () => { + expect(getDataValue(makeEl({ "data-x": '{"a": 1}' }), "x")).toEqual({ + a: 1, + }); + expect(getDataValue(makeEl({ "data-x": "[1, 2]" }), "x")).toEqual([ + 1, 2, + ]); + }); + + it("keeps hyphenated and underscored attribute names verbatim", () => { + const el = makeEl({ + "data-highlight-lines": "1-3", + "data-question_label": "1.2", + }); + expect(getDataValue(el, "highlight-lines")).toBe("1-3"); + expect(getDataValue(el, "question_label")).toBe(1.2); + }); + + it("empty string attribute is truthy-adjacent like jQuery (empty string)", () => { + expect(getDataValue(makeEl({ "data-x": "" }), "x")).toBe(""); + }); +}); + +describe("toggleDisplay", () => { + it("hides a visible element and shows it again", () => { + const el = document.createElement("div"); + document.body.appendChild(el); + toggleDisplay(el); + expect(el.style.display).toBe("none"); + toggleDisplay(el); + expect(el.style.display).not.toBe("none"); + el.remove(); + }); + + it("ignores null elements", () => { + expect(() => toggleDisplay(null)).not.toThrow(); + }); +}); diff --git a/bases/rsptx/interactives/test-support/jquery-globals.js b/bases/rsptx/interactives/test-support/jquery-globals.js new file mode 100644 index 000000000..64499e56a --- /dev/null +++ b/bases/rsptx/interactives/test-support/jquery-globals.js @@ -0,0 +1,44 @@ +// Book pages provide jQuery and jQuery UI as page globals (webpack marks +// jquery as an external). Tests for components that still depend on them +// must import this shim explicitly. Once a component is migrated off +// jQuery, drop this import from its tests -- a passing suite without the +// shim proves the component no longer touches jQuery. +import $ from "jquery"; + +globalThis.$ = $; +globalThis.jQuery = $; + +// Just enough of jQuery UI for component construction in tests. +$.fn.slider = function (arg, opt, val) { + const el = this[0]; + if (!el) return this; + if (!el._sliderState) { + el._sliderState = { value: 0, max: 0, disabled: false }; + // jQuery UI creates an
handle; activecode sets an aria-label on it. + el.appendChild(document.createElement("a")); + } + const state = el._sliderState; + if (arg === undefined || typeof arg === "object") { + Object.assign(state, arg || {}); + return this; + } + if (arg === "value") { + if (opt === undefined) return state.value; + state.value = opt; + $(el).trigger("slidechange"); + return this; + } + if (arg === "option") { + if (val === undefined) return state[opt]; + state[opt] = val; + if (opt === "value") $(el).trigger("slidechange"); + return this; + } + if (arg === "disable") state.disabled = true; + if (arg === "enable") state.disabled = false; + return this; +}; + +$.fn.resizable = function () { + return this; +}; diff --git a/bases/rsptx/interactives/test-support/setup.js b/bases/rsptx/interactives/test-support/setup.js new file mode 100644 index 000000000..a473475dd --- /dev/null +++ b/bases/rsptx/interactives/test-support/setup.js @@ -0,0 +1,76 @@ +// Global test environment for Runestone component tests. +// +// Book pages (Sphinx/PreTeXt templates) define eBookConfig before any +// component code runs. Tests get a minimal logged-out configuration; +// individual tests may override fields and should restore them afterwards. +globalThis.eBookConfig = { + useRunestoneServices: false, + isLoggedIn: false, + practice_mode: false, + logLevel: 0, + course: "test_course", + basecourse: "test_course", + email: "test@example.com", + allow_pairs: false, + enable_chatcodes: false, + downloadsEnabled: false, + isInstructor: false, + new_server_prefix: "/ns", + acDefaultLanguage: "python", + enableScratchAC: false, +}; + +// bookfuncs.js and runestonebase.js import each other; bookfuncs must be +// evaluated first (as it is in the webpack bundle) or its module-level +// `new RunestoneBase()` runs before the class exists. +import "../runestone/common/js/bookfuncs.js"; + +// Neither this jsdom version nor Node's flag-gated implementation provides a +// usable localStorage global, so install a simple in-memory one. +function makeStorage() { + let store = new Map(); + return { + getItem: (k) => (store.has(String(k)) ? store.get(String(k)) : null), + setItem: (k, v) => store.set(String(k), String(v)), + removeItem: (k) => store.delete(String(k)), + clear: () => store.clear(), + key: (i) => [...store.keys()][i] ?? null, + get length() { + return store.size; + }, + }; +} +for (const name of ["localStorage", "sessionStorage"]) { + const storage = makeStorage(); + for (const target of [globalThis, window]) { + Object.defineProperty(target, name, { + value: storage, + writable: true, + configurable: true, + }); + } +} + +// jsdom's Range implementation lacks the measurement APIs CodeMirror uses. +const origCreateRange = document.createRange.bind(document); +document.createRange = () => { + const range = origCreateRange(); + if (!range.getClientRects) { + range.getClientRects = () => ({ + length: 0, + item: () => null, + [Symbol.iterator]: [][Symbol.iterator], + }); + } + if (!range.getBoundingClientRect) { + range.getBoundingClientRect = () => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + width: 0, + height: 0, + }); + } + return range; +}; diff --git a/bases/rsptx/interactives/test-support/stubs/renderComponent.js b/bases/rsptx/interactives/test-support/stubs/renderComponent.js new file mode 100644 index 000000000..0ae6736c3 --- /dev/null +++ b/bases/rsptx/interactives/test-support/stubs/renderComponent.js @@ -0,0 +1,10 @@ +// Test stub for runestone/common/js/renderComponent.js, which imports +// webpack.index.js and with it every component in the repository. +export async function renderRunestoneComponent(html, divid) { + const target = document.getElementById(divid); + if (target) { + target.innerHTML = html; + } +} + +export async function renderOneComponent() {} diff --git a/bases/rsptx/interactives/vitest.config.js b/bases/rsptx/interactives/vitest.config.js new file mode 100644 index 000000000..ec159681d --- /dev/null +++ b/bases/rsptx/interactives/vitest.config.js @@ -0,0 +1,34 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: [ + // renderComponent.js imports webpack.index.js, which pulls in every + // component in the book. Tests exercise one component at a time, so + // stub it out. + { + find: /^.*\/renderComponent\.js$/, + replacement: path.resolve( + __dirname, + "test-support/stubs/renderComponent.js", + ), + }, + ], + }, + test: { + environment: "jsdom", + environmentOptions: { + jsdom: { + // provides requestAnimationFrame, needed by CodeMirror + pretendToBeVisual: true, + // a real origin so localStorage works + url: "http://localhost/", + }, + }, + setupFiles: ["./test-support/setup.js"], + include: ["runestone/**/test/**/*.test.js"], + // skulpt.min.js is ~10MB; give the first import time to parse + testTimeout: 20000, + }, +}); diff --git a/docs/source/javascript_feature.rst b/docs/source/javascript_feature.rst index 18ddba423..c6dfccd0d 100644 --- a/docs/source/javascript_feature.rst +++ b/docs/source/javascript_feature.rst @@ -26,3 +26,70 @@ The following is what you need to do to work on the javascript for a component t If you change your PreTeXt source, you just need to rerun 5 & 6. If you change your Runestone components code, rerun 3 (or use ``watch``) and then hard refresh your browser (Ctrl-F5). If you are still working with old RST-based books, you can simply use the ``runestone build`` command which automatically copies the files to the correct location. + +Unit tests +~~~~~~~~~~ + +The components have a `vitest `_-based unit test suite +that runs in a jsdom environment — no book build, server, or browser needed. +Tests live next to each component in +``runestone//test/*.test.js``. + +Running the tests +................. + +From ``bases/rsptx/interactives``:: + + npm install # first time only + npm test # run the whole suite once + npm run test:watch # re-run on file change while developing + +To run a single file or filter by test name:: + + npx vitest run runestone/activecode/test/activecode.test.js + npx vitest run -t "history scrubber" + +How the harness works +..................... + +The pieces, all under ``bases/rsptx/interactives``: + +* ``vitest.config.js`` — configures the jsdom environment and aliases + ``common/js/renderComponent.js`` to a stub (the real module imports + ``webpack.index.js`` and with it every component in the repo). +* ``test-support/setup.js`` — runs before every test file. It defines a + minimal logged-out ``eBookConfig``, polyfills ``localStorage``, patches + jsdom's ``Range`` for CodeMirror, and imports ``bookfuncs.js`` first to + break the ``bookfuncs``/``runestonebase`` circular import. +* ``test-support/jquery-globals.js`` — provides the page globals ``$`` and + ``jQuery`` (plus tiny jQuery UI slider/resizable stubs). On a real book + page these come from Sphinx/PreTeXt; in tests, a component that still + uses jQuery must import this shim explicitly at the top of its test file. + **Do not import it in tests for a component that has been migrated off + jQuery** — a passing suite without the shim is the proof that the + component no longer depends on it. + +Writing tests +............. + +Tests build a DOM fixture the same way a book page does and construct the +component directly, for example:: + + document.body.innerHTML = ` +
+
+ +
+
`; + let ac = new ActiveCode({ + orig: document.getElementById("test_ac_1"), + useRunestoneServices: false, + }); + +Skulpt really runs Python inside jsdom, so tests can click Run (or call +``runProg()``) and assert on the output. Note that stdout writes are +appended from short timeouts, so ``await`` a ~50ms delay after ``runProg()`` +before asserting on the output element. + +If you add a feature or a new component, please include tests that verify it +works. ``runestone/activecode/test/`` is a good model to copy. From 7a5026b33b8edf7dcdb5dca7ec019b87ab8399d5 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 07:00:42 -0500 Subject: [PATCH 02/15] feat: remove jQuery from parsons component Convert parsons.js, parsonsBlock.js, parsonsLine.js, settledBlock.js, placeholderBlock.js and timedparsons.js to plain DOM APIs. The drag machinery was already Hammer.js; jQuery was only used for DOM manipulation, $.animate tweens and $.inArray. - add outerWidth/outerHeight and a rAF-based animate() tween (numeric and color properties) to common/js/domutil.js - data-* option parsing now uses getDataValue for jQuery .data() type coercion - component instantiation listens for runestone:login-complete via document.addEventListener like other converted components - add a 48-test characterization suite (runestone/parsons/test) written against the jQuery implementation first, then verified against the jQuery-free version; the suite does not load the jquery-globals shim, proving the component no longer touches jQuery Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/common/js/domutil.js | 99 +++ .../runestone/parsons/js/parsons.js | 593 +++++++------- .../runestone/parsons/js/parsonsBlock.js | 189 +++-- .../runestone/parsons/js/parsonsLine.js | 10 +- .../runestone/parsons/js/placeholderBlock.js | 7 +- .../runestone/parsons/js/settledBlock.js | 31 +- .../runestone/parsons/js/timedparsons.js | 10 +- .../runestone/parsons/test/parsons.test.js | 734 ++++++++++++++++++ 8 files changed, 1245 insertions(+), 428 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/parsons/test/parsons.test.js diff --git a/bases/rsptx/interactives/runestone/common/js/domutil.js b/bases/rsptx/interactives/runestone/common/js/domutil.js index 5e4475b2b..0d4a4ef7c 100644 --- a/bases/rsptx/interactives/runestone/common/js/domutil.js +++ b/bases/rsptx/interactives/runestone/common/js/domutil.js @@ -56,3 +56,102 @@ function isHidden(element) { const win = element.ownerDocument.defaultView; return win.getComputedStyle(element).display === "none"; } + +/** + * Width of an element including margins, like jQuery's .outerWidth(true). + */ +export function outerWidth(element) { + const style = getComputedStyle(element); + return ( + element.getBoundingClientRect().width + + (parseFloat(style.marginLeft) || 0) + + (parseFloat(style.marginRight) || 0) + ); +} + +/** + * Height of an element including margins, like jQuery's .outerHeight(true). + */ +export function outerHeight(element) { + const style = getComputedStyle(element); + return ( + element.getBoundingClientRect().height + + (parseFloat(style.marginTop) || 0) + + (parseFloat(style.marginBottom) || 0) + ); +} + +// Properties that take a bare number rather than a px length. +const unitless = new Set(["opacity", "zIndex", "fontWeight"]); + +const kebab = /-([a-z])/g; +const camelCase = (name) => name.replace(kebab, (m, c) => c.toUpperCase()); + +function parseColor(value) { + if (!value) return null; + let m = value.match(/^#([0-9a-f]{3})$/i); + if (m) { + return [...m[1]].map((c) => parseInt(c + c, 16)); + } + m = value.match(/^#([0-9a-f]{6})$/i); + if (m) { + return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)); + } + m = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (m) { + return [+m[1], +m[2], +m[3]]; + } + return null; +} + +/** + * A requestAnimationFrame tween covering the subset of jQuery's .animate() + * the components use: numeric CSS properties (px, or bare for opacity-like + * properties) and colors, plus start/progress/complete callbacks. progress + * receives the 0..1 fraction as its second argument, matching the jQuery + * callback shape used by existing callers. + */ +export function animate(element, properties, options = {}) { + const duration = options.duration ?? 400; + const computed = getComputedStyle(element); + const tweens = []; + for (const [name, target] of Object.entries(properties)) { + const prop = camelCase(name); + if (typeof target === "number") { + const from = parseFloat(computed[prop]) || 0; + const unit = unitless.has(prop) ? "" : "px"; + tweens.push((p) => { + element.style[prop] = from + (target - from) * p + unit; + }); + } else { + const fromColor = parseColor(computed[prop]); + const toColor = parseColor(target); + if (fromColor && toColor) { + tweens.push((p) => { + const mixed = fromColor.map((f, i) => + Math.round(f + (toColor[i] - f) * p), + ); + element.style[prop] = `rgb(${mixed.join(", ")})`; + }); + } else { + // Not interpolatable; just apply the final value at the end. + tweens.push((p) => { + if (p === 1) element.style[prop] = target; + }); + } + } + } + if (options.start) options.start(); + const t0 = performance.now(); + function step(now) { + const p = Math.min(1, (now - t0) / duration); + for (const tween of tweens) tween(p); + if (options.progress) options.progress(null, p, null); + if (p < 1) { + requestAnimationFrame(step); + } else if (options.complete) { + options.complete(); + } + } + requestAnimationFrame(step); +} diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsons.js b/bases/rsptx/interactives/runestone/parsons/js/parsons.js index 861052ce4..b4da25f32 100644 --- a/bases/rsptx/interactives/runestone/parsons/js/parsons.js +++ b/bases/rsptx/interactives/runestone/parsons/js/parsons.js @@ -23,6 +23,12 @@ import RunestoneBase from "../../common/js/runestonebase.js"; import { t } from "../../common/js/rsi18n.js"; +import { + getDataValue, + animate, + outerHeight, + outerWidth, +} from "../../common/js/domutil.js"; import "./parsons-i18n.en.js"; import "./parsons-i18n.pt-br.js"; import "./parsons-i18n.sr-Cyrl.js"; @@ -62,9 +68,9 @@ export default class Parsons extends RunestoneBase { super(opts); var orig = opts.orig; // entire
 element that will be replaced by new HTML
         this.containerDiv = orig;
-        this.origElem = $(orig).find("pre.parsonsblocks")[0];
+        this.origElem = orig.querySelector("pre.parsonsblocks");
         // Find the question text and store it in .question
-        this.question = $(orig).find(`.parsons_question`)[0];
+        this.question = orig.querySelector(".parsons_question");
         this.useRunestoneServices = opts.useRunestoneServices;
         this.divid = opts.orig.id;
         // Set the storageId (key for storing data)
@@ -87,7 +93,7 @@ export default class Parsons extends RunestoneBase {
                 ? new DAGGrader(this)
                 : new LineBasedGrader(this);
         this.grader.showfeedback = this.showfeedback;
-        var fulltext = $(this.origElem).html();
+        var fulltext = this.origElem.innerHTML;
         this.blockIndex = 0;
         this.checkCount = 0;
         this.numDistinct = 0;
@@ -106,14 +112,14 @@ export default class Parsons extends RunestoneBase {
             pixelsPerIndent: 30,
         };
         // add maxdist and order if present
-        var maxdist = $(this.origElem).data("maxdist");
-        var order = $(this.origElem).data("order");
-        var noindent = $(this.origElem).data("noindent");
-        var adaptive = $(this.origElem).data("adaptive");
-        var numbered = $(this.origElem).data("numbered");
-        var grader = $(this.origElem).data("grader");
+        var maxdist = getDataValue(this.origElem, "maxdist");
+        var order = getDataValue(this.origElem, "order");
+        var noindent = getDataValue(this.origElem, "noindent");
+        var adaptive = getDataValue(this.origElem, "adaptive");
+        var numbered = getDataValue(this.origElem, "numbered");
+        var grader = getDataValue(this.origElem, "grader");
         // CodeTailor: proactively set the puzzle as scaffolding puzzle to differentiate with other puzzle practice formats
-        var scaffolding = $(this.origElem).data("scaffolding");
+        var scaffolding = getDataValue(this.origElem, "scaffolding");
         options["numbered"] = numbered;
         options["grader"] = grader;
         options["scaffolding"] = scaffolding;
@@ -145,7 +151,7 @@ export default class Parsons extends RunestoneBase {
             locale = "en";
         }
         options["locale"] = locale;
-        var language = $(this.origElem).data("language");
+        var language = getDataValue(this.origElem, "language");
         if (language == undefined) {
             language = eBookConfig.language;
             if (language == undefined) {
@@ -168,9 +174,9 @@ export default class Parsons extends RunestoneBase {
         }
         options["prettifyLanguage"] = prettifyLanguage;
         //runnable if the parent has a parsons-runnable attr
-        options["runnable"] = $(this.origElem).data("runnable");
+        options["runnable"] = getDataValue(this.origElem, "runnable");
         // Parse block explanations if present
-        var explanationsRaw = $(this.origElem).attr("data-explanations");
+        var explanationsRaw = this.origElem.getAttribute("data-explanations");
         if (explanationsRaw) {
             try {
                 this.blockExplanations = JSON.parse(explanationsRaw);
@@ -185,7 +191,7 @@ export default class Parsons extends RunestoneBase {
     // Based on what is specified in the original HTML, create the HTML view
     initializeView() {
         this.outerDiv = document.createElement("div");
-        $(this.outerDiv).addClass("parsons");
+        this.outerDiv.classList.add("parsons");
         this.outerDiv.id = this.counterId;
         // parsons-text exists in source, make sure it has exercise-statement class
         let parsonsTextDiv = this.containerDiv.querySelector(".parsons-text");
@@ -195,57 +201,57 @@ export default class Parsons extends RunestoneBase {
         )
             parsonsTextDiv.classList.add("exercise-statement");
         this.keyboardTip = document.createElement("div");
-        $(this.keyboardTip).attr("role", "tooltip");
+        this.keyboardTip.setAttribute("role", "tooltip");
         this.keyboardTip.id = this.counterId + "-tip";
         this.keyboardTip.innerHTML = t("msg_parson_arrow_navigate");
         this.outerDiv.appendChild(this.keyboardTip);
-        $(this.keyboardTip).hide();
+        this.keyboardTip.style.display = "none";
         this.sortContainerDiv = document.createElement("div");
-        $(this.sortContainerDiv).addClass("sortable-code-container");
-        $(this.sortContainerDiv).attr(
+        this.sortContainerDiv.classList.add("sortable-code-container");
+        this.sortContainerDiv.setAttribute(
             "aria-describedby",
             this.counterId + "-tip",
         );
         this.outerDiv.appendChild(this.sortContainerDiv);
         this.sourceRegionDiv = document.createElement("div");
         this.sourceRegionDiv.id = this.counterId + "-sourceRegion";
-        $(this.sourceRegionDiv).addClass("sortable-code");
+        this.sourceRegionDiv.classList.add("sortable-code");
         this.sourceLabel = document.createElement("div");
-        $(this.sourceLabel).attr("role", "tooltip");
+        this.sourceLabel.setAttribute("role", "tooltip");
         this.sourceLabel.id = this.counterId + "-sourceTip";
         this.sourceLabel.innerHTML = t("msg_parson_drag_from_here");
         this.sourceRegionDiv.appendChild(this.sourceLabel);
         this.sortContainerDiv.appendChild(this.sourceRegionDiv);
         this.sourceArea = document.createElement("div");
         this.sourceArea.id = this.counterId + "-source";
-        $(this.sourceArea).addClass("source");
-        $(this.sourceArea).attr(
+        this.sourceArea.classList.add("source");
+        this.sourceArea.setAttribute(
             "aria-describedby",
             this.counterId + "-sourceTip",
         );
         this.sourceRegionDiv.appendChild(this.sourceArea);
         this.answerRegionDiv = document.createElement("div");
         this.answerRegionDiv.id = this.counterId + "-answerRegion";
-        $(this.answerRegionDiv).addClass("sortable-code");
+        this.answerRegionDiv.classList.add("sortable-code");
         this.answerLabel = document.createElement("div");
-        $(this.answerLabel).attr("role", "tooltip");
+        this.answerLabel.setAttribute("role", "tooltip");
         this.answerLabel.id = this.counterId + "-answerTip";
         this.answerLabel.innerHTML = t("msg_parson_drag_to_here");
         this.answerRegionDiv.appendChild(this.answerLabel);
         this.sortContainerDiv.appendChild(this.answerRegionDiv);
         this.answerArea = document.createElement("div");
         this.answerArea.id = this.counterId + "-answer";
-        $(this.answerArea).attr(
+        this.answerArea.setAttribute(
             "aria-describedby",
             this.counterId + "-answerTip",
         );
         this.answerRegionDiv.appendChild(this.answerArea);
         this.parsonsControlDiv = document.createElement("div");
-        $(this.parsonsControlDiv).addClass("parsons-controls");
+        this.parsonsControlDiv.classList.add("parsons-controls");
         this.outerDiv.appendChild(this.parsonsControlDiv);
         var that = this;
         this.checkButton = document.createElement("button");
-        $(this.checkButton).attr("class", "btn btn-success");
+        this.checkButton.className = "btn btn-success";
         this.checkButton.textContent = t("msg_parson_check_me");
         this.checkButton.id = this.counterId + "-check";
         this.parsonsControlDiv.appendChild(this.checkButton);
@@ -257,7 +263,7 @@ export default class Parsons extends RunestoneBase {
             that.renderFeedback();
         });
         this.resetButton = document.createElement("button");
-        $(this.resetButton).attr("class", "btn btn-default");
+        this.resetButton.className = "btn btn-default";
         this.resetButton.textContent = t("msg_parson_reset");
         this.resetButton.id = this.counterId + "-reset";
         this.resetButton.type = "button";
@@ -265,7 +271,7 @@ export default class Parsons extends RunestoneBase {
         this.resetButton.addEventListener("click", function (event) {
             event.preventDefault();
             that.clearFeedback();
-            $(that.checkButton).prop("disabled", false);
+            that.checkButton.disabled = false;
             that.resetView();
             that.checkCount = 0;
             that.logMove("reset");
@@ -273,7 +279,7 @@ export default class Parsons extends RunestoneBase {
         });
         if (this.options.adaptive) {
             this.helpButton = document.createElement("button");
-            $(this.helpButton).attr("class", "btn btn-primary");
+            this.helpButton.className = "btn btn-primary";
             this.helpButton.textContent = t("msg_parson_help");
             this.helpButton.id = this.counterId + "-help";
             this.helpButton.disabled = false; // bje
@@ -289,13 +295,16 @@ export default class Parsons extends RunestoneBase {
         this.messageDiv.setAttribute("role", "status");
         this.parsonsControlDiv.appendChild(this.messageDiv);
         this.messageDiv.style.visibility = "hidden";
-        $(this.origElem).replaceWith(this.outerDiv);
-        $(this.outerDiv).closest(".sqcontainer").css("max-width", "none");
+        this.origElem.replaceWith(this.outerDiv);
+        const sqContainer = this.outerDiv.closest(".sqcontainer");
+        if (sqContainer) {
+            sqContainer.style.maxWidth = "none";
+        }
         if (this.outerDiv) {
-            if ($(this.question).html().match(/^\s+$/)) {
-                $(this.question).remove();
+            if (this.question.innerHTML.match(/^\s+$/)) {
+                this.question.remove();
             } else {
-                $(this.outerDiv).prepend(this.question);
+                this.outerDiv.prepend(this.question);
             }
         }
     }
@@ -444,7 +453,7 @@ export default class Parsons extends RunestoneBase {
                             }
                             solution.push(line);
                         }
-                        if ($.inArray(line.indent, indents) == -1) {
+                        if (indents.indexOf(line.indent) == -1) {
                             indents.push(line.indent);
                         }
                     }
@@ -626,7 +635,7 @@ export default class Parsons extends RunestoneBase {
                         }
                         solution.push(line);
                     }
-                    if ($.inArray(line.indent, indents) == -1) {
+                    if (indents.indexOf(line.indent) == -1) {
                         indents.push(line.indent);
                     }
                 }
@@ -1247,8 +1256,8 @@ export default class Parsons extends RunestoneBase {
 
                     if (hasNormalBlock) {
                         // if a placeholder has a normal block before or after, hide it;
-                        if (!$(placeholder.view).hasClass("hide")) {
-                            $(placeholder.view).addClass("hide");
+                        if (!placeholder.view.classList.contains("hide")) {
+                            placeholder.view.classList.add("hide");
                             for (
                                 var j = 0;
                                 j < answerBlocks.length - 1 - i;
@@ -1267,7 +1276,7 @@ export default class Parsons extends RunestoneBase {
             var contentLength = 0;
             while (
                 contentLength < answerBlocks.length &&
-                !$(answerBlocks[contentLength].view).hasClass("hide")
+                !answerBlocks[contentLength].view.classList.contains("hide")
             ) {
                 contentLength++;
             }
@@ -1319,8 +1328,8 @@ export default class Parsons extends RunestoneBase {
                     console.log("error moving up; no placeholder found");
                     return;
                 }
-                if ($(placeholder.view).hasClass("hide")) {
-                    $(placeholder.view).removeClass("hide");
+                if (placeholder.view.classList.contains("hide")) {
+                    placeholder.view.classList.remove("hide");
                     // should be moved right before the block answerBlocks[i]
                     for (var j = 0; j < placeholderIndex - i; j++) {
                         placeholder.moveUp();
@@ -1548,7 +1557,7 @@ export default class Parsons extends RunestoneBase {
             block = input[i];
             if (!block.isDistractor()) {
                 output.push(block);
-            } else if ($.inArray(block, distractors) == -1) {
+            } else if (distractors.indexOf(block) == -1) {
                 output.push(block);
             }
         }
@@ -1656,7 +1665,7 @@ export default class Parsons extends RunestoneBase {
                 for (i = 0; i < unorderedBlocks.length; i++) {
                     block = unorderedBlocks[i];
                     if (block.lines[0].distractor) {
-                        if ($.inArray(block, distractors) > -1) {
+                        if (distractors.indexOf(block) > -1) {
                             blocks.push(block);
                         } else {
                             removedBlocks.push(i);
@@ -1712,7 +1721,7 @@ export default class Parsons extends RunestoneBase {
                 block = originalBlocks[this.options.order[i]];
                 if (
                     block !== undefined &&
-                    $.inArray(this.options.order[i], removedBlocks) < 0
+                    removedBlocks.indexOf(this.options.order[i]) < 0
                 ) {
                     blocks.push(block);
                 }
@@ -1727,7 +1736,7 @@ export default class Parsons extends RunestoneBase {
                     var index =
                         this.options.order == undefined
                             ? Math.random(0, blocks.length)
-                            : $.inArray(removedBlocks[i], this.options.order);
+                            : this.options.order.indexOf(removedBlocks[i]);
                     blocks.splice(index, 0, originalBlocks[removedBlocks[i]]);
                 }
             }
@@ -1738,15 +1747,15 @@ export default class Parsons extends RunestoneBase {
             for (i = 1; i < originalBlocks.length; i++) {
                 if (
                     originalBlocks[i].lines[0].paired &&
-                    $.inArray(originalBlocks[i], blocks) >= 0
+                    blocks.indexOf(originalBlocks[i]) >= 0
                 ) {
                     var j = i;
-                    while ($.inArray(originalBlocks[j - 1], blocks) < 0) {
+                    while (blocks.indexOf(originalBlocks[j - 1]) < 0) {
                         // find the paired distractor or solution block it will be next to
                         j--;
                     }
-                    var indexTo = $.inArray(originalBlocks[j - 1], blocks);
-                    var indexFrom = $.inArray(originalBlocks[i], blocks);
+                    var indexTo = blocks.indexOf(originalBlocks[j - 1]);
+                    var indexFrom = blocks.indexOf(originalBlocks[i]);
                     blocks.splice(indexFrom, 1);
                     blocks.splice(indexTo, 0, originalBlocks[i]);
                 }
@@ -1887,10 +1896,10 @@ export default class Parsons extends RunestoneBase {
     // Return array of codeblocks based on what is in the source field
     sourceBlocks() {
         var sourceBlocks = [];
-        var children = this.sourceArea.childNodes;
+        var children = this.sourceArea.children;
         for (var i = 0; i < children.length; i++) {
             var child = children[i];
-            if ($(child).hasClass("block")) {
+            if (child.classList.contains("block")) {
                 sourceBlocks.push(this.getBlockById(child.id));
             }
         }
@@ -1982,7 +1991,7 @@ export default class Parsons extends RunestoneBase {
             if (this.grade == "correct") {
                 this.hasSolved = true;
                 this.correct = true;
-                $(this.checkButton).prop("disabled", true);
+                this.checkButton.disabled = true;
                 // CodeTailor: make the Copy Answer button visible when the puzzle is solved
                 if (this.hasSolved && this.options.scaffolding) {
                     const copyBtn = document.querySelector(
@@ -2060,40 +2069,38 @@ export default class Parsons extends RunestoneBase {
     renderFeedback() {
         this.grader.showfeedback = true;
         this.grade = this.grader.graderState;
-        var feedbackArea;
-        var answerArea = $(this.answerArea);
+        var answerArea = this.answerArea;
 
-        if (this.showfeedback === true) {
-            feedbackArea = $(this.messageDiv);
-        } else {
-            feedbackArea = $("#doesnotexist");
-        }
+        // When feedback is suppressed the messages are simply not written.
+        var feedbackArea = this.showfeedback === true ? this.messageDiv : null;
+        var setFeedback = (className, message) => {
+            if (feedbackArea) {
+                feedbackArea.setAttribute("class", className);
+                setTimeout(() => {
+                    feedbackArea.innerHTML = message;
+                }, 10);
+            }
+        };
 
         if (this.grade === "correct") {
-            answerArea.addClass("correct");
+            answerArea.classList.add("correct");
             this.messageDiv.style.visibility = "visible";
-            feedbackArea.attr("class", "alert alert-info");
             let message =
                 this.checkCount > 1
                     ? t("msg_parson_correct", this.checkCount)
                     : t("msg_parson_correct_first_try");
             if (this.options.runnable)
                 message += " " + t("msg_parson_correct_runnable");
-            setTimeout(() => {
-                feedbackArea.html(message);
-            }, 10);
+            setFeedback("alert alert-info", message);
             // Show block explanations after solving
             this.showBlockExplanations();
         }
 
         if (this.grade === "incorrectTooShort") {
             // too little code
-            answerArea.addClass("incorrect");
+            answerArea.classList.add("incorrect");
             this.messageDiv.style.visibility = "visible";
-            feedbackArea.attr("class", "alert alert-danger");
-            setTimeout(() => {
-                feedbackArea.html(t("msg_parson_too_short"));
-            }, 10);
+            setFeedback("alert alert-danger", t("msg_parson_too_short"));
         }
 
         if (this.grade === "incorrectIndent") {
@@ -2102,25 +2109,23 @@ export default class Parsons extends RunestoneBase {
                 block = this.grader.indentLeft[i].block();
                 if (incorrectBlocks.indexOf(block) == -1) {
                     incorrectBlocks.push(block);
-                    $(block.view).addClass("indentLeft");
+                    block.view.classList.add("indentLeft");
                 }
             }
             for (let i = 0; i < this.grader.indentRight.length; i++) {
                 block = this.grader.indentRight[i].block();
                 if (incorrectBlocks.indexOf(block) == -1) {
                     incorrectBlocks.push(block);
-                    $(block.view).addClass("indentRight");
+                    block.view.classList.add("indentRight");
                 }
             }
             this.messageDiv.style.visibility = "visible";
-            feedbackArea.attr("class", "alert alert-danger");
-            setTimeout(() => {
-                if (incorrectBlocks.length == 1) {
-                    feedbackArea.html(t("msg_parson_wrong_indent"));
-                } else {
-                    feedbackArea.html(t("msg_parson_wrong_indents"));
-                }
-            }, 10);
+            setFeedback(
+                "alert alert-danger",
+                incorrectBlocks.length == 1
+                    ? t("msg_parson_wrong_indent")
+                    : t("msg_parson_wrong_indents"),
+            );
         }
 
         if (this.grade === "incorrectMoveBlocks") {
@@ -2145,17 +2150,14 @@ export default class Parsons extends RunestoneBase {
             for (let i = 0; i < lisIndexes.length; i++) {
                 notInSolution.push(inSolution[lisIndexes[i]]);
             }
-            answerArea.addClass("incorrect");
+            answerArea.classList.add("incorrect");
             this.messageDiv.style.visibility = "visible";
-            feedbackArea.attr("class", "alert alert-danger");
             if (this.showfeedback === true) {
                 for (let i = 0; i < notInSolution.length; i++) {
-                    $(notInSolution[i].view).addClass("incorrectPosition");
+                    notInSolution[i].view.classList.add("incorrectPosition");
                 }
             }
-            setTimeout(() => {
-                feedbackArea.html(t("msg_parson_wrong_order"));
-            }, 10);
+            setFeedback("alert alert-danger", t("msg_parson_wrong_order"));
         }
     }
 
@@ -2185,20 +2187,21 @@ export default class Parsons extends RunestoneBase {
                 // set data-toggle="tooltip" and title attribute for hover display
                 block.view.setAttribute("data-toggle", "tooltip");
                 block.view.setAttribute("title", explanation);
-                $(block.view).addClass("has-explanation");
+                block.view.classList.add("has-explanation");
             }
         }
     }
 
     // Remove all block explanations (tooltip attributes)
     hideBlockExplanations() {
-        var blocks = $(this.answerArea).find(".has-explanation");
-        blocks.each(function () {
-            this.removeAttribute("data-toggle");
-            this.removeAttribute("title");
-            this.removeAttribute("data-original-title");
-        });
-        blocks.removeClass("has-explanation");
+        for (const view of this.answerArea.querySelectorAll(
+            ".has-explanation",
+        )) {
+            view.removeAttribute("data-toggle");
+            view.removeAttribute("title");
+            view.removeAttribute("data-original-title");
+            view.classList.remove("has-explanation");
+        }
     }
 
     /* =====================================================================
@@ -2299,11 +2302,11 @@ export default class Parsons extends RunestoneBase {
     // Remove this distractors to make the problem easier
     removeDistractor(block) {
         // Alert the user to what is happening
-        var feedbackArea = $(this.messageDiv);
+        var feedbackArea = this.messageDiv;
         this.messageDiv.style.visibility = "visible";
-        feedbackArea.attr("class", "alert alert-info");
+        feedbackArea.setAttribute("class", "alert alert-info");
         setTimeout(() => {
-            feedbackArea.html(t("msg_parson_not_solution"));
+            feedbackArea.innerHTML = t("msg_parson_not_solution");
         }, 10);
         // Stop ability to select
         if (block.lines[0].distractHelptext) {
@@ -2326,14 +2329,14 @@ export default class Parsons extends RunestoneBase {
             if (slideUnderBlock !== undefined) {
                 endY +=
                     slideUnderBlock.view.getBoundingClientRect().height + 20;
-                endY += parseInt($(slideUnderBlock.view).css("top"));
+                endY +=
+                    parseInt(getComputedStyle(slideUnderBlock.view).top) || 0;
             }
             var that = this;
-            $(block.view).css({
-                "border-color": "#000",
-                "background-color": "#fff",
-            });
-            $(block.view).animate(
+            block.view.style.borderColor = "#000";
+            block.view.style.backgroundColor = "#fff";
+            animate(
+                block.view,
                 {
                     opacity: 1.0,
                 },
@@ -2361,7 +2364,8 @@ export default class Parsons extends RunestoneBase {
                         delete that.movingX;
                         delete that.movingY;
                         that.updateView();
-                        $(block.view).animate(
+                        animate(
+                            block.view,
                             {
                                 opacity: 0.3,
                                 "border-color": "#d3d3d3",
@@ -2370,12 +2374,10 @@ export default class Parsons extends RunestoneBase {
                             {
                                 duration: 1000,
                                 complete: function () {
-                                    $(block.view).css({
-                                        opacity: "",
-                                        "border-color": "",
-                                        "background-color": "",
-                                    });
-                                    $(block.view).addClass("disabled");
+                                    block.view.style.opacity = "";
+                                    block.view.style.borderColor = "";
+                                    block.view.style.backgroundColor = "";
+                                    block.view.classList.add("disabled");
                                 },
                             },
                         );
@@ -2383,11 +2385,10 @@ export default class Parsons extends RunestoneBase {
                 },
             );
         } else {
-            $(block.view).css({
-                "border-color": "#000",
-                "background-color": "#fff",
-            });
-            $(block.view).animate(
+            block.view.style.borderColor = "#000";
+            block.view.style.backgroundColor = "#fff";
+            animate(
+                block.view,
                 {
                     opacity: 0.3,
                     "border-color": "#d3d3d3",
@@ -2396,10 +2397,8 @@ export default class Parsons extends RunestoneBase {
                 {
                     duration: 2000,
                     complete: function () {
-                        $(block.view).css({
-                            "border-color": "",
-                            "background-color": "",
-                        });
+                        block.view.style.borderColor = "";
+                        block.view.style.backgroundColor = "";
                     },
                 },
             );
@@ -2408,11 +2407,11 @@ export default class Parsons extends RunestoneBase {
     // Give the user the indentation
     removeIndentation() {
         // Alert the user to what is happening
-        var feedbackArea = $(this.messageDiv);
+        var feedbackArea = this.messageDiv;
         this.messageDiv.style.visibility = "visible";
-        feedbackArea.attr("class", "alert alert-info");
+        feedbackArea.setAttribute("class", "alert alert-info");
         setTimeout(() => {
-            feedbackArea.html(t("msg_parson_provided_indent"));
+            feedbackArea.innerHTML = t("msg_parson_provided_indent");
         }, 10);
         // Move and resize blocks
         var blockWidth = 200;
@@ -2432,7 +2431,8 @@ export default class Parsons extends RunestoneBase {
             block = sourceBlocks[i];
             indent = block.solutionIndent();
             if (indent == 0) {
-                $(block.view).animate(
+                animate(
+                    block.view,
                     {
                         width: blockWidth,
                     },
@@ -2441,7 +2441,8 @@ export default class Parsons extends RunestoneBase {
                     },
                 );
             } else {
-                $(block.view).animate(
+                animate(
+                    block.view,
                     {
                         width:
                             blockWidth - indent * this.options.pixelsPerIndent,
@@ -2455,7 +2456,8 @@ export default class Parsons extends RunestoneBase {
             }
         }
         for (let i = 0; i < this.pairedDivs.length; i++) {
-            $(this.pairedDivs[i]).animate(
+            animate(
+                this.pairedDivs[i],
                 {
                     width: blockWidth + 34,
                 },
@@ -2469,7 +2471,8 @@ export default class Parsons extends RunestoneBase {
             block = answerBlocks[i];
             indent = block.solutionIndent();
             if (indent == 0) {
-                $(block.view).animate(
+                animate(
+                    block.view,
                     {
                         left: 0,
                         width: blockWidth,
@@ -2479,7 +2482,8 @@ export default class Parsons extends RunestoneBase {
                     },
                 );
             } else {
-                $(block.view).animate(
+                animate(
+                    block.view,
                     {
                         left: 0,
                         width:
@@ -2494,11 +2498,17 @@ export default class Parsons extends RunestoneBase {
             }
         }
         // Resize answer and source area
-        $(this.answerArea).removeClass("answer1 answer2 answer3 answer4");
-        $(this.answerArea).addClass("answer");
+        this.answerArea.classList.remove(
+            "answer1",
+            "answer2",
+            "answer3",
+            "answer4",
+        );
+        this.answerArea.classList.add("answer");
         this.indent = 0;
         this.noindent = true;
-        $(this.sourceArea).animate(
+        animate(
+            this.sourceArea,
             {
                 width: this.areaWidth + 2,
             },
@@ -2506,7 +2516,8 @@ export default class Parsons extends RunestoneBase {
                 duration: 1000,
             },
         );
-        $(this.answerArea).animate(
+        animate(
+            this.answerArea,
             {
                 width: this.areaWidth + 2,
             },
@@ -2514,17 +2525,15 @@ export default class Parsons extends RunestoneBase {
                 duration: 1000,
             },
         );
-        // Change the model (with view)
-        $(this.answerArea).animate(
+        // Change the model (with view) once the resize animations are done
+        animate(
+            this.answerArea,
             {
                 opacity: 1.0,
             },
             {
                 duration: 1100,
                 complete: function () {
-                    $(this.answerArea).css({
-                        opacity: "",
-                    });
                     // Update the model
                     for (let i = 0; i < sourceBlocks.length; i++) {
                         sourceBlocks[i].addIndent();
@@ -2603,11 +2612,11 @@ export default class Parsons extends RunestoneBase {
         var sourceBlocks = this.sourceBlocks();
 
         // Alert the user to what is happening
-        var feedbackArea = $(this.messageDiv);
+        var feedbackArea = this.messageDiv;
         this.messageDiv.style.visibility = "visible";
-        feedbackArea.attr("class", "alert alert-info");
+        feedbackArea.setAttribute("class", "alert alert-info");
         setTimeout(() => {
-            feedbackArea.html(t("msg_parson_combined_blocks"));
+            feedbackArea.innerHTML = t("msg_parson_combined_blocks");
         }, 10);
         var block1 = null;
         var block2 = null;
@@ -2667,21 +2676,18 @@ export default class Parsons extends RunestoneBase {
                 endY += block2.view.getBoundingClientRect().height / 2;
             }
             var that = this;
-            $(block2.view).animate(
+            animate(
+                block2.view,
                 {
                     opacity: 1,
                 },
                 {
                     duration: 1000, // 1 seccond
                     start: function () {
-                        $(block1.view).css({
-                            "border-color": "#000",
-                            "background-color": "#fff",
-                        });
-                        $(block2.view).css({
-                            "border-color": "#000",
-                            "background-color": "#fff",
-                        });
+                        block1.view.style.borderColor = "#000";
+                        block1.view.style.backgroundColor = "#fff";
+                        block2.view.style.borderColor = "#000";
+                        block2.view.style.backgroundColor = "#fff";
                         block2.lines[0].index += 1000;
                         that.moving = block2;
                         that.movingX = startX;
@@ -2700,7 +2706,8 @@ export default class Parsons extends RunestoneBase {
                         that.updateView();
                         block2.lines[0].index -= 1000;
                         block1.consumeBlock(block2);
-                        $(block1.view).animate(
+                        animate(
+                            block1.view,
                             {
                                 "border-color": "#d3d3d3",
                                 "background-color": "#efefef",
@@ -2708,10 +2715,8 @@ export default class Parsons extends RunestoneBase {
                             {
                                 duration: 1000,
                                 complete: function () {
-                                    $(block1.view).css({
-                                        "border-color": "",
-                                        "background-color": "",
-                                    });
+                                    block1.view.style.borderColor = "";
+                                    block1.view.style.backgroundColor = "";
                                 },
                             },
                         );
@@ -2719,25 +2724,23 @@ export default class Parsons extends RunestoneBase {
                 },
             );
         } else {
-            $(block2.view).animate(
+            animate(
+                block2.view,
                 {
                     opacity: 1,
                 },
                 {
                     duration: 1000,
                     start: function () {
-                        $(block1.view).css({
-                            "border-color": "#000",
-                            "background-color": "#fff",
-                        });
-                        $(block2.view).css({
-                            "border-color": "#000",
-                            "background-color": "#fff",
-                        });
+                        block1.view.style.borderColor = "#000";
+                        block1.view.style.backgroundColor = "#fff";
+                        block2.view.style.borderColor = "#000";
+                        block2.view.style.backgroundColor = "#fff";
                     },
                     complete: function () {
                         block1.consumeBlock(block2);
-                        $(block1.view).animate(
+                        animate(
+                            block1.view,
                             {
                                 "border-color": "#d3d3d3",
                                 "background-color": "#efefef",
@@ -2745,10 +2748,8 @@ export default class Parsons extends RunestoneBase {
                             {
                                 duration: 1000,
                                 complete: function () {
-                                    $(block1.view).css({
-                                        "border-color": "",
-                                        "background-color": "",
-                                    });
+                                    block1.view.style.borderColor = "";
+                                    block1.view.style.backgroundColor = "";
                                 },
                             },
                         );
@@ -2842,27 +2843,30 @@ export default class Parsons extends RunestoneBase {
     ===================================================================== */
     // When the user has entered the Parsons problem via keyboard mode
     enterKeyboardMode() {
-        $(this.keyboardTip).show();
-        $(this.sourceLabel).hide();
-        $(this.answerLabel).hide();
+        this.keyboardTip.style.display = "";
+        this.sourceLabel.style.display = "none";
+        this.answerLabel.style.display = "none";
         this.clearFeedback();
     }
     // When the user leaves the Parsons problem via keyboard mode
     exitKeyboardMode() {
-        $(this.keyboardTip).hide();
-        $(this.sourceLabel).show();
-        $(this.answerLabel).show();
+        this.keyboardTip.style.display = "none";
+        this.sourceLabel.style.display = "";
+        this.answerLabel.style.display = "";
     }
     /* =====================================================================
     ==== VIEW ==============================================================
     ===================================================================== */
     // Clear any feedback from the answer area
     clearFeedback() {
-        $(this.answerArea).removeClass("incorrect correct");
-        var children = this.answerArea.childNodes;
+        this.answerArea.classList.remove("incorrect", "correct");
+        var children = this.answerArea.children;
         for (var i = 0; i < children.length; i++) {
-            $(children[i]).removeClass(
-                "correctPosition incorrectPosition indentLeft indentRight",
+            children[i].classList.remove(
+                "correctPosition",
+                "incorrectPosition",
+                "indentLeft",
+                "indentRight",
             );
         }
         this.messageDiv.style.visibility = "hidden";
@@ -2880,8 +2884,8 @@ export default class Parsons extends RunestoneBase {
             }
         }
         // Hide buttons
-        $(this.checkButton).hide();
-        $(this.resetButton).hide();
+        this.checkButton.style.display = "none";
+        this.resetButton.style.display = "none";
     }
     // Based on the moving element, etc., establish the moving state
     //   rest: not moving
@@ -2916,6 +2920,13 @@ export default class Parsons extends RunestoneBase {
         }
         return "moving";
     }
+    // Absolutely position a block or indicator view inside its area
+    placeView(el, left, top, width, zIndex) {
+        el.style.left = left + "px";
+        el.style.top = top + "px";
+        el.style.width = width + "px";
+        el.style.zIndex = zIndex;
+    }
     // Update the Parsons view
     // This gets called when dragging a block
     updateView() {
@@ -2941,8 +2952,8 @@ export default class Parsons extends RunestoneBase {
         var movingHeight;
         if (this.moving !== undefined) {
             // Must get height here as detached items don't have height
-            movingHeight = $(this.moving.view).outerHeight(true);
-            $(this.moving.view).detach();
+            movingHeight = outerHeight(this.moving.view);
+            this.moving.view.remove();
         }
         var positionTop, width;
         var baseWidth = this.areaWidth - 22;
@@ -3003,12 +3014,12 @@ export default class Parsons extends RunestoneBase {
                     var item = blocks[i];
                     var j;
                     if (!hasInserted && insertPositions.includes(i)) {
-                        var testHeight = $(item.view).outerHeight(true);
+                        var testHeight = outerHeight(item.view);
                         for (j = i + 1; j < blocks.length; j++) {
                             if (insertPositions.includes(j)) {
                                 break;
                             }
-                            testHeight += $(blocks[j].view).outerHeight(true);
+                            testHeight += outerHeight(blocks[j].view);
                         }
                         if (
                             y - positionTop < movingHeight + testHeight / 2 ||
@@ -3019,44 +3030,34 @@ export default class Parsons extends RunestoneBase {
                                 this.moving.view,
                                 item.view,
                             );
-                            $(this.moving.view).css({
-                                left: x,
-                                top: y - movingHeight / 2,
-                                width: baseWidth,
-                                "z-index": 3,
-                            });
+                            this.placeView(
+                                this.moving.view,
+                                x,
+                                y - movingHeight / 2,
+                                baseWidth,
+                                3,
+                            );
                             positionTop = positionTop + movingHeight;
                         }
                     }
-                    $(item.view).css({
-                        left: 0,
-                        top: positionTop,
-                        width: baseWidth,
-                        "z-index": 2,
-                    });
-                    positionTop = positionTop + $(item.view).outerHeight(true);
+                    this.placeView(item.view, 0, positionTop, baseWidth, 2);
+                    positionTop = positionTop + outerHeight(item.view);
                 }
                 if (!hasInserted) {
-                    $(this.moving.view).appendTo(
-                        "#" + this.counterId + "-source",
+                    this.sourceArea.appendChild(this.moving.view);
+                    this.placeView(
+                        this.moving.view,
+                        x,
+                        y - outerHeight(this.moving.view) / 2,
+                        baseWidth,
+                        3,
                     );
-                    $(this.moving.view).css({
-                        left: x,
-                        top: y - $(this.moving.view).outerHeight(true) / 2,
-                        width: baseWidth,
-                        "z-index": 3,
-                    });
                 }
             } else {
                 for (var i = 0; i < blocks.length; i++) {
                     item = blocks[i];
-                    $(item.view).css({
-                        left: 0,
-                        top: positionTop,
-                        width: baseWidth,
-                        "z-index": 2,
-                    });
-                    positionTop = positionTop + $(item.view).outerHeight(true);
+                    this.placeView(item.view, 0, positionTop, baseWidth, 2);
+                    positionTop = positionTop + outerHeight(item.view);
                 }
             }
             // Update the Paired Distractor Indicators
@@ -3071,36 +3072,30 @@ export default class Parsons extends RunestoneBase {
                 }
                 var div = this.pairedDivs[i];
                 if (matching.length == 0) {
-                    $(div).hide();
+                    div.style.display = "none";
                 } else {
-                    $(div).show();
+                    div.style.display = "";
                     var height = -5;
-                    height += parseInt(
-                        $(matching[matching.length - 1].view).css("top"),
-                    );
-                    height -= parseInt($(matching[0].view).css("top"));
-                    height += $(matching[matching.length - 1].view).outerHeight(
-                        true,
-                    );
-                    $(div).css({
-                        left: -6,
-                        top: $(matching[0].view).css("top"),
-                        width: baseWidth + 34,
-                        height: height,
-                        "z-index": 1,
-                        "text-indent": -30,
-                        "padding-top": (height - 70) / 2,
-                        overflow: "visible",
-                        "font-size": 43,
-                        "vertical-align": "middle",
-                        color: "#7e7ee7",
-                    });
-                    $(div).html(
-                        "or{",
-                    );
+                    var lastView = matching[matching.length - 1].view;
+                    height += parseInt(getComputedStyle(lastView).top);
+                    height -= parseInt(getComputedStyle(matching[0].view).top);
+                    height += outerHeight(lastView);
+                    div.style.left = "-6px";
+                    div.style.top = getComputedStyle(matching[0].view).top;
+                    div.style.width = baseWidth + 34 + "px";
+                    div.style.height = height + "px";
+                    div.style.zIndex = 1;
+                    div.style.textIndent = "-30px";
+                    div.style.paddingTop = (height - 70) / 2 + "px";
+                    div.style.overflow = "visible";
+                    div.style.fontSize = "43px";
+                    div.style.verticalAlign = "middle";
+                    div.style.color = "#7e7ee7";
+                    div.innerHTML =
+                        "or{";
                 }
                 if (matching.length == 1) {
-                    $(div).html("");
+                    div.innerHTML = "";
                 }
             }
         }
@@ -3139,53 +3134,55 @@ export default class Parsons extends RunestoneBase {
                     if (!hasInserted) {
                         if (
                             y - positionTop <
-                            (movingHeight + $(block.view).outerHeight(true)) / 2
+                            (movingHeight + outerHeight(block.view)) / 2
                         ) {
                             hasInserted = true;
                             this.answerArea.insertBefore(
                                 this.moving.view,
                                 block.view,
                             );
-                            $(this.moving.view).css({
-                                left: x,
-                                top: y - movingHeight / 2,
-                                width: baseWidth,
-                                "z-index": 3,
-                            });
+                            this.placeView(
+                                this.moving.view,
+                                x,
+                                y - movingHeight / 2,
+                                baseWidth,
+                                3,
+                            );
                             positionTop = positionTop + movingHeight;
                         }
                     }
                     indent = block.indent * this.options.pixelsPerIndent;
-                    $(block.view).css({
-                        left: indent,
-                        top: positionTop,
-                        width: width - indent,
-                        "z-index": 2,
-                    });
-                    positionTop = positionTop + $(block.view).outerHeight(true);
+                    this.placeView(
+                        block.view,
+                        indent,
+                        positionTop,
+                        width - indent,
+                        2,
+                    );
+                    positionTop = positionTop + outerHeight(block.view);
                 }
                 if (!hasInserted) {
-                    $(this.moving.view).appendTo(
-                        "#" + this.counterId + "-answer",
+                    this.answerArea.appendChild(this.moving.view);
+                    this.placeView(
+                        this.moving.view,
+                        x,
+                        y - outerHeight(this.moving.view) / 2,
+                        baseWidth,
+                        3,
                     );
-                    $(this.moving.view).css({
-                        left: x,
-                        top: y - $(this.moving.view).outerHeight(true) / 2,
-                        width: baseWidth,
-                        "z-index": 3,
-                    });
                 }
             } else {
                 for (let i = 0; i < blocks.length; i++) {
                     block = blocks[i];
                     indent = block.indent * this.options.pixelsPerIndent;
-                    $(block.view).css({
-                        left: indent,
-                        top: positionTop,
-                        width: width - indent,
-                        "z-index": 2,
-                    });
-                    positionTop = positionTop + $(block.view).outerHeight(true);
+                    this.placeView(
+                        block.view,
+                        indent,
+                        positionTop,
+                        width - indent,
+                        2,
+                    );
+                    positionTop = positionTop + outerHeight(block.view);
                 }
             }
         }
@@ -3194,7 +3191,7 @@ export default class Parsons extends RunestoneBase {
             // Add it to the lowest place in the source area
             movingBin = this.moving.pairedBin();
             if (movingBin == -1) {
-                $(this.moving.view).appendTo("#" + this.counterId + "-source");
+                this.sourceArea.appendChild(this.moving.view);
             } else {
                 var before;
                 blocks = this.sourceBlocks;
@@ -3205,9 +3202,7 @@ export default class Parsons extends RunestoneBase {
                     }
                 }
                 if (before == undefined || before == blocks.length) {
-                    $(this.moving.view).appendTo(
-                        "#" + this.counterId + "-source",
-                    );
+                    this.sourceArea.appendChild(this.moving.view);
                 } else {
                     this.sourceArea.insertBefore(
                         this.moving.view,
@@ -3216,20 +3211,19 @@ export default class Parsons extends RunestoneBase {
                 }
             }
             // Place in the middle of the mouse cursor
-            $(this.moving.view).css({
-                left:
-                    this.movingX -
+            this.placeView(
+                this.moving.view,
+                this.movingX -
                     this.sourceArea.getBoundingClientRect().left -
                     window.pageXOffset -
-                    $(this.moving.view).outerWidth(true) / 2,
-                top:
-                    this.movingY -
+                    outerWidth(this.moving.view) / 2,
+                this.movingY -
                     this.sourceArea.getBoundingClientRect().top -
                     window.pageYOffset -
                     movingHeight / 2,
-                width: baseWidth,
-                "z-index": 3,
-            });
+                baseWidth,
+                3,
+            );
         }
         state = newState;
         this.state = state;
@@ -3268,12 +3262,14 @@ export default class Parsons extends RunestoneBase {
         }
         if (blocksNotInBins + this.pairedBins.length >= 10) {
             this.areaWidth += 5;
-            $(this.sourceArea).css({
-                width: $(this.sourceArea).width() + 5,
-            });
-            $(this.answerArea).css({
-                width: $(this.answerArea).width() + 5,
-            });
+            this.sourceArea.style.width =
+                (parseFloat(getComputedStyle(this.sourceArea).width) || 0) +
+                5 +
+                "px";
+            this.answerArea.style.width =
+                (parseFloat(getComputedStyle(this.answerArea).width) || 0) +
+                5 +
+                "px";
         }
     }
     // Put all the blocks back into the source area, reshuffling as necessary
@@ -3294,24 +3290,25 @@ export default class Parsons extends RunestoneBase {
         for (let i = 0; i < this.blocks.length; i++) {
             block = this.blocks[i];
             for (var j = 0; j < block.lines.length; j++) {
-                var children = $(block.lines[j].view).find(".block-label");
+                var children =
+                    block.lines[j].view.querySelectorAll(".block-label");
                 for (var c = 0; c < children.length; c++) {
                     children[c].remove();
                 }
             }
             block.destroy();
-            $(this.blocks[i].view).detach();
+            this.blocks[i].view.remove();
         }
         delete this.blocks;
         this.blockIndex = 0;
         if (this.pairedDivs) {
             for (let i = 0; i < this.pairedDivs.length; i++) {
-                $(this.pairedDivs[i]).detach();
+                this.pairedDivs[i].remove();
             }
         }
-        $(this.sourceArea).attr("style", "");
-        $(this.answerArea).removeClass();
-        $(this.answerArea).attr("style", "");
+        this.sourceArea.setAttribute("style", "");
+        this.answerArea.className = "";
+        this.answerArea.setAttribute("style", "");
         this.noindent = this.options.noindent;
         // Reinitialize
         if (this.hasSolved) {
@@ -3351,19 +3348,19 @@ export default class Parsons extends RunestoneBase {
 
 Parsons.counter = 0;
 
-$(document).on("runestone:login-complete", function () {
-    $("[data-component=parsons]").each(function (index) {
-        if ($(this).closest("[data-component=timedAssessment]").length == 0) {
+document.addEventListener("runestone:login-complete", function () {
+    for (const el of document.querySelectorAll("[data-component=parsons]")) {
+        if (el.closest("[data-component=timedAssessment]") === null) {
             try {
-                window.componentMap[this.id] = new Parsons({
-                    orig: this,
+                window.componentMap[el.id] = new Parsons({
+                    orig: el,
                     useRunestoneServices: eBookConfig.useRunestoneServices,
                 });
             } catch (err) {
-                console.log(`Error rendering Parsons Problem ${this.id}
+                console.log(`Error rendering Parsons Problem ${el.id}
                              Details: ${err}`);
                 console.log(err.stack);
             }
         }
-    });
+    }
 });
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js b/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js
index 0f4c54a99..d9035d4cc 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsonsBlock.js
@@ -12,6 +12,15 @@
 
 import Hammer from "./hammer.min.js";
 
+const INDENT_CLASSES = [
+    "indent1",
+    "indent2",
+    "indent3",
+    "indent4",
+    "indent5",
+    "indent6",
+];
+
 // Initialize based on the problem and the lines
 export default class ParsonsBlock {
     constructor(problem, lines) {
@@ -23,14 +32,14 @@ export default class ParsonsBlock {
         var view = document.createElement("div");
         view.id = problem.counterId + "-block-" + problem.blockIndex;
         problem.blockIndex += 1;
-        $(view).addClass("block");
+        view.classList.add("block");
         var sharedIndent = lines[0].indent;
         for (let i = 1; i < lines.length; i++) {
             sharedIndent = Math.min(sharedIndent, lines[i].indent);
         }
         var lineDiv = document.createElement("div");
-        $(lineDiv).addClass("lines");
-        $(view).append(lineDiv);
+        lineDiv.classList.add("lines");
+        view.append(lineDiv);
         for (let i = 0; i < lines.length; i++) {
             var line = lines[i];
             var lineIndent;
@@ -39,36 +48,28 @@ export default class ParsonsBlock {
             } else {
                 lineIndent = line.indent - sharedIndent;
             }
-            $(line.view).removeClass(
-                "indent1 indent2 indent3 indent4 indent5 indent6",
-            );
+            line.view.classList.remove(...INDENT_CLASSES);
             if (
                 this.problem.options.language != "natural" &&
                 this.problem.options.language != "math"
             ) {
                 if (lineIndent > 0) {
-                    $(line.view).addClass("indent" + lineIndent);
+                    line.view.classList.add("indent" + lineIndent);
                 }
             }
             lineDiv.appendChild(line.view);
         }
         var labelDiv = document.createElement("div");
-        $(labelDiv).addClass("labels");
+        labelDiv.classList.add("labels");
         if (this.problem.options.numbered == "left") {
-            $(lineDiv).addClass("border_left");
-            $(view).prepend(labelDiv);
-            $(view).css({
-                "justify-content": "flex-start",
-            });
+            lineDiv.classList.add("border_left");
+            view.prepend(labelDiv);
+            view.style.justifyContent = "flex-start";
         } else if (this.problem.options.numbered == "right") {
-            $(labelDiv).addClass("border_left");
-            $(labelDiv).css({
-                float: "right",
-            });
-            $(view).css({
-                "justify-content": "space-between",
-            });
-            $(view).append(labelDiv);
+            labelDiv.classList.add("border_left");
+            labelDiv.style.float = "right";
+            view.style.justifyContent = "space-between";
+            view.append(labelDiv);
         }
         this.view = view;
         // is not placeholder by default
@@ -76,12 +77,10 @@ export default class ParsonsBlock {
     }
     // Add a line (from another block) to this block
     addLine(line) {
-        $(line.view).removeClass(
-            "indent1 indent2 indent3 indent4 indent5 indent6",
-        );
+        line.view.classList.remove(...INDENT_CLASSES);
         if (this.problem.noindent) {
             if (line.indent > 0) {
-                $(line.view).addClass("indent" + line.indent);
+                line.view.classList.add("indent" + line.indent);
             }
         } else {
             var lines = this.lines;
@@ -90,18 +89,18 @@ export default class ParsonsBlock {
                 sharedIndent = Math.min(sharedIndent, lines[i].indent);
             }
             if (sharedIndent < line.indent) {
-                $(line.view).addClass("indent" + (line.indent - sharedIndent));
+                line.view.classList.add(
+                    "indent" + (line.indent - sharedIndent),
+                );
             } else if (sharedIndent > line.indent) {
                 for (let i = 0; i < lines.length; i++) {
-                    $(lines[i].view).removeClass(
-                        "indent1 indent2 indent3 indent4 indent5 indent6",
-                    );
+                    lines[i].view.classList.remove(...INDENT_CLASSES);
                     // todo: if language is natural or math then don't do this
                     if (
                         this.problem.options.language !== "natural" &&
                         this.problem.options.language !== "math"
                     ) {
-                        $(lines[i].view).addClass(
+                        lines[i].view.classList.add(
                             "indent" + (lines[i].indent - line.indent),
                         );
                     }
@@ -109,17 +108,17 @@ export default class ParsonsBlock {
             }
         }
         this.lines.push(line);
-        $(this.view).children(".lines")[0].appendChild(line.view);
+        this.view.querySelector(".lines").appendChild(line.view);
     }
     // Add the contents of that block to myself and then delete that block
     consumeBlock(block) {
         for (let i = 0; i < block.lines.length; i++) {
             this.addLine(block.lines[i]);
         }
-        if ($(block.view).attr("tabindex") == "0") {
+        if (block.view.getAttribute("tabindex") == "0") {
             this.makeTabIndex();
         }
-        $(block.view).detach();
+        block.view.remove();
         var newBlocks = [];
         for (let i = 0; i < this.problem.blocks.length; i++) {
             if (this.problem.blocks[i] !== block) {
@@ -142,46 +141,41 @@ export default class ParsonsBlock {
         for (let i = 0; i < this.lines.length; i++) {
             var line = this.lines[i];
             if (line.indent > 0) {
-                $(line.view).removeClass(
-                    "indent1 indent2 indent3 indent4 indent5 indent6",
-                );
-                $(line.view).addClass("indent" + line.indent);
+                line.view.classList.remove(...INDENT_CLASSES);
+                line.view.classList.add("indent" + line.indent);
             }
         }
         // Update the block model / view
         this.indent = 0;
-        $(this.view).css({
-            "padding-left": "",
-            width: this.problem.areaWidth - 22,
-        });
+        this.view.style.paddingLeft = "";
+        this.view.style.width = this.problem.areaWidth - 22 + "px";
     }
     // Add a label to block and update its view
     addLabel(label, line) {
         var div = document.createElement("div");
-        $(div).addClass("block-label");
+        div.classList.add("block-label");
         if (this.problem.options.numbered == "right") {
-            $(div).addClass("right-label");
+            div.classList.add("right-label");
         }
         if (this.problem.options.numbered == "left") {
-            $(div).addClass("left-label");
+            div.classList.add("left-label");
         }
-        $(div).append(document.createTextNode(label));
-        $(this.view).children(".labels")[0].append(div);
+        div.append(document.createTextNode(label));
+        this.view.querySelector(".labels").append(div);
         if (this.labels.length != 0) {
-            $(div).css(
-                "margin-top",
+            div.style.marginTop =
                 (line - this.labels[this.labels.length - 1][1] - 1) *
-                    this.lines[line].view.offsetHeight,
-            );
+                    this.lines[line].view.offsetHeight +
+                "px";
         }
         this.labels.push([label, line]);
     }
     // Initialize Interactivity
     initializeInteractivity() {
-        if ($(this.view).hasClass("disabled")) {
+        if (this.view.classList.contains("disabled")) {
             return this;
         }
-        $(this.view).attr("tabindex", "-1");
+        this.view.setAttribute("tabindex", "-1");
         this.hammer = new Hammer.Manager(this.view, {
             recognizers: [
                 [
@@ -207,7 +201,7 @@ export default class ParsonsBlock {
     }
     // Return a boolean as to whether this block is able to be selected
     enabled() {
-        return $(this.view).attr("tabindex") !== undefined;
+        return this.view.getAttribute("tabindex") !== null;
     }
     // Return a boolean as to whether this block is a distractor
     isDistractor() {
@@ -350,24 +344,24 @@ export default class ParsonsBlock {
             this.problem.enterKeyboardMode();
             this.problem.textFocus = this;
             this.problem.textMove = false;
-            $(this.view).addClass("down");
+            this.view.classList.add("down");
         } else if (this.problem.textFocus == this) {
             if (this.problem.textMove) {
-                $(this.view).addClass("up");
+                this.view.classList.add("up");
             } else {
-                $(this.view).addClass("down");
+                this.view.classList.add("down");
             }
         } else {
             // already in keyboard mode
             this.problem.textFocus = this;
             this.problem.textMove = false;
-            $(this.view).addClass("down");
+            this.view.classList.add("down");
         }
         this.problem.textMoving = false;
     }
     // This block just lost textual focus
     releaseFocus() {
-        $(this.view).removeClass("down up");
+        this.view.classList.remove("down", "up");
         if (this.problem.textFocus == this) {
             if (!this.problem.textMoving) {
                 // exit out of problem but stay way into problem
@@ -382,37 +376,36 @@ export default class ParsonsBlock {
             }
         } else {
             // become selectable, but not active
-            $(this.view).attr("tabindex", "-1");
-            $(this.view).unbind("focus");
-            $(this.view).unbind("blur");
-            $(this.view).unbind("keydown");
+            this.view.setAttribute("tabindex", "-1");
+            this.view.removeEventListener("focus", this.focusHandler);
+            this.view.removeEventListener("blur", this.blurHandler);
+            this.view.removeEventListener("keydown", this.keydownHandler);
         }
     }
     // Make this block into the keyboard entry point
     makeTabIndex() {
-        $(this.view).attr("tabindex", "0");
-        var that = this;
-        $(this.view).focus(function () {
-            that.newFocus();
-        });
-        $(this.view).blur(function () {
-            that.releaseFocus();
-        });
-        $(this.view).keydown(function (event) {
-            that.keyDown(event);
-        });
+        this.view.setAttribute("tabindex", "0");
+        if (this.focusHandler === undefined) {
+            this.focusHandler = () => this.newFocus();
+            this.blurHandler = () => this.releaseFocus();
+            this.keydownHandler = (event) => this.keyDown(event);
+        }
+        // re-adding an identical listener is a no-op, so repeated calls are safe
+        this.view.addEventListener("focus", this.focusHandler);
+        this.view.addEventListener("blur", this.blurHandler);
+        this.view.addEventListener("keydown", this.keydownHandler);
     }
     // Called to disable interaction for the future
     disable() {
         if (this.hammer !== undefined) {
             this.hammer.set({ enable: false });
         }
-        if ($(this.view).attr("tabindex") == "0") {
+        if (this.view.getAttribute("tabindex") == "0") {
             this.releaseFocus();
-            $(this.view).removeAttr("tabindex");
+            this.view.removeAttribute("tabindex");
             this.problem.initializeTabIndex();
         } else {
-            $(this.view).removeAttr("tabindex");
+            this.view.removeAttribute("tabindex");
         }
     }
     // Enable functionality after reset button has been pressed
@@ -420,10 +413,10 @@ export default class ParsonsBlock {
         if (this.hammer !== undefined) {
             this.hammer.set({ enable: true });
         }
-        if (!$(this.view)[0].hasAttribute("tabindex")) {
-            $(this.view).attr("tabindex", "-1");
+        if (!this.view.hasAttribute("tabindex")) {
+            this.view.setAttribute("tabindex", "-1");
         }
-        $(this.view).css({ opacity: "" });
+        this.view.style.opacity = "";
     }
     // Called to destroy interaction for the future
     destroy() {
@@ -431,10 +424,10 @@ export default class ParsonsBlock {
             this.hammer.destroy();
             delete this.hammer;
         }
-        if ($(this.view).attr("tabindex") == "0") {
+        if (this.view.getAttribute("tabindex") == "0") {
             this.releaseFocus();
         }
-        $(this.view).removeAttr("tabindex");
+        this.view.removeAttribute("tabindex");
     }
     // Called when a block is picked up
     panStart(event) {
@@ -534,7 +527,7 @@ export default class ParsonsBlock {
                     if (index == blocks.length) {
                         this.problem.textMoving = true;
                         this.problem.sourceArea.appendChild(this.view);
-                        $(this.view).focus();
+                        this.view.focus();
                         this.problem.state = undefined;
                         this.problem.updateView();
                         return this;
@@ -547,7 +540,7 @@ export default class ParsonsBlock {
                 }
                 this.problem.textMoving = true;
                 this.problem.sourceArea.insertBefore(this.view, block.view);
-                $(this.view).focus();
+                this.view.focus();
             } else {
                 // reduce indent
                 this.indent = this.indent - 1;
@@ -573,7 +566,7 @@ export default class ParsonsBlock {
                             this.view,
                             block.view,
                         );
-                        $(this.view).focus();
+                        this.view.focus();
                         this.problem.state = undefined;
                         this.problem.updateView();
                         return this;
@@ -592,7 +585,7 @@ export default class ParsonsBlock {
                         this.view,
                         blocks[i - 1].view,
                     );
-                    $(this.view).focus();
+                    this.view.focus();
                     this.problem.state = undefined;
                     this.problem.updateView();
                 }
@@ -612,7 +605,7 @@ export default class ParsonsBlock {
                 if (itemOffset >= offset) {
                     this.problem.textMoving = true;
                     this.problem.answerArea.insertBefore(this.view, item.view);
-                    $(this.view).focus();
+                    this.view.focus();
                     this.problem.state = undefined;
                     this.problem.updateView();
                     return this;
@@ -620,7 +613,7 @@ export default class ParsonsBlock {
             }
             this.problem.textMoving = true;
             this.problem.answerArea.appendChild(this.view);
-            $(this.view).focus();
+            this.view.focus();
             this.problem.state = undefined;
             this.problem.updateView();
         } else {
@@ -647,7 +640,7 @@ export default class ParsonsBlock {
                 if (index == blocks.length) {
                     this.problem.textMoving = true;
                     this.problem.sourceArea.appendChild(this.view);
-                    $(this.view).focus();
+                    this.view.focus();
                     this.problem.state = undefined;
                     this.problem.updateView();
                     return this;
@@ -657,7 +650,7 @@ export default class ParsonsBlock {
                         this.view,
                         blocks[index].view,
                     );
-                    $(this.view).focus();
+                    this.view.focus();
                     this.problem.state = undefined;
                     this.problem.updateView();
                     return this;
@@ -679,7 +672,7 @@ export default class ParsonsBlock {
                             blocks[i + 2].view,
                         );
                     }
-                    $(this.view).focus();
+                    this.view.focus();
                     this.problem.state = undefined;
                     this.problem.updateView();
                 }
@@ -706,7 +699,7 @@ export default class ParsonsBlock {
             }
             this.problem.textFocus = chooseNext;
             chooseNext.makeTabIndex();
-            $(chooseNext.view).focus();
+            chooseNext.view.focus();
         }
     }
     // Move selection up
@@ -723,7 +716,7 @@ export default class ParsonsBlock {
             if (chooseNext) {
                 this.problem.textFocus = item;
                 item.makeTabIndex();
-                $(item.view).focus();
+                item.view.focus();
                 return this;
             } else {
                 if (item.view.id == this.view.id) {
@@ -752,7 +745,7 @@ export default class ParsonsBlock {
             }
             this.problem.textFocus = chooseNext;
             chooseNext.makeTabIndex();
-            $(chooseNext.view).focus();
+            chooseNext.view.focus();
         }
     }
     // Move selection down
@@ -769,7 +762,7 @@ export default class ParsonsBlock {
             if (chooseNext) {
                 this.problem.textFocus = item;
                 item.makeTabIndex();
-                $(item.view).focus();
+                item.view.focus();
                 return this;
             } else {
                 if (item.view.id == this.view.id) {
@@ -781,16 +774,16 @@ export default class ParsonsBlock {
     // Toggle whether to move this block
     toggleMove() {
         if (this.problem.textMove) {
-            $(this.view).removeClass("up");
-            $(this.view).addClass("down");
+            this.view.classList.remove("up");
+            this.view.classList.add("down");
             this.problem.textMove = false;
             this.problem.logMove("kmove");
             if (!this.isPlaceholder) {
                 this.problem.updatePlaceholders();
             }
         } else {
-            $(this.view).removeClass("down");
-            $(this.view).addClass("up");
+            this.view.classList.remove("down");
+            this.view.classList.add("up");
             this.problem.textMove = true;
         }
     }
diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsonsLine.js b/bases/rsptx/interactives/runestone/parsons/js/parsonsLine.js
index e7f56633c..5bc9f8e73 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsonsLine.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsonsLine.js
@@ -17,6 +17,8 @@
 ===================================================================== */
 // Initialize from codestring
 
+import { outerWidth } from "../../common/js/domutil.js";
+
 export default class ParsonsLine {
     constructor(problem, codestring, displaymath) {
         this.problem = problem;
@@ -88,7 +90,11 @@ export default class ParsonsLine {
             }
         } else {
             view = document.createElement("code");
-            $(view).addClass(problem.options.prettifyLanguage);
+            if (problem.options.prettifyLanguage) {
+                view.classList.add(
+                    ...problem.options.prettifyLanguage.split(" "),
+                );
+            }
         }
         view.id = problem.counterId + "-line-" + this.index;
         view.innerHTML += this.text;
@@ -100,7 +106,7 @@ export default class ParsonsLine {
         // this.width does not appear to be used anywhere later
         // since changing the value of this.width appears to have no effect. - Vincent Qiu (September 2020)
         this.width =
-            $(this.view).outerWidth(true) -
+            outerWidth(this.view) -
             this.problem.options.pixelsPerIndent * this.indent;
 
         // Pass this information on to be used in class Parsons function initializeAreas
diff --git a/bases/rsptx/interactives/runestone/parsons/js/placeholderBlock.js b/bases/rsptx/interactives/runestone/parsons/js/placeholderBlock.js
index 88caddb8d..c7511f034 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/placeholderBlock.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/placeholderBlock.js
@@ -17,19 +17,18 @@ export default class PlaceholderBlock extends ParsonsBlock {
         this.afterSettledBlockCount = afterSettledBlockCount;
 
         // create a normal parsons block, but use css to control visibility of normal content
-        $(this.view).addClass("placeholder-block");
-        $(this.view).addClass("disabled");
+        this.view.classList.add("placeholder-block", "disabled");
 
         // create a new div displaying how many blocks are missing
         this.placeholderSize = placeholderSize;
         var content = document.createElement("div");
-        $(content).addClass("placeholder-text");
+        content.classList.add("placeholder-text");
         if (placeholderSize > 1) {
             content.innerText = `${placeholderSize} blocks are missing here`;
         } else {
             content.innerText = `${placeholderSize} block is missing here`;
         }
-        $(this.view).append(content);
+        this.view.append(content);
 
         this.isPlaceholder = true;
     }
diff --git a/bases/rsptx/interactives/runestone/parsons/js/settledBlock.js b/bases/rsptx/interactives/runestone/parsons/js/settledBlock.js
index 6e3181a35..c9747d963 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/settledBlock.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/settledBlock.js
@@ -10,13 +10,12 @@ export default class SettledBlock extends ParsonsBlock {
         //update its indent to follow the indentation of the lines inside
 
         // add a css class
-        $(this.view).addClass("settled-block");
-        $(this.view).addClass("disabled");
+        this.view.classList.add("settled-block", "disabled");
 
-        let tooltipSpan = document.createElement("span");
-        tooltipSpan.classList.add("settled-tooltip");
-        $(this.view).append(tooltipSpan);
-        $(this.view).find(".settled-tooltip").text("OK");
+        this.tooltipSpan = document.createElement("span");
+        this.tooltipSpan.classList.add("settled-tooltip");
+        this.view.append(this.tooltipSpan);
+        this.tooltipSpan.textContent = "OK";
     }
 
     setBlocksBefore(num) {
@@ -24,15 +23,9 @@ export default class SettledBlock extends ParsonsBlock {
         var beforePlural = num > 1 ? "s" : "";
         if (this.blocksAfter) {
             var afterPlural = this.blocksAfter > 1 ? "s" : "";
-            $(this.view)
-                .find(".settled-tooltip")
-                .html(
-                    `${num} block${beforePlural} ⬆️️), ${this.blocksAfter} block${afterPlural} ⬇️`,
-                );
+            this.tooltipSpan.innerHTML = `${num} block${beforePlural} ⬆️️), ${this.blocksAfter} block${afterPlural} ⬇️`;
         } else {
-            $(this.view)
-                .find(".settled-tooltip")
-                .html(`${num} block${beforePlural} ⬆️️`);
+            this.tooltipSpan.innerHTML = `${num} block${beforePlural} ⬆️️`;
         }
     }
 
@@ -41,15 +34,9 @@ export default class SettledBlock extends ParsonsBlock {
         var afterPlural = num > 1 ? "s" : "";
         if (this.blocksBefore) {
             var beforePlural = this.blocksBefore > 1 ? "s" : "";
-            $(this.view)
-                .find(".settled-tooltip")
-                .html(
-                    `${this.blocksBefore} block${beforePlural} ⬆️, ${num} block${afterPlural} ⬇️`,
-                );
+            this.tooltipSpan.innerHTML = `${this.blocksBefore} block${beforePlural} ⬆️, ${num} block${afterPlural} ⬇️`;
         } else {
-            $(this.view)
-                .find(".settled-tooltip")
-                .html(`${num} block${afterPlural} ️️⬇️`);
+            this.tooltipSpan.innerHTML = `${num} block${afterPlural} ️️⬇️`;
         }
     }
 }
diff --git a/bases/rsptx/interactives/runestone/parsons/js/timedparsons.js b/bases/rsptx/interactives/runestone/parsons/js/timedparsons.js
index 2cecedebb..8de9290f5 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/timedparsons.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/timedparsons.js
@@ -11,15 +11,17 @@ export default class TimedParsons extends Parsons {
         }
         this.grader.showfeedback = this.showfeedback;
         this.hideFeedback();
-        $(this.checkButton).hide();
-        $(this.helpButton).hide();
-        $(this.resetButton).hide();
+        this.checkButton.style.display = "none";
+        if (this.helpButton) {
+            this.helpButton.style.display = "none";
+        }
+        this.resetButton.style.display = "none";
     }
     checkCorrectTimed() {
         return this.correct ? "T" : "F";
     }
     hideFeedback() {
-        $(this.messageDiv).hide();
+        this.messageDiv.style.display = "none";
     }
 }
 
diff --git a/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js b/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js
new file mode 100644
index 000000000..68b42e4b5
--- /dev/null
+++ b/bases/rsptx/interactives/runestone/parsons/test/parsons.test.js
@@ -0,0 +1,734 @@
+// Characterization tests for the Parsons component. These were first written
+// against the jQuery implementation and now guard the jQuery-free version.
+// Note: deliberately NO jquery-globals import here -- parsons must work
+// without jQuery.
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import Parsons from "../js/parsons.js";
+import "../js/timedparsons.js";
+
+// Build the same DOM a book page provides (see parsonsPreview.tsx and the
+// Sphinx/PreTeXt templates): div.runestone > [data-component=parsons] with a
+// question div and a pre.parsonsblocks holding the block source text.
+function makeFixture({
+    id = "test_parsons_1",
+    blocks,
+    attrs = "",
+    question = "

Arrange the code.

", +} = {}) { + document.body.innerHTML = ` +
+
+
${question}
+
+${blocks}
+          
+
+
`; + return document.getElementById(id); +} + +// data-order pins block order so tests are deterministic (no shuffling). +const FLAT_BLOCKS = "line_a = 1\n---\nline_b = 2\n---\nline_c = 3"; +const FLAT_ATTRS = 'data-language="python" data-order="0,1,2"'; + +const INDENTED_BLOCKS = + 'def main():\n---\n print("Hello")\n---\n print("Bye") #distractor: not needed'; +const INDENTED_ATTRS = 'data-language="python" data-order="0,1,2"'; + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function makeParsons(fixtureOpts = {}, extraOpts = {}) { + const orig = makeFixture(fixtureOpts); + const parsons = new Parsons({ + orig: orig, + useRunestoneServices: false, + ...extraOpts, + }); + await parsons.component_ready_promise; + await tick(); + return parsons; +} + +// Place source blocks (by their current source-area position) into the answer +// area the way a drag would: answerBlocks()/sourceBlocks() read the DOM. +function answer(parsons, sourceIndexes) { + const blocks = parsons.sourceBlocks(); + for (const i of sourceIndexes) { + parsons.answerArea.appendChild(blocks[i].view); + } +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.stubGlobal("alert", vi.fn()); +}); + +describe("construction", () => { + it("replaces the pre with a rendered view holding source/answer areas", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect(p.divid).toBe("test_parsons_1"); + expect(p.outerDiv.classList.contains("parsons")).toBe(true); + // The original pre.parsonsblocks is gone from the document. + expect(document.querySelector("pre.parsonsblocks")).toBeNull(); + // Areas exist in the document with derived ids. + expect(document.getElementById(`${p.counterId}-source`)).toBe( + p.sourceArea, + ); + expect(document.getElementById(`${p.counterId}-answer`)).toBe( + p.answerArea, + ); + expect(p.sourceArea.getAttribute("aria-describedby")).toBe( + `${p.counterId}-sourceTip`, + ); + }); + + it("creates Check and Reset buttons; Help only when adaptive", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect(p.checkButton.textContent).toBe("Check"); + expect(p.checkButton.type).toBe("button"); + expect(p.resetButton.textContent).toBe("Reset"); + expect(p.helpButton).toBeUndefined(); + + const adaptive = await makeParsons({ + id: "test_parsons_adaptive", + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-adaptive="true"', + }); + expect(adaptive.helpButton.textContent).toBe("Help me"); + }); + + it("renders drag labels and a hidden keyboard tip", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect(p.sourceLabel.textContent).toBe("Drag from here"); + expect(p.answerLabel.textContent).toBe("Drop blocks here"); + expect(p.keyboardTip.textContent).toContain("Arrow keys to navigate"); + expect(p.keyboardTip.style.display).toBe("none"); + }); + + it("moves the question above the problem; drops a blank question", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect(p.outerDiv.firstElementChild).toBe(p.question); + expect(p.question.textContent).toContain("Arrange the code."); + + const blank = await makeParsons({ + id: "test_parsons_blank_q", + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS, + question: " ", + }); + expect(document.contains(blank.question)).toBe(false); + }); + + it("starts with all blocks in the source area and none answered", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect(p.blocks).toHaveLength(3); + expect(p.sourceBlocks()).toHaveLength(3); + expect(p.answerBlocks()).toHaveLength(0); + for (const block of p.sourceBlocks()) { + expect(block.view.classList.contains("block")).toBe(true); + } + }); + + it("marks the component ready and adds a caption", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect( + p.containerDiv.classList.contains("runestone-component-ready"), + ).toBe(true); + const cap = p.containerDiv.querySelector("p.runestone_caption"); + expect(cap.textContent).toContain("Parsons"); + }); + + it("makes exactly one block the keyboard entry point", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + const tabZero = p.blocks.filter( + (b) => b.view.getAttribute("tabindex") === "0", + ); + expect(tabZero).toHaveLength(1); + for (const block of p.blocks) { + expect(block.enabled()).toBe(true); + } + }); +}); + +describe("option parsing", () => { + it("defaults to python and its prettify class", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: 'data-order="0,1,2"', + }); + expect(p.options.language).toBe("python"); + expect(p.options.prettifyLanguage).toBe("prettyprint lang-py"); + expect(p.lines[0].view.tagName).toBe("CODE"); + expect(p.lines[0].view.classList.contains("lang-py")).toBe(true); + }); + + it("maps other languages; unknown language gets no prettify class", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: 'data-language="java" data-order="0,1,2"', + }); + expect(p.options.prettifyLanguage).toBe("prettyprint lang-java"); + + const q = await makeParsons({ + id: "test_parsons_weird_lang", + blocks: FLAT_BLOCKS, + attrs: 'data-language="brainfudge" data-order="0,1,2"', + }); + expect(q.options.prettifyLanguage).toBe(""); + }); + + it("parses order, maxdist, noindent and numbered attributes", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: 'data-language="python" data-order="2,0,1" data-maxdist="1" data-noindent="true" data-numbered="left"', + }); + expect(p.options.order).toEqual([2, 0, 1]); + expect(p.options.maxdist).toBe(1); + expect(p.noindent).toBe(true); + expect(p.options.numbered).toBe("left"); + // Source blocks follow data-order. + const texts = p.sourceBlocks().map((b) => b.lines[0].text); + expect(texts).toEqual(["line_c = 3", "line_a = 1", "line_b = 2"]); + }); + + it("numbered problems label each block", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-numbered="left"', + }); + const labels = p + .sourceBlocks() + .map((b) => b.view.querySelector(".block-label").textContent); + expect(labels).toEqual(["1 ", "2 ", "3 "]); + }); + + it("parses data-explanations JSON and tolerates bad JSON", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-explanations=\'{"0": "first line"}\'', + }); + expect(p.blockExplanations).toEqual({ 0: "first line" }); + + const q = await makeParsons({ + id: "test_parsons_bad_json", + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + " data-explanations='{not json'", + }); + expect(q.blockExplanations).toEqual({}); + }); +}); + +describe("line initialization", () => { + it("normalizes indents to levels and keeps line text", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + expect(p.lines.map((l) => l.text)).toEqual([ + "def main():", + 'print("Hello")', + 'print("Bye")', + ]); + expect(p.lines.map((l) => l.indent)).toEqual([0, 1, 1]); + }); + + it("flags distractors and stores their help text", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + expect(p.lines[2].distractor).toBe(true); + expect(p.lines[2].paired).toBe(false); + expect(p.lines[2].distractHelptext).toBe("not needed"); + // Distractors are not part of the solution. + expect(p.solution).toEqual([p.lines[0], p.lines[1]]); + }); + + it("marks paired distractors", async () => { + const p = await makeParsons({ + blocks: "a = 1\n---\na = 2 #paired: close but no", + attrs: FLAT_ATTRS, + }); + expect(p.lines[1].distractor).toBe(true); + expect(p.lines[1].paired).toBe(true); + expect(p.lines[1].distractHelptext).toBe("close but no"); + }); + + it("groups multi-line segments into a single block", async () => { + const p = await makeParsons({ + blocks: "a = 1\nb = 2\n---\nc = 3", + attrs: FLAT_ATTRS, + }); + expect(p.lines[0].groupWithNext).toBe(true); + expect(p.lines[1].groupWithNext).toBe(false); + expect(p.blocks).toHaveLength(2); + expect(p.blocks[0].lines).toHaveLength(2); + }); +}); + +describe("hashing", () => { + it("hashes empty areas as '-' and blocks as lineIndexes_indent", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + expect(p.answerHash()).toBe("-"); + expect(p.sourceHash()).toBe("0_0-1_0-2_0"); + answer(p, [0, 1]); + p.answerBlocks()[1].indent = 1; + expect(p.answerHash()).toBe("0_0-1_1"); + expect(p.sourceHash()).toBe("2_0"); + }); + + it("round-trips blocks through blocksFromHash", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS, + }); + const blocks = p.blocksFromHash("0_1_0-2_0"); + expect(blocks).toHaveLength(2); + expect(blocks[0].lines.map((l) => l.index)).toEqual([0, 1]); + expect(blocks[0].hash()).toBe("0_1_0"); + expect(blocks[1].hash()).toBe("2_0"); + }); + + it("decodes adaptive state hashes", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + expect(p.optionsFromHash("d2-i-c3-s")).toEqual({ + disabled: [2], + noindent: true, + checkCount: 3, + hasSolved: true, + }); + expect(p.optionsFromHash("-")).toEqual({}); + }); + + it("encodes adaptive state for adaptive problems", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-adaptive="true"', + }); + expect(p.adaptiveHash()).toBe("c0"); + expect(p.adaptiveHash()).not.toContain("s"); + }); +}); + +describe("grading", () => { + it("reports incorrectTooShort when blocks are missing", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [0]); + p.checkCurrentAnswer(); + expect(p.grade).toBe("incorrectTooShort"); + expect(p.hasSolved).toBe(false); + expect(p.checkCount).toBe(1); + }); + + it("reports incorrectMoveBlocks for wrong order", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [1, 0, 2]); + p.checkCurrentAnswer(); + expect(p.grade).toBe("incorrectMoveBlocks"); + expect(p.percent).toBeCloseTo(0.2 + 0.4 / 3 + 0.4 / 3, 5); + }); + + it("reports correct, locks the check button and records the solve", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [0, 1, 2]); + p.checkCurrentAnswer(); + expect(p.grade).toBe("correct"); + expect(p.hasSolved).toBe(true); + expect(p.correct).toBe(true); + expect(p.percent).toBe(1); + expect(p.checkButton.disabled).toBe(true); + expect(localStorage.getItem(p.storageId + "Solved")).toBe("true"); + }); + + it("reports incorrectIndent when order is right but indent wrong", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + answer(p, [0, 1]); // print("Hello") left at indent 0 + p.checkCurrentAnswer(); + expect(p.grade).toBe("incorrectIndent"); + + p.answerBlocks()[1].indent = 1; + p.checkCurrentAnswer(); + expect(p.grade).toBe("correct"); + }); + + it("counts distinct attempts, not repeats (adaptive only)", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-adaptive="true"', + }); + answer(p, [1, 0, 2]); + p.checkCurrentAnswer(); + p.checkCurrentAnswer(); + expect(p.numDistinct).toBe(1); + expect(p.checkCount).toBe(2); + }); +}); + +describe("feedback rendering", () => { + it("shows the success message and marks the answer area", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [0, 1, 2]); + p.checkCurrentAnswer(); + p.renderFeedback(); + expect(p.answerArea.classList.contains("correct")).toBe(true); + expect(p.messageDiv.style.visibility).toBe("visible"); + expect(p.messageDiv.getAttribute("class")).toBe("alert alert-info"); + await tick(30); + expect(p.messageDiv.textContent).toContain("Perfect!"); + }); + + it("highlights out-of-place blocks on wrong order", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [1, 0, 2]); + p.checkCurrentAnswer(); + p.renderFeedback(); + expect(p.answerArea.classList.contains("incorrect")).toBe(true); + expect(p.messageDiv.getAttribute("class")).toBe("alert alert-danger"); + const flagged = p + .answerBlocks() + .filter((b) => b.view.classList.contains("incorrectPosition")); + expect(flagged.length).toBeGreaterThan(0); + await tick(30); + expect(p.messageDiv.textContent).toContain( + "wrong or are in the wrong order", + ); + }); + + it("marks misindented blocks", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + answer(p, [0, 1]); + p.checkCurrentAnswer(); + p.renderFeedback(); + const hello = p.answerBlocks()[1]; + expect(hello.view.classList.contains("indentRight")).toBe(true); + await tick(30); + expect(p.messageDiv.textContent).toContain("not indented correctly"); + }); + + it("clearFeedback removes classes and hides the message", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [1, 0, 2]); + p.checkCurrentAnswer(); + p.renderFeedback(); + p.clearFeedback(); + expect(p.answerArea.classList.contains("incorrect")).toBe(false); + expect(p.messageDiv.style.visibility).toBe("hidden"); + for (const b of p.answerBlocks()) { + expect(b.view.classList.contains("incorrectPosition")).toBe(false); + } + }); + + it("adds explanation tooltips to answer blocks after solving", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-explanations=\'{"0": "first!"}\'', + }); + answer(p, [0, 1, 2]); + p.checkCurrentAnswer(); + p.renderFeedback(); + const first = p.answerBlocks()[0]; + expect(first.view.getAttribute("title")).toBe("first!"); + expect(first.view.classList.contains("has-explanation")).toBe(true); + p.clearFeedback(); + expect(first.view.getAttribute("title")).toBeNull(); + expect(first.view.classList.contains("has-explanation")).toBe(false); + }); +}); + +describe("reset", () => { + it("returns all blocks to the source area and clears state", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [0, 1, 2]); + p.checkCurrentAnswer(); + expect(p.checkButton.disabled).toBe(true); + p.resetButton.click(); + await tick(); + expect(p.answerBlocks()).toHaveLength(0); + expect(p.sourceBlocks()).toHaveLength(3); + expect(p.checkCount).toBe(0); + expect(p.hasSolved).toBe(false); + expect(p.checkButton.disabled).toBe(false); + expect(p.messageDiv.style.visibility).toBe("hidden"); + }); +}); + +describe("persistence", () => { + it("stores source/answer hashes in localStorage", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + answer(p, [0, 1]); + p.answerBlocks()[1].indent = 1; + p.setLocalStorage(); + const stored = JSON.parse(localStorage.getItem(p.storageId)); + expect(stored.source).toBe("2_0"); + expect(stored.answer).toBe("0_0-1_1"); + expect(stored.timestamp).toBeTruthy(); + }); + + it("restores a saved answer on construction and grades it", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + answer(p, [0, 1]); + p.answerBlocks()[1].indent = 1; + p.setLocalStorage(); + + const restored = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + expect(restored.answerBlocks()).toHaveLength(2); + expect(restored.answerHash()).toBe("0_0-1_1"); + expect(restored.sourceHash()).toBe("2_0"); + expect(restored.correct).toBe(true); + }); +}); + +describe("keyboard movement model", () => { + it("moveRight moves a source block into the answer area", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + const block = p.sourceBlocks()[0]; + block.moveRight(); + expect(block.inSourceArea()).toBe(false); + expect(p.answerBlocks()).toContain(block); + }); + + it("moveRight in the answer area increases indent up to the limit", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + expect(p.indent).toBe(1); + const block = p.sourceBlocks()[1]; + block.moveRight(); // into answer, indent reset to 0 + expect(block.indent).toBe(0); + block.moveRight(); + expect(block.indent).toBe(1); + block.moveRight(); // at the limit, stays + expect(block.indent).toBe(1); + }); + + it("moveLeft reduces indent then returns the block to the source", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS, + }); + const block = p.sourceBlocks()[1]; + block.moveRight(); + block.moveRight(); + block.moveLeft(); + expect(block.indent).toBe(0); + expect(block.inSourceArea()).toBe(false); + block.moveLeft(); + expect(block.inSourceArea()).toBe(true); + }); + + it("moveUp and moveDown reorder blocks in the answer area", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + answer(p, [0, 1, 2]); + const [a, b, c] = p.answerBlocks(); + b.moveDown(); + expect(p.answerBlocks()).toEqual([a, c, b]); + b.moveUp(); + expect(p.answerBlocks()).toEqual([a, b, c]); + a.moveUp(); // already first: no-op + expect(p.answerBlocks()).toEqual([a, b, c]); + }); + + it("focusing a block enters keyboard mode; blur leaves it", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + const block = p.blocks.find( + (b) => b.view.getAttribute("tabindex") === "0", + ); + block.view.focus(); + expect(p.textFocus).toBe(block); + expect(block.view.classList.contains("down")).toBe(true); + expect(p.keyboardTip.style.display).not.toBe("none"); + expect(p.sourceLabel.style.display).toBe("none"); + block.view.blur(); + expect(p.textFocus).toBeUndefined(); + expect(p.keyboardTip.style.display).toBe("none"); + expect(p.sourceLabel.style.display).not.toBe("none"); + }); + + it("space toggles a focused block between select and move", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + const block = p.blocks.find( + (b) => b.view.getAttribute("tabindex") === "0", + ); + block.view.focus(); + block.keyDown({ keyCode: 32, preventDefault() {} }); + expect(p.textMove).toBe(true); + expect(block.view.classList.contains("up")).toBe(true); + block.keyDown({ keyCode: 32, preventDefault() {} }); + expect(p.textMove).toBe(false); + expect(block.view.classList.contains("down")).toBe(true); + }); + + it("disable() removes a block from keyboard rotation", async () => { + const p = await makeParsons({ blocks: FLAT_BLOCKS, attrs: FLAT_ATTRS }); + const block = p.sourceBlocks()[0]; + block.disable(); + expect(block.enabled()).toBe(false); + // Another block takes over as the keyboard entry point. + const tabZero = p.blocks.filter( + (b) => b.view.getAttribute("tabindex") === "0", + ); + expect(tabZero).toHaveLength(1); + expect(tabZero[0]).not.toBe(block); + }); +}); + +describe("adaptive problems", () => { + it("initializes adaptive bookkeeping in localStorage", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-adaptive="true"', + }); + expect(p.canHelp).toBe(true); + expect(localStorage.getItem(p.adaptiveId + "Problem")).toBe(p.divid); + expect(localStorage.getItem(p.adaptiveId + "recentAttempts")).toBe("3"); + expect(localStorage.getItem(p.adaptiveId + "Solved")).toBe("false"); + }); + + it("helpMe demands three distinct attempts first", async () => { + const p = await makeParsons({ + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS + ' data-adaptive="true"', + }); + p.helpMe(); + expect(alert).toHaveBeenCalledWith( + expect.stringContaining("three distinct full attempts"), + ); + expect(p.gotHelp).toBe(false); + }); + + it("removeDistractor disables the block and reveals its help text", async () => { + const p = await makeParsons({ + blocks: INDENTED_BLOCKS, + attrs: INDENTED_ATTRS + ' data-adaptive="true"', + }); + const distractor = p.sourceBlocks().find((b) => b.isDistractor()); + p.removeDistractor(distractor); + expect(distractor.enabled()).toBe(false); + expect(distractor.view.getAttribute("title")).toBe("not needed"); + await tick(30); + expect(p.messageDiv.textContent).toContain( + "Disabled an unneeded code block", + ); + }); +}); + +describe("paired distractor presentation", () => { + it("builds paired bins and an 'or' bracket div", async () => { + const p = await makeParsons({ + blocks: "a = 1\n---\na = 2 #paired\n---\nb = 3", + attrs: FLAT_ATTRS, + }); + expect(p.pairedBins).toEqual([[0, 1]]); + expect(p.pairedDivs).toHaveLength(1); + expect(p.sourceArea.querySelector(".paired")).toBe(p.pairedDivs[0]); + // Both paired lines land in the same bin, so their blocks report it. + const blocks = p.sourceBlocks(); + const bins = blocks.map((b) => b.pairedBin()); + expect(bins.filter((b) => b === 0)).toHaveLength(2); + expect(bins.filter((b) => b === -1)).toHaveLength(1); + }); +}); + +describe("scaffolding (CodeTailor) problems", () => { + it("pre-places settled blocks and placeholders in the answer area", async () => { + const p = await makeParsons({ + blocks: 'def main(): #settled\n---\n print("Hello")\n---\n print("Bye")', + attrs: 'data-language="python" data-order="0,1" data-scaffolding="true"', + }); + const answerBlocks = p.answerBlocks(); + expect(answerBlocks.some((b) => b.isSettled)).toBe(true); + expect(answerBlocks.some((b) => b.isPlaceholder)).toBe(true); + const placeholder = answerBlocks.find((b) => b.isPlaceholder); + // (the count message is set via innerText, which jsdom doesn't render, + // so assert the message container rather than its text) + expect(placeholder.placeholderSize).toBe(2); + expect( + placeholder.view.querySelector(".placeholder-text"), + ).toBeTruthy(); + expect(placeholder.view.classList.contains("placeholder-block")).toBe( + true, + ); + const settled = answerBlocks.find((b) => b.isSettled); + expect(settled.view.classList.contains("settled-block")).toBe(true); + expect(settled.view.classList.contains("disabled")).toBe(true); + expect( + settled.view.querySelector(".settled-tooltip").textContent, + ).toContain("2 block"); + // Settled/placeholder lines stay out of the draggable source blocks. + expect(p.sourceBlocks()).toHaveLength(2); + }); +}); + +describe("dag grading", () => { + it("accepts any topological order of the dependency graph", async () => { + const p = await makeParsons({ + blocks: "a = 1 #tag:1;depends:;\n---\nb = 2 #tag:2;depends:;\n---\nc = a + b #tag:3;depends:1,2;", + attrs: 'data-language="python" data-order="0,1,2" data-grader="dag"', + }); + expect(p.lines[0].tag).toBe("1"); + expect(p.lines[2].depends).toEqual(["1", "2"]); + answer(p, [1, 0, 2]); // b before a: still a valid topological order + p.checkCurrentAnswer(); + expect(p.grade).toBe("correct"); + }); +}); + +describe("timed parsons", () => { + it("component factory returns a feedback-suppressed timed variant", async () => { + const orig = makeFixture({ + id: "test_parsons_timed", + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS, + }); + const p = window.component_factory["parsons"]({ + orig: orig, + timed: true, + useRunestoneServices: false, + }); + await p.component_ready_promise; + await tick(); + expect(p.showfeedback).toBe(false); + expect(p.checkButton.style.display).toBe("none"); + expect(p.resetButton.style.display).toBe("none"); + expect(p.messageDiv.style.display).toBe("none"); + answer(p, [0, 1, 2]); + p.checkCurrentAnswer(); + expect(p.checkCorrectTimed()).toBe("T"); + }); +}); + +describe("page integration", () => { + it("renders every [data-component=parsons] on login-complete", async () => { + makeFixture({ + id: "test_parsons_page", + blocks: FLAT_BLOCKS, + attrs: FLAT_ATTRS, + }); + document.dispatchEvent(new Event("runestone:login-complete")); + const p = window.componentMap["test_parsons_page"]; + expect(p).toBeInstanceOf(Parsons); + await p.component_ready_promise; + }); +}); From 18eca4410b28410e0948fd4006ee5c45e86f9a3f Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 09:28:10 -0500 Subject: [PATCH 03/15] feat: remove jQuery from dragndrop; add shortanswer + dragndrop tests shortanswer was already jQuery-free; its tests lock that in. timeddnd.js loses its four jQuery call sites. Also fixes checkCorrectTimed, which referenced this.dragPairArray -- renamed to premiseArray in the 12/30/24 rewrite -- and threw whenever a timed exam graded an untouched dragndrop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/dragndrop/js/timeddnd.js | 15 +- .../dragndrop/test/dragndrop.test.js | 470 ++++++++++++++++++ .../shortanswer/test/shortanswer.test.js | 327 ++++++++++++ 3 files changed, 804 insertions(+), 8 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js create mode 100644 bases/rsptx/interactives/runestone/shortanswer/test/shortanswer.test.js diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/timeddnd.js b/bases/rsptx/interactives/runestone/dragndrop/js/timeddnd.js index 6dac19117..2fb887799 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/timeddnd.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/timeddnd.js @@ -10,25 +10,24 @@ export default class TimedDragNDrop extends DragNDrop { this.hideButtons(); } hideButtons() { - $(this.submitButton).hide(); + this.submitButton.style.display = "none"; } renderTimedIcon(component) { // renders the clock icon on timed components. The component parameter // is the element that the icon should be appended to. var timeIconDiv = document.createElement("div"); var timeIcon = document.createElement("img"); - $(timeIcon).attr({ - src: "../_static/clock.png", - style: "width:15px;height:15px", - }); + timeIcon.src = "../_static/clock.png"; + timeIcon.style.width = "15px"; + timeIcon.style.height = "15px"; timeIconDiv.className = "timeTip"; timeIconDiv.title = ""; timeIconDiv.appendChild(timeIcon); - $(component).prepend(timeIconDiv); + component.insertBefore(timeIconDiv, component.firstChild); } checkCorrectTimed() { // Returns if the question was correct. Used for timed assessment grading. - if (this.unansweredNum === this.dragPairArray.length) { + if (this.unansweredNum === this.premiseArray.length) { this.correct = null; } switch (this.correct) { @@ -41,7 +40,7 @@ export default class TimedDragNDrop extends DragNDrop { } } hideFeedback() { - $(this.feedBackDiv).hide(); + this.feedBackDiv.style.display = "none"; } } diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js new file mode 100644 index 000000000..ef5fa2f32 --- /dev/null +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -0,0 +1,470 @@ +// Characterization tests for the DragNDrop component. These were first +// written against the jQuery implementation (only timeddnd.js used jQuery) +// and now guard the jQuery-free version. +// Note: deliberately NO jquery-globals import here -- dragndrop must work +// without jQuery. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import DragNDrop from "../js/dragndrop.js"; +import TimedDragNDrop from "../js/timeddnd.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +// A question authored as a JSON + + `; + return document.getElementById(id); +} + +// The legacy markup: draggable/dropzone pairs linked by data-category. +function makeLegacyFixture({ id = "test_dnd_legacy" } = {}) { + document.body.innerHTML = ` +
+
    + Match the terms. + Alpha + First letter + Beta + Second letter + A hint. +
+
`; + return document.getElementById(id); +} + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function makeDnd(fixtureOpts = {}, extraOpts = {}, Cls = DragNDrop) { + const orig = makeJsonFixture(fixtureOpts); + const dnd = new Cls({ + orig: orig, + useRunestoneServices: false, + ...extraOpts, + }); + await dnd.component_ready_promise; + await tick(); + return dnd; +} + +// Simulate dropping a premise into a response zone the way the drop handler +// would: the grading code only reads the DOM. +function place(dnd, premiseId, responseId) { + const premise = dnd.premiseArray.find((p) => p.id === premiseId); + const response = dnd.responseArray.find((r) => r.id === responseId); + response.appendChild(premise); +} + +// Feedback text is written inside a setTimeout(…, 10). +const feedbackSettles = () => tick(20); + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); + vi.stubGlobal("alert", vi.fn()); +}); + +describe("construction from a JSON script block", () => { + it("replaces the original element with statement, dragzone and dropzone", async () => { + const dnd = await makeDnd(); + const container = document.getElementById("test_dnd_1"); + expect(container).toBe(dnd.containerDiv); + expect( + container.querySelector(".cardsort-statement").textContent, + ).toBe("Match each animal to its sound."); + const dragIds = [...dnd.draggableDiv.querySelectorAll(".premise")].map( + (el) => el.id, + ); + expect(dragIds).toEqual(["p1", "p2", "p3"]); + const dropIds = [...dnd.dropZoneDiv.querySelectorAll(".response")].map( + (el) => el.id, + ); + expect(dropIds).toEqual(["r1", "r2"]); + }); + + it("gives paired premises the response id as category and distractors their own", async () => { + const dnd = await makeDnd(); + const byId = Object.fromEntries( + dnd.premiseArray.map((p) => [p.id, p.dataset.category]), + ); + expect(byId.p1).toBe("r1"); + expect(byId.p2).toBe("r2"); + expect(byId.p3).toBe("distractor-p3"); + }); + + it("creates Check me and Reset buttons", async () => { + const dnd = await makeDnd(); + expect(dnd.submitButton.textContent).toBe("Check me"); + expect(dnd.resetButton.textContent).toBe("Reset"); + }); + + it("makes premises draggable and keyboard-operable", async () => { + const dnd = await makeDnd(); + for (const premise of dnd.premiseArray) { + expect(premise.getAttribute("draggable")).toBe("true"); + expect(premise.getAttribute("role")).toBe("button"); + expect(premise.tabIndex).toBe(0); + } + }); + + it("stores the author feedback for use in incorrect messages", async () => { + const dnd = await makeDnd(); + expect(dnd.feedback).toBe("Think about pets."); + }); +}); + +describe("construction from legacy data-subcomponent markup", () => { + it("builds premises and responses from draggable/dropzone pairs", async () => { + const orig = makeLegacyFixture(); + const dnd = new DragNDrop({ orig, useRunestoneServices: false }); + await dnd.component_ready_promise; + await tick(); + expect(dnd.question).toBe("Match the terms."); + expect(dnd.feedback).toBe("A hint."); + expect(dnd.premiseArray.map((p) => p.id)).toEqual([ + "drag_a", + "drag_b", + ]); + // dropzone ids are derived from the for attribute + expect(dnd.responseArray.map((r) => r.id)).toEqual([ + "drop_a", + "drop_b", + ]); + expect(dnd.premiseArray[0].dataset.category).toBe("cat_a"); + expect(dnd.responseArray[0].dataset.category).toBe("cat_a"); + }); +}); + +describe("grading", () => { + it("is correct when all pairs are placed right and distractors stay", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r1"); + place(dnd, "p2", "r2"); + dnd.checkCurrentAnswer(); + expect(dnd.correct).toBe(true); + expect(dnd.correctNum).toBe(3); // p1, p2 placed + p3 left alone + expect(dnd.incorrectNum).toBe(0); + expect(dnd.enoughPlaced).toBe(true); + }); + + it("is incorrect when a premise is in the wrong zone", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r2"); + place(dnd, "p2", "r1"); + dnd.checkCurrentAnswer(); + expect(dnd.correct).toBe(false); + expect(dnd.incorrectNum).toBe(2); + }); + + it("is incorrect when a distractor is placed in a zone", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r1"); + place(dnd, "p2", "r2"); + place(dnd, "p3", "r1"); + dnd.checkCurrentAnswer(); + expect(dnd.correct).toBe(false); + expect(dnd.incorrectNum).toBe(1); + }); + + it("requires all non-distractor premises to be placed before grading", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r1"); + dnd.checkCurrentAnswer(); + expect(dnd.enoughPlaced).toBe(false); + expect(dnd.requiredPlacements).toBe(2); + expect(dnd.placedNum).toBe(1); + }); + + it("saves correctness into localStorage on check", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r1"); + place(dnd, "p2", "r2"); + dnd.checkCurrentAnswer(); + const stored = JSON.parse(localStorage.getItem(dnd.localStorageKey())); + expect(stored.correct).toBe("T"); + expect(stored.answer).toEqual({ r1: ["p1"], r2: ["p2"] }); + }); +}); + +describe("feedback rendering", () => { + it("asks for more placements before grading", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r1"); + dnd.checkCurrentAnswer(); + dnd.renderFeedback(); + await feedbackSettles(); + expect(dnd.feedBackDiv.textContent).toContain( + "Please place all of the cards", + ); + expect(dnd.feedBackDiv.textContent).toContain("You have 1 left"); + expect(dnd.feedBackDiv.className).toContain("alert-warning"); + }); + + it("celebrates a correct answer", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r1"); + place(dnd, "p2", "r2"); + dnd.submitButton.click(); + await feedbackSettles(); + expect(dnd.feedBackDiv.innerHTML).toBe("You are correct!"); + expect(dnd.feedBackDiv.className).toContain("alert-info"); + }); + + it("reports counts and author feedback when incorrect", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r2"); + place(dnd, "p2", "r1"); + dnd.submitButton.click(); + await feedbackSettles(); + expect(dnd.feedBackDiv.textContent).toContain( + "you placed 1 correctly and 2 incorrectly", + ); + expect(dnd.feedBackDiv.textContent).toContain("Think about pets."); + expect(dnd.feedBackDiv.className).toContain("alert-danger"); + }); + + it("only colors misplaced blocks red after three gradeable tries", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r2"); + place(dnd, "p2", "r1"); + const p1 = dnd.premiseArray.find((p) => p.id === "p1"); + dnd.submitButton.click(); + await feedbackSettles(); + expect(p1.classList.contains("drop-incorrect")).toBe(false); + dnd.submitButton.click(); + await feedbackSettles(); + expect(p1.classList.contains("drop-incorrect")).toBe(false); + dnd.submitButton.click(); + await feedbackSettles(); + expect(p1.classList.contains("drop-incorrect")).toBe(true); + expect(p1.getAttribute("aria-invalid")).toBe("true"); + expect(p1.getAttribute("aria-errormessage")).toBe("p1_error"); + }); +}); + +describe("reset", () => { + it("returns premises to the dragzone and starts the try count over", async () => { + const dnd = await makeDnd(); + place(dnd, "p1", "r2"); + dnd.submitButton.click(); + await feedbackSettles(); + dnd.resetButton.click(); + expect(dnd.draggableDiv.querySelectorAll(".premise").length).toBe(3); + expect(dnd.dropZoneDiv.querySelectorAll(".premise").length).toBe(0); + // reset clears the state, then saving regenerates it (empty) from the DOM + expect(dnd.answerState).toEqual({ r1: [], r2: [] }); + expect(dnd.tries).toBe(0); + expect(dnd.feedBackDiv.style.display).toBe("none"); + }); +}); + +describe("persistence", () => { + it("restores placements from localStorage on a fresh render", async () => { + const first = await makeDnd(); + place(first, "p1", "r1"); + first.checkCurrentAnswer(); + const second = await makeDnd(); + const r1 = second.responseArray.find((r) => r.id === "r1"); + expect( + [...r1.querySelectorAll(".premise")].map((p) => p.id), + ).toEqual(["p1"]); + expect( + [...second.draggableDiv.querySelectorAll(".premise")].map( + (p) => p.id, + ), + ).toEqual(["p2", "p3"]); + }); + + it("restores placements from server data via restoreAnswers", async () => { + // restoreAnswers is only reached on the logged-in server path, where + // the original element has not yet been replaced. + eBookConfig.isLoggedIn = true; + const orig = makeJsonFixture(); + const dnd = new DragNDrop({ + orig, + useRunestoneServices: true, + assessmentTaken: false, + }); + dnd.restoreAnswers({ + answer: JSON.stringify({ r1: ["p1"], r2: ["p2"] }), + min_height: 100, + drag_width: 40, + drop_width: 56, + correct: true, + }); + const r2 = dnd.responseArray.find((r) => r.id === "r2"); + expect( + [...r2.querySelectorAll(".premise")].map((p) => p.id), + ).toEqual(["p2"]); + expect(dnd.correct).toBe(true); + }); + + it("drops malformed stored data", async () => { + const key = `${eBookConfig.email}:${eBookConfig.course}:test_dnd_1-given`; + localStorage.setItem(key, "not json{"); + const dnd = await makeDnd(); + expect(dnd.localStorageKey()).toBe(key); + expect(localStorage.getItem(key)).toBe(null); + }); +}); + +describe("logging", () => { + it("logs the answer state on submit", async () => { + const dnd = await makeDnd(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + place(dnd, "p1", "r1"); + place(dnd, "p2", "r2"); + dnd.submitButton.click(); + await tick(); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "dragNdrop", + div_id: "test_dnd_1", + correct: true, + answer: JSON.stringify({ r1: ["p1"], r2: ["p2"] }), + }), + ); + }); +}); + +describe("guards", () => { + it("rejects premises belonging to a different dragndrop instance", async () => { + const dnd = await makeDnd(); + const stranger = document.createElement("span"); + stranger.dataset.parent_id = "some_other_dnd"; + expect(dnd.strangerDanger(stranger)).toBe(true); + expect(dnd.strangerDanger(dnd.premiseArray[0])).toBe(false); + }); + + it("disableInteraction hides reset and stops dragging", async () => { + const dnd = await makeDnd(); + dnd.disableInteraction(); + expect(dnd.resetButton.style.display).toBe("none"); + for (const premise of dnd.premiseArray) { + expect(premise.draggable).toBe(false); + } + }); +}); + +describe("TimedDragNDrop", () => { + // Timed exams require login; the logged-out path would replace the + // original element inside the constructor and then the timed + // constructor's own finishSettingUp would find it already gone. + async function makeTimedDnd(fixtureOpts = {}) { + eBookConfig.isLoggedIn = true; + return makeDnd( + fixtureOpts, + { timed: true, useRunestoneServices: true, assessmentTaken: false }, + TimedDragNDrop, + ); + } + + beforeEach(() => { + vi.spyOn( + RunestoneBase.prototype, + "logBookEvent", + ).mockResolvedValue(undefined); + }); + + afterEach(() => { + eBookConfig.isLoggedIn = false; + }); + + it("hides the submit button and shows the clock icon", async () => { + const dnd = await makeTimedDnd(); + expect(dnd.submitButton.style.display).toBe("none"); + const icon = dnd.containerDiv.querySelector(".timeTip img"); + expect(icon.getAttribute("src")).toContain("clock.png"); + expect(dnd.containerDiv.firstChild.className).toBe("timeTip"); + }); + + it("grades T/F for timed assessments", async () => { + const dnd = await makeTimedDnd(); + place(dnd, "p1", "r1"); + place(dnd, "p2", "r2"); + dnd.checkCurrentAnswer(); + expect(dnd.checkCorrectTimed()).toBe("T"); + dnd.resetButton.click(); + place(dnd, "p1", "r2"); + place(dnd, "p2", "r1"); + dnd.checkCurrentAnswer(); + expect(dnd.checkCorrectTimed()).toBe("F"); + }); + + it("grades an untouched distractor-free question as unanswered", async () => { + // The unanswered check compares against every premise, so it can only + // trigger when the question has no distractors. + const dnd = await makeTimedDnd({ + id: "test_dnd_nodistractor", + question: { + statement: "Match.", + left: [ + { id: "q1", label: "One" }, + { id: "q2", label: "Two" }, + ], + right: [ + { id: "z1", label: "1" }, + { id: "z2", label: "2" }, + ], + correctAnswers: [ + ["q1", "z1"], + ["q2", "z2"], + ], + }, + }); + dnd.checkCurrentAnswer(); + expect(dnd.checkCorrectTimed()).toBe(null); + }); + + it("can hide its feedback between questions", async () => { + const dnd = await makeTimedDnd(); + dnd.feedBackDiv.style.display = "block"; + dnd.hideFeedback(); + expect(dnd.feedBackDiv.style.display).toBe("none"); + }); + + it("the factory picks the timed variant from opts.timed", async () => { + eBookConfig.isLoggedIn = false; + const plain = window.component_factory.dragndrop({ + orig: makeJsonFixture({ id: "dnd_plain" }), + useRunestoneServices: false, + }); + expect(plain).toBeInstanceOf(DragNDrop); + expect(plain).not.toBeInstanceOf(TimedDragNDrop); + eBookConfig.isLoggedIn = true; + const timed = window.component_factory.dragndrop({ + orig: makeJsonFixture({ id: "dnd_timed" }), + useRunestoneServices: true, + assessmentTaken: false, + timed: true, + }); + expect(timed).toBeInstanceOf(TimedDragNDrop); + }); +}); diff --git a/bases/rsptx/interactives/runestone/shortanswer/test/shortanswer.test.js b/bases/rsptx/interactives/runestone/shortanswer/test/shortanswer.test.js new file mode 100644 index 000000000..dbb6969ee --- /dev/null +++ b/bases/rsptx/interactives/runestone/shortanswer/test/shortanswer.test.js @@ -0,0 +1,327 @@ +// Characterization tests for the ShortAnswer component. shortanswer.js was +// already free of jQuery when these were written; they guard that state. +// Note: deliberately NO jquery-globals import here -- shortanswer must work +// without jQuery. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import ShortAnswer from "../js/shortanswer.js"; +import TimedShortAnswer from "../js/timed_shortanswer.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +// Book pages provide a [data-component=shortanswer] element whose innerHTML is +// the question text (see the Sphinx/PreTeXt templates). +function makeFixture({ + id = "test_shortanswer_1", + question = "

Explain your reasoning.

", + attrs = "", +} = {}) { + document.body.innerHTML = ` +
+
+ ${question} +
+
`; + return document.getElementById(id); +} + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function makeShortAnswer(fixtureOpts = {}, extraOpts = {}) { + const orig = makeFixture(fixtureOpts); + const sa = new ShortAnswer({ + orig: orig, + useRunestoneServices: false, + ...extraOpts, + }); + await sa.component_ready_promise; + await tick(); + return sa; +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); + vi.stubGlobal("alert", vi.fn()); +}); + +describe("construction", () => { + it("replaces the original element with a form holding a textarea and save button", async () => { + const sa = await makeShortAnswer(); + const container = document.getElementById("test_shortanswer_1"); + expect(container).toBe(sa.containerDiv); + expect(container.querySelector("form").id).toBe( + "test_shortanswer_1_journal", + ); + const textarea = container.querySelector("textarea"); + expect(textarea.id).toBe("test_shortanswer_1_solution"); + expect(textarea.placeholder).toBe("Write your answer here"); + expect(container.querySelector("button.btn-success").textContent).toBe( + "Save", + ); + }); + + it("moves the question content into the fieldset", async () => { + await makeShortAnswer({ question: "

Why?

" }); + const legend = document.querySelector(".journal-question"); + expect(legend.querySelector("#qmark").textContent).toBe("Why?"); + }); + + it("keeps the original element's classes on the new container", async () => { + await makeShortAnswer(); + const container = document.getElementById("test_shortanswer_1"); + expect(container.classList.contains("journal")).toBe(true); + expect(container.classList.contains("alert-warning")).toBe(true); + }); + + it("honors data-placeholder", async () => { + await makeShortAnswer({ attrs: 'data-placeholder="Type here"' }); + expect(document.querySelector("textarea").placeholder).toBe( + "Type here", + ); + }); + + it("sets the optional flag from data-optional", async () => { + const sa = await makeShortAnswer({ attrs: "data-optional" }); + expect(sa.optional).toBe(true); + }); + + it("starts with the feedback div hidden", async () => { + const sa = await makeShortAnswer(); + expect(sa.feedbackDiv.style.display).toBe("none"); + }); + + it("adds a file input when data-attachment is set (student mode)", async () => { + // getAttachmentName fetches; stub it out. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, statusText: "nope" }), + ); + const sa = await makeShortAnswer({ attrs: "data-attachment" }); + expect(sa.attachment).toBe(true); + expect( + document.getElementById("test_shortanswer_1_fileme").type, + ).toBe("file"); + }); +}); + +describe("editing feedback", () => { + it("warns that the answer is unsaved on keydown", async () => { + const sa = await makeShortAnswer(); + sa.jTextArea.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + expect(sa.feedbackDiv.innerHTML).toBe( + "Your answer has not been saved yet!", + ); + expect(sa.feedbackDiv.classList.contains("alert-danger")).toBe(true); + }); + + it("shows the autosave message instead when timed", async () => { + const sa = await makeShortAnswer(); + sa.isTimed = true; + sa.jTextArea.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + expect(sa.feedbackDiv.innerHTML).toBe( + "Your answer is automatically saved.", + ); + }); + + it("marks the question answered when the textarea changes", async () => { + const sa = await makeShortAnswer(); + sa.jTextArea.value = "an answer"; + sa.jTextArea.dispatchEvent(new Event("change")); + expect(sa.isAnswered).toBe(true); + }); +}); + +describe("saving", () => { + it("stores the answer in localStorage and logs it on Save", async () => { + const sa = await makeShortAnswer(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + sa.jTextArea.value = "my thoughtful answer"; + sa.submitButton.click(); + await tick(); + const stored = JSON.parse( + localStorage.getItem(sa.localStorageKey()), + ); + expect(stored.answer).toBe("my thoughtful answer"); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "shortanswer", + act: "my thoughtful answer", + answer: "my thoughtful answer", + div_id: "test_shortanswer_1", + }), + ); + expect(sa.feedbackDiv.innerHTML).toBe("Your answer has been saved."); + expect(sa.feedbackDiv.classList.contains("alert-success")).toBe(true); + expect(sa.feedbackDiv.style.display).toBe("block"); + }); + + it("passes a student id through to the log event when given", async () => { + const sa = await makeShortAnswer(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + sa.jTextArea.value = "group answer"; + await sa.logCurrentAnswer("student_42"); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ sid: "student_42" }), + ); + }); +}); + +describe("checkLocalStorage", () => { + it("restores a previously saved answer into the textarea", async () => { + const first = await makeShortAnswer(); + first.jTextArea.value = "saved before"; + await first.checkCurrentAnswer(); + // A fresh render of the same question picks the answer up. + const second = await makeShortAnswer(); + expect(second.jTextArea.value).toBe("saved before"); + }); + + it("drops malformed stored data", async () => { + const sa = await makeShortAnswer(); + localStorage.setItem(sa.localStorageKey(), "not json{"); + sa.checkLocalStorage(); + expect(localStorage.getItem(sa.localStorageKey())).toBe(null); + }); + + it("does not restore when grading", async () => { + const first = await makeShortAnswer(); + first.jTextArea.value = "student work"; + await first.checkCurrentAnswer(); + const second = await makeShortAnswer({}, { graderactive: true }); + expect(second.jTextArea.value).toBe(""); + }); +}); + +describe("restoreAnswers", () => { + it("fills the textarea and shows the submission timestamp", async () => { + const sa = await makeShortAnswer(); + const when = new Date("2026-01-15T10:30:00"); + sa.restoreAnswers({ answer: "server answer", timestamp: when }); + expect(sa.jTextArea.value).toBe("server answer"); + const p = document.querySelector(".shortanswer__timestamp"); + expect(p.textContent).toBe(when.toLocaleString()); + }); + + it("treats a null answer as empty", async () => { + const sa = await makeShortAnswer(); + sa.restoreAnswers({ answer: null }); + expect(sa.jTextArea.value).toBe(""); + }); + + it("shows score and comment feedback", async () => { + const sa = await makeShortAnswer(); + sa.restoreAnswers({ answer: "x", score: 4, comment: "good work" }); + expect(sa.feedbackDiv.innerHTML).toBe("Score: 4 -- good work"); + expect(sa.feedbackDiv.style.display).toBe("block"); + }); + + it("adds a toggle button that swaps between on-time and late answers", async () => { + const sa = await makeShortAnswer(); + sa.restoreAnswers({ + answer: "on time", + timestamp: new Date("2026-01-15T10:30:00"), + last_answer: "late edit", + last_timestamp: new Date("2026-01-20T10:30:00"), + }); + const toggle = sa.buttonDiv.querySelector("button.btn-warning"); + expect(toggle.textContent).toBe("Show Late Answer"); + toggle.click(); + expect(sa.jTextArea.value).toBe("late edit"); + expect(toggle.textContent).toBe("Show on-Time Answer"); + toggle.click(); + expect(sa.jTextArea.value).toBe("on time"); + }); +}); + +describe("renderMath", () => { + it("renders LaTeX-looking answers into a live region with converted delimiters", async () => { + const sa = await makeShortAnswer(); + sa.renderMath("The area is $$x^2$$ and $y$"); + const rendered = document.querySelector( + ".shortanswer__rendered-answer", + ); + expect(rendered.innerHTML).toContain("\\[ x^2 \\]"); + expect(rendered.innerHTML).toContain("\\( y \\)"); + expect(rendered.getAttribute("aria-live")).toBe("polite"); + expect(sa.rederedAnswerDiv.style.display).toBe("block"); + }); + + it("hides the rendered answer again for plain text", async () => { + const sa = await makeShortAnswer(); + sa.renderMath("has math $x^2$"); + sa.renderMath("plain prose"); + expect(sa.rederedAnswerDiv.style.display).toBe("none"); + }); + + it("creates no rendered-answer div for plain text", async () => { + const sa = await makeShortAnswer(); + sa.renderMath("plain prose"); + expect(document.querySelector(".shortanswer__rendered-answer")).toBe( + null, + ); + }); +}); + +describe("disableInteraction", () => { + it("disables the textarea", async () => { + const sa = await makeShortAnswer(); + sa.disableInteraction(); + expect(sa.jTextArea.disabled).toBe(true); + }); +}); + +describe("TimedShortAnswer", () => { + async function makeTimed() { + const orig = makeFixture(); + const tsa = new TimedShortAnswer({ + orig: orig, + useRunestoneServices: false, + timed: true, + }); + await tsa.component_ready_promise; + await tick(); + return tsa; + } + + it("hides the save button and shows the clock icon", async () => { + const tsa = await makeTimed(); + expect(tsa.submitButton.style.display).toBe("none"); + expect(document.querySelector(".timeTip img").src).toContain( + "clock.png", + ); + expect(tsa.isTimed).toBe(true); + }); + + it("is ignored in grading and can hide its feedback", async () => { + const tsa = await makeTimed(); + expect(tsa.checkCorrectTimed()).toBe("I"); + tsa.feedbackDiv.style.display = "block"; + tsa.hideFeedback(); + expect(tsa.feedbackDiv.style.display).toBe("none"); + }); +}); + +describe("component factory", () => { + it("builds the timed variant only when opts.timed is set", async () => { + const plain = window.component_factory.shortanswer({ + orig: makeFixture({ id: "sa_plain" }), + }); + expect(plain).toBeInstanceOf(ShortAnswer); + expect(plain).not.toBeInstanceOf(TimedShortAnswer); + const timed = window.component_factory.shortanswer({ + orig: makeFixture({ id: "sa_timed" }), + timed: true, + }); + expect(timed).toBeInstanceOf(TimedShortAnswer); + }); +}); From ab9bc4b3554f858fcbabb69b8e1f6bc36f7e601b Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 09:31:10 -0500 Subject: [PATCH 04/15] feat: remove jQuery from video component Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/video/js/runestonevideo.js | 46 +++--- .../runestone/video/test/video.test.js | 149 ++++++++++++++++++ 2 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/video/test/video.test.js diff --git a/bases/rsptx/interactives/runestone/video/js/runestonevideo.js b/bases/rsptx/interactives/runestone/video/js/runestonevideo.js index 08904f403..0cff2be3f 100644 --- a/bases/rsptx/interactives/runestone/video/js/runestonevideo.js +++ b/bases/rsptx/interactives/runestone/video/js/runestonevideo.js @@ -1,13 +1,14 @@ "use strict"; import RunestoneBase from "../../common/js/runestonebase"; +import { getDataValue } from "../../common/js/domutil.js"; import "../css/video.css"; class RunestoneVideo extends RunestoneBase { constructor(opts) { super(opts); this.divid = opts.orig.id; - this.container = $(`#${this.divid}`); + this.container = document.getElementById(this.divid); this.caption = "YouTube"; if (document.getElementById("youtubescript") == null) { let script = document.createElement("script"); @@ -15,7 +16,7 @@ class RunestoneVideo extends RunestoneBase { script.src = "https://www.youtube.com/player_api"; document.body.appendChild(script); } - this.containerDiv = this.container[0].parentElement; + this.containerDiv = this.container.parentElement; this.addCaption("runestone"); this.indicate_component_ready(); } @@ -46,42 +47,47 @@ window.onPlayerStateChange = function (event) { //Callback function to load youtube videos once IFrame Player loads window.onYouTubeIframeAPIReady = function () { - let videolist = $(".youtube-video"); - videolist.each(function (i, video) { + for (let video of document.querySelectorAll(".youtube-video")) { let playerVars = {}; - playerVars["start"] = $(video).data("video-start"); - if ($(video).data("video-end") != -1) - playerVars["end"] = $(video).data("video-end"); - let player = new YT.Player($(video).data("video-divid"), { - height: $(video).data("video-height"), - width: $(video).data("video-width"), - videoId: $(video).data("video-videoid"), + playerVars["start"] = getDataValue(video, "video-start"); + if (getDataValue(video, "video-end") != -1) + playerVars["end"] = getDataValue(video, "video-end"); + let player = new YT.Player(getDataValue(video, "video-divid"), { + height: getDataValue(video, "video-height"), + width: getDataValue(video, "video-width"), + videoId: getDataValue(video, "video-videoid"), align: "center", playerVars: playerVars, events: { onStateChange: window.onPlayerStateChange, }, }); - }); + } }; //Need to make sure the YouTube IFrame Player API is not loaded until after // all YouTube videos are in the DOM. Add a script tag with it after document is loaded -$(function () { +function addPlayerApiScript() { let script = document.createElement("script"); script.src = "https://www.youtube.com/player_api"; document.body.appendChild(script); -}); +} +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", addPlayerApiScript); +} else { + addPlayerApiScript(); +} -$(document).on("runestone:login-complete", function () { - $("[data-component=youtube]").each(function (index) { - // MC +document.addEventListener("runestone:login-complete", function () { + for (let element of document.querySelectorAll( + "[data-component=youtube]", + )) { var opts = { - orig: this, + orig: element, useRunestoneServices: eBookConfig.useRunestoneServices, }; - window.componentMap[this.id] = new RunestoneVideo(opts); - }); + window.componentMap[element.id] = new RunestoneVideo(opts); + } }); if (typeof window.component_factory === "undefined") { diff --git a/bases/rsptx/interactives/runestone/video/test/video.test.js b/bases/rsptx/interactives/runestone/video/test/video.test.js new file mode 100644 index 000000000..bb2b2baea --- /dev/null +++ b/bases/rsptx/interactives/runestone/video/test/video.test.js @@ -0,0 +1,149 @@ +// Characterization tests for the RunestoneVideo (youtube/vimeo) component. +// These were first written against the jQuery implementation and now guard +// the jQuery-free version. +// Note: deliberately NO jquery-globals import here -- video must work +// without jQuery. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import "../js/runestonevideo.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +// A book page renders the video directive as a .youtube-video div (carrying +// the data-video-* attributes) inside a .runestone container. +function makeFixture({ id = "test_video_1", attrs = "" } = {}) { + document.body.innerHTML = ` +
+
+
`; + return document.getElementById(id); +} + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +beforeEach(() => { + document.body.innerHTML = ""; + document.getElementById("youtubescript")?.remove(); + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("construction", () => { + it("uses the parent of the video div as its container and captions it", async () => { + const orig = makeFixture(); + const video = new window.component_factory.youtube({ orig }); + await video.component_ready_promise; + expect(video.containerDiv).toBe(orig.parentElement); + const caption = video.containerDiv.querySelector(".runestone_caption"); + expect(caption.textContent).toBe("YouTube"); + }); + + it("injects the YouTube player API script exactly once", async () => { + const orig = makeFixture(); + new window.component_factory.youtube({ orig }); + const script = document.getElementById("youtubescript"); + expect(script.src).toContain("youtube.com/player_api"); + new window.component_factory.vimeo({ orig: makeFixture({ id: "v2" }) }); + expect( + document.querySelectorAll("#youtubescript").length, + ).toBe(1); + }); +}); + +describe("page wiring", () => { + it("builds a component for each [data-component=youtube] at login-complete", async () => { + makeFixture({ id: "wired_video" }); + document.dispatchEvent(new CustomEvent("runestone:login-complete")); + await tick(); + expect(window.componentMap.wired_video).toBeDefined(); + expect(window.componentMap.wired_video.divid).toBe("wired_video"); + }); +}); + +describe("onYouTubeIframeAPIReady", () => { + it("creates a YT.Player per .youtube-video with coerced data attributes", () => { + makeFixture({ id: "yt_a" }); + const Player = vi.fn(); + vi.stubGlobal("YT", { Player }); + window.onYouTubeIframeAPIReady(); + expect(Player).toHaveBeenCalledTimes(1); + const [divid, config] = Player.mock.calls[0]; + expect(divid).toBe("yt_a"); + expect(config.height).toBe(315); + expect(config.width).toBe(560); + expect(config.videoId).toBe("dQw4w9WgXcQ"); + expect(config.playerVars).toEqual({ start: 0 }); + expect(config.events.onStateChange).toBe(window.onPlayerStateChange); + }); + + it("passes an end time when data-video-end is not -1", () => { + makeFixture({ id: "yt_b" }); + document + .getElementById("yt_b") + .setAttribute("data-video-end", "90"); + const Player = vi.fn(); + vi.stubGlobal("YT", { Player }); + window.onYouTubeIframeAPIReady(); + expect(Player.mock.calls[0][1].playerVars).toEqual({ + start: 0, + end: 90, + }); + }); +}); + +describe("onPlayerStateChange", () => { + const PlayerState = { PLAYING: 1, ENDED: 0, PAUSED: 2 }; + + function fireState(data) { + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + window.onPlayerStateChange({ + data, + target: { + getCurrentTime: () => 42, + getIframe: () => ({ id: "test_video_1" }), + }, + }); + return logSpy; + } + + beforeEach(() => { + vi.stubGlobal("YT", { PlayerState }); + }); + + it("logs play with the current time", () => { + const logSpy = fireState(PlayerState.PLAYING); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "video", + div_id: "test_video_1", + act: "play:42", + }), + ); + }); + + it("logs completion when the video ends", () => { + const logSpy = fireState(PlayerState.ENDED); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ act: "complete" }), + ); + }); + + it("logs pause with the current time", () => { + const logSpy = fireState(PlayerState.PAUSED); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ act: "pause:42" }), + ); + }); + + it("logs ready for any other state", () => { + const logSpy = fireState(3); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ act: "ready" }), + ); + }); +}); From fdd4f7245acb57376aff6c15c18e91ec2fffc7d1 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 09:42:08 -0500 Subject: [PATCH 05/15] feat: remove jQuery from webwork component The WeBWorK server's JS still fires runestone_ww_check through the page's jQuery, so listener registration goes through window.jQuery when the page provides it (with a native fallback); a dedicated interop test covers both dispatch styles. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/webwork/js/webwork.js | 60 +++- .../runestone/webwork/test/webwork.test.js | 266 ++++++++++++++++++ .../test/webwork_jquery_interop.test.js | 75 +++++ 3 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/webwork/test/webwork.test.js create mode 100644 bases/rsptx/interactives/runestone/webwork/test/webwork_jquery_interop.test.js diff --git a/bases/rsptx/interactives/runestone/webwork/js/webwork.js b/bases/rsptx/interactives/runestone/webwork/js/webwork.js index 8012eb9f9..230fa4a9a 100644 --- a/bases/rsptx/interactives/runestone/webwork/js/webwork.js +++ b/bases/rsptx/interactives/runestone/webwork/js/webwork.js @@ -210,21 +210,59 @@ window.component_factory.webwork = function (opts) { return new WebWork(opts); }; -$(function () { - $("body").on("runestone_ww_check", logWebWork); - $("body").on("runestone_show_correct", logShowCorrect); -}); +// The WeBWorK server's own JS fires these events. Historically it does so +// with the page's jQuery (`$("body").trigger(...)`, payload as an extra +// handler argument); jQuery-triggered events never reach native listeners, so +// when the page provides jQuery we must register through it. A jQuery +// registration also sees natively dispatched CustomEvents, so exactly one +// registration path is used to avoid handling an event twice. The payload +// can arrive as the extra jQuery argument or as the CustomEvent detail. +function wwHandler(handler) { + return function (e, data) { + const payload = + data !== undefined + ? data + : (e.detail ?? e.originalEvent?.detail); + if (payload === undefined) return; + handler(e, payload); + }; +} + +function registerWWListeners() { + if (window.jQuery) { + window.jQuery("body").on("runestone_ww_check", wwHandler(logWebWork)); + window + .jQuery("body") + .on("runestone_show_correct", wwHandler(logShowCorrect)); + } else { + document.body.addEventListener( + "runestone_ww_check", + wwHandler(logWebWork), + ); + document.body.addEventListener( + "runestone_show_correct", + wwHandler(logShowCorrect), + ); + } +} -$(document).on("runestone:login-complete", function () { - $("[data-component=webwork]").each(function (index) { - // MC +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", registerWWListeners); +} else { + registerWWListeners(); +} + +document.addEventListener("runestone:login-complete", function () { + for (let element of document.querySelectorAll( + "[data-component=webwork]", + )) { var opts = { - orig: this, + orig: element, useRunestoneServices: eBookConfig.useRunestoneServices, }; - if ($(this).closest("[data-component=timedAssessment]").length == 0) { + if (!element.closest("[data-component=timedAssessment]")) { // If this element exists within a timed component, don't render it here - window.wwList[this.id] = new WebWork(opts); + window.wwList[element.id] = new WebWork(opts); } - }); + } }); diff --git a/bases/rsptx/interactives/runestone/webwork/test/webwork.test.js b/bases/rsptx/interactives/runestone/webwork/test/webwork.test.js new file mode 100644 index 000000000..69e747151 --- /dev/null +++ b/bases/rsptx/interactives/runestone/webwork/test/webwork.test.js @@ -0,0 +1,266 @@ +// Characterization tests for the WebWork component. These were first written +// against the jQuery implementation and now guard the jQuery-free version. +// Note: deliberately NO jquery-globals import here -- webwork must work +// without jQuery. The cooperation with a page that DOES provide jQuery (the +// WeBWorK server's own JS triggers events through it) is covered separately +// in webwork_jquery_interop.test.js. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import "../js/webwork.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +function makeFixture({ id = "test_ww_1" } = {}) { + document.body.innerHTML = ` +
+
+
`; + return document.getElementById(id); +} + +// The payload WeBWorK's check-answer machinery passes along with +// runestone_ww_check (shape from a 2.17 server). +function makeWWData({ answers, ww_version = "2.17", inputs = {} } = {}) { + return { + ww_version, + inputs_ref: { + problemUUID: "test_ww_1-ww-rs", + ...inputs, + }, + rh_result: { answers }, + }; +} + +const RIGHT = { + score: 1, + type: "Value (Formula)", + original_student_ans: "x+1", + student_value: "x+1(v)", + correct_value: "x+1", +}; +const WRONG = { + score: 0, + type: "Value (Formula)", + original_student_ans: "x-1", + student_value: "x-1(v)", + correct_value: "x+1", +}; + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +function makeWW(fixtureOpts = {}) { + const orig = makeFixture(fixtureOpts); + return window.component_factory.webwork({ + orig, + useRunestoneServices: false, + }); +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + window.wwList = {}; + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("construction", () => { + it("registers itself in window.wwList keyed by div id", () => { + const ww = makeWW(); + expect(window.wwList.test_ww_1).toBe(ww); + expect(ww.containerDiv).toBe(document.getElementById("test_ww_1")); + expect(ww.caption).toBe("WebWork"); + }); +}); + +describe("processCurrentAnswers", () => { + it("grades a fully correct submission", () => { + const ww = makeWW(); + ww.processCurrentAnswers( + makeWWData({ answers: { AnSwEr0001: RIGHT } }), + ); + expect(ww.correct).toBe(true); + expect(ww.percent).toBe(1); + expect(ww.actString).toContain("actual:x+1:expected:x+1"); + expect(ww.actString).toContain("correct:1:count:1:pct:1"); + expect(ww.answerObj.answers.AnSwEr0001).toBe("x+1"); + }); + + it("grades partial credit as incorrect with the right percent", () => { + const ww = makeWW(); + ww.processCurrentAnswers( + makeWWData({ answers: { AnSwEr0001: RIGHT, AnSwEr0002: WRONG } }), + ); + expect(ww.correct).toBe(false); + expect(ww.percent).toBe(0.5); + }); + + it("uses student_value for multiple-choice style answers", () => { + const ww = makeWW(); + ww.processCurrentAnswers( + makeWWData({ + answers: { + AnSwEr0001: { ...RIGHT, type: "Value (PopUp)" }, + }, + }), + ); + expect(ww.answerObj.answers.AnSwEr0001).toBe("x+1(v)"); + }); + + it("takes raw input refs on servers newer than 2.18", () => { + const ww = makeWW(); + ww.processCurrentAnswers( + makeWWData({ + ww_version: "2.19", + answers: { AnSwEr0001: RIGHT }, + inputs: { AnSwEr0001: ["a", "b"] }, + }), + ); + expect(ww.answerObj.answers.AnSwEr0001).toEqual(["a", "b"]); + }); + + it("captures MathQuill companion inputs", () => { + const ww = makeWW(); + ww.processCurrentAnswers( + makeWWData({ + answers: { AnSwEr0001: RIGHT }, + inputs: { MaThQuIlL_AnSwEr0001: "\\frac{1}{2}" }, + }), + ); + expect(ww.answerObj.mqAnswers.MaThQuIlL_AnSwEr0001).toBe( + "\\frac{1}{2}", + ); + }); + + it("saves the graded state to localStorage and decorates the question", () => { + const ww = makeWW(); + ww.processCurrentAnswers( + makeWWData({ answers: { AnSwEr0001: RIGHT } }), + ); + const stored = JSON.parse(localStorage.getItem(ww.localStorageKey())); + expect(stored.correct).toBe(true); + expect(stored.answer.answers.AnSwEr0001).toBe("x+1"); + expect( + document.querySelector("div.runestone").classList.contains( + "isCorrect", + ), + ).toBe(true); + }); +}); + +describe("persistence", () => { + it("checkLocalStorage restores answers and decorates", () => { + const first = makeWW(); + first.processCurrentAnswers( + makeWWData({ answers: { AnSwEr0001: WRONG } }), + ); + const second = makeWW(); + second.checkLocalStorage(); + expect(second.correct).toBe(false); + expect( + document.querySelector("div.runestone").classList.contains( + "isInCorrect", + ), + ).toBe(true); + }); + + it("checkLocalStorage drops malformed data", () => { + const ww = makeWW(); + localStorage.setItem(ww.localStorageKey(), "not json{"); + ww.checkLocalStorage(); + expect(localStorage.getItem(ww.localStorageKey())).toBe(null); + }); + + it("restoreAnswers treats a null answer as empty and decorates", () => { + const ww = makeWW(); + ww.restoreAnswers({ answer: null, correct: true, percent: 1 }); + expect(ww.answers).toBe(""); + expect(ww.correct).toBe(true); + expect( + document.querySelector("div.runestone").classList.contains( + "isCorrect", + ), + ).toBe(true); + }); +}); + +describe("logCurrentAnswer", () => { + it("logs the webwork event with the JSON answer", async () => { + const ww = makeWW(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + ww.processCurrentAnswers( + makeWWData({ answers: { AnSwEr0001: RIGHT } }), + ); + await ww.logCurrentAnswer(); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "webwork", + div_id: "test_ww_1", + correct: true, + answer: JSON.stringify(ww.answerObj), + }), + ); + }); +}); + +describe("page wiring", () => { + afterEach(() => { + eBookConfig.useRunestoneServices = false; + }); + + it("handles a natively dispatched runestone_ww_check", async () => { + eBookConfig.useRunestoneServices = true; + const ww = makeWW(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + document.body.dispatchEvent( + new CustomEvent("runestone_ww_check", { + detail: makeWWData({ answers: { AnSwEr0001: RIGHT } }), + bubbles: true, + }), + ); + await tick(); + expect(ww.correct).toBe(true); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ event: "webwork" }), + ); + }); + + it("logs a show event for a natively dispatched runestone_show_correct", async () => { + eBookConfig.useRunestoneServices = true; + makeWW(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + document.body.dispatchEvent( + new CustomEvent("runestone_show_correct", { + detail: makeWWData({ answers: { AnSwEr0001: RIGHT } }), + bubbles: true, + }), + ); + await tick(); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "webwork", + div_id: "test_ww_1-ww-rs", + act: "show", + }), + ); + }); + + it("builds components at login-complete, skipping timed exam questions", async () => { + document.body.innerHTML = ` +
+
+
+
+
+
`; + document.dispatchEvent(new CustomEvent("runestone:login-complete")); + await tick(); + expect(window.wwList.ww_free).toBeDefined(); + expect(window.wwList.ww_timed).toBeUndefined(); + }); +}); diff --git a/bases/rsptx/interactives/runestone/webwork/test/webwork_jquery_interop.test.js b/bases/rsptx/interactives/runestone/webwork/test/webwork_jquery_interop.test.js new file mode 100644 index 000000000..7de7c2221 --- /dev/null +++ b/bases/rsptx/interactives/runestone/webwork/test/webwork_jquery_interop.test.js @@ -0,0 +1,75 @@ +// The WeBWorK server's own JS fires runestone_ww_check / runestone_show_correct +// through the page's jQuery ($("body").trigger(name, payload)). webwork.js no +// longer needs jQuery itself, but it must still hear those triggers when the +// page provides jQuery. This file loads the jQuery shim ON PURPOSE to stand in +// for such a page -- do not copy this import into component tests. +import "../../../test-support/jquery-globals.js"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import "../js/webwork.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +const RIGHT = { + score: 1, + type: "Value (Formula)", + original_student_ans: "x+1", + student_value: "x+1(v)", + correct_value: "x+1", +}; + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +beforeEach(() => { + document.body.innerHTML = ` +
+
+
`; + window.componentMap = {}; + window.allComponents = []; + window.wwList = {}; + localStorage.clear(); + vi.restoreAllMocks(); + eBookConfig.useRunestoneServices = true; +}); + +it("grades an answer delivered by a jQuery trigger", async () => { + const ww = window.component_factory.webwork({ + orig: document.getElementById("test_ww_1"), + }); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + window + .jQuery("body") + .trigger("runestone_ww_check", { + ww_version: "2.17", + inputs_ref: { problemUUID: "test_ww_1-ww-rs" }, + rh_result: { answers: { AnSwEr0001: RIGHT } }, + }); + await tick(); + expect(ww.correct).toBe(true); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ event: "webwork" }), + ); +}); + +it("handles a native dispatch exactly once even with jQuery present", async () => { + const ww = window.component_factory.webwork({ + orig: document.getElementById("test_ww_1"), + }); + const processSpy = vi.spyOn(ww, "processCurrentAnswers"); + vi.spyOn(RunestoneBase.prototype, "logBookEvent").mockResolvedValue( + undefined, + ); + document.body.dispatchEvent( + new CustomEvent("runestone_ww_check", { + detail: { + ww_version: "2.17", + inputs_ref: { problemUUID: "test_ww_1-ww-rs" }, + rh_result: { answers: { AnSwEr0001: RIGHT } }, + }, + bubbles: true, + }), + ); + await tick(); + expect(processSpy).toHaveBeenCalledTimes(1); +}); From fbf09fcc45da4a402fc0b620a925735c522c8f22 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 09:45:34 -0500 Subject: [PATCH 06/15] feat: remove jQuery from tabbedStuff; own tab switching natively Tab switching used to be delegated to Bootstrap 3's jQuery tab plugin via data-toggle attributes; the component now handles clicks itself and runs the CodeMirror-refresh/Disqus-click logic that lived in the shown.bs.tab handler. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/tabbedStuff/js/tabbedstuff.js | 124 ++++++++------ .../tabbedStuff/test/tabbedstuff.test.js | 156 ++++++++++++++++++ 2 files changed, 228 insertions(+), 52 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/tabbedStuff/test/tabbedstuff.test.js diff --git a/bases/rsptx/interactives/runestone/tabbedStuff/js/tabbedstuff.js b/bases/rsptx/interactives/runestone/tabbedStuff/js/tabbedstuff.js index 8da6db44a..8b3ead0d7 100644 --- a/bases/rsptx/interactives/runestone/tabbedStuff/js/tabbedstuff.js +++ b/bases/rsptx/interactives/runestone/tabbedStuff/js/tabbedstuff.js @@ -18,16 +18,13 @@ import RunestoneBase from "../../common/js/runestonebase"; import "../css/tabbedstuff.css"; // Define TabbedStuff object -class TabbedStuff extends RunestoneBase { +export class TabbedStuff extends RunestoneBase { constructor(opts) { super(opts); var orig = opts.orig; this.origElem = orig; // entire original
element that will be replaced by new HTML this.divid = orig.id; - this.inactive = false; - if ($(this.origElem).is("[data-inactive]")) { - this.inactive = true; - } + this.inactive = orig.hasAttribute("data-inactive"); this.togglesList = []; // For use in Codemirror/Disqus this.childTabs = []; this.populateChildTabs(); @@ -42,14 +39,18 @@ class TabbedStuff extends RunestoneBase { ===========================================*/ populateChildTabs() { for (var i = 0; i < this.origElem.childNodes.length; i++) { - if ($(this.origElem.childNodes[i]).data("component") === "tab") { - this.childTabs.push(this.origElem.childNodes[i]); + const node = this.origElem.childNodes[i]; + if ( + node.nodeType === Node.ELEMENT_NODE && + node.dataset.component === "tab" + ) { + this.childTabs.push(node); } } } findActiveTab() { for (var i = 0; i < this.childTabs.length; i++) { - if ($(this.childTabs[i]).is("[data-active]")) { + if (this.childTabs[i].hasAttribute("data-active")) { this.activeTab = i; } } @@ -60,74 +61,86 @@ class TabbedStuff extends RunestoneBase { createTabContainer() { this.containerDiv = document.createElement("div"); this.containerDiv.id = this.divid; - $(this.containerDiv).addClass(this.origElem.getAttribute("class")); - $(this.containerDiv).attr({ role: "tabpanel" }); + const origClass = this.origElem.getAttribute("class"); + if (origClass) { + this.containerDiv.classList.add( + ...origClass.split(" ").filter(Boolean), + ); + } + this.containerDiv.setAttribute("role", "tabpanel"); this.tabsUL = document.createElement("ul"); this.tabsUL.id = this.divid + "_tab"; - $(this.tabsUL).addClass("nav nav-tabs"); - $(this.tabsUL).attr({ role: "tablist" }); + this.tabsUL.classList.add("nav", "nav-tabs"); + this.tabsUL.setAttribute("role", "tablist"); this.tabContentDiv = document.createElement("div"); // Create tab content container that holds tab panes w/content - $(this.tabContentDiv).addClass("tab-content"); + this.tabContentDiv.classList.add("tab-content"); this.createTabs(); // create and append tabs to the
    this.containerDiv.appendChild(this.tabsUL); this.containerDiv.appendChild(this.tabContentDiv); - this.addCMD(); // Adds fuctionality for Codemirror/Disqus - $(this.origElem).replaceWith(this.containerDiv); + this.origElem.replaceWith(this.containerDiv); } createTabs() { // Create tabs in format
  • to be appended to the
      for (var i = 0; i < this.childTabs.length; i++) { // First create tabname and tabfriendly name that has no spaces to be used for the id var tabListElement = document.createElement("li"); - $(tabListElement).attr({ - role: "presentation", - "aria-controls": this.divid + "-" + i, - }); - // Using bootstrap tabs functionality + tabListElement.setAttribute("role", "presentation"); + tabListElement.setAttribute( + "aria-controls", + this.divid + "-" + i, + ); var tabElement = document.createElement("a"); - $(tabElement).attr({ - "data-toggle": "tab", - href: "#" + this.divid + "-" + i, - role: "tab", - }); + tabElement.setAttribute("href", "#" + this.divid + "-" + i); + tabElement.setAttribute("role", "tab"); + tabElement.addEventListener("click", this.handleTabClick(i)); var tabTitle = document.createElement("span"); // Title of tab--what the user will see - tabTitle.textContent = $(this.childTabs[i]).data("tabname"); + tabTitle.textContent = this.childTabs[i].dataset.tabname; tabElement.appendChild(tabTitle); tabListElement.appendChild(tabElement); this.tabsUL.appendChild(tabListElement); // tabPane is what holds the contents of the tab var tabPaneDiv = document.createElement("div"); tabPaneDiv.id = this.divid + "-" + i; - $(tabPaneDiv).addClass("tab-pane"); - $(tabPaneDiv).attr({ - role: "tabpanel", - }); - //var tabHTML = $(this.childTabs[i]).html(); - //$(tabPaneDiv).html(tabHTML); + tabPaneDiv.classList.add("tab-pane"); + tabPaneDiv.setAttribute("role", "tabpanel"); tabPaneDiv.appendChild(this.childTabs[i]); if (!this.inactive) { if (this.activeTab === i) { - $(tabListElement).addClass("active"); - $(tabPaneDiv).addClass("active"); + tabListElement.classList.add("active"); + tabPaneDiv.classList.add("active"); } } this.togglesList.push(tabElement); this.tabContentDiv.appendChild(tabPaneDiv); } } - /*=================================== - == Codemirror/Disqus functionality == - ===================================*/ - addCMD() { - $(this.togglesList).on("shown.bs.tab", function (e) { - var content_div = $(e.target.attributes.href.value); - content_div.find(".disqus_thread_link").each(function () { - $(this).click(); - }); - content_div.find(".CodeMirror").each(function (i, el) { - el.CodeMirror.refresh(); - }); - }); + /*===================================================== + == Tab switching (was Bootstrap 3's jQuery tab plugin) == + =====================================================*/ + handleTabClick(index) { + return (event) => { + event.preventDefault(); + this.showTab(index); + }; + } + showTab(index) { + for (const li of this.tabsUL.children) { + li.classList.remove("active"); + } + for (const pane of this.tabContentDiv.children) { + pane.classList.remove("active"); + } + this.tabsUL.children[index].classList.add("active"); + const pane = this.tabContentDiv.children[index]; + pane.classList.add("active"); + // Anything that was rendered while the pane was hidden may need a + // nudge now that it is visible (was the shown.bs.tab handler). + for (const link of pane.querySelectorAll(".disqus_thread_link")) { + link.click(); + } + for (const el of pane.querySelectorAll(".CodeMirror")) { + el.CodeMirror.refresh(); + } } } @@ -135,8 +148,15 @@ class TabbedStuff extends RunestoneBase { == Find the custom HTML tags and == == execute our code on them == =================================*/ -$("load", function () { - $("[data-component=tabbedStuff]").each(function (index) { - TSList[this.id] = new TabbedStuff({ orig: this }); - }); -}); +function initTabbedStuff() { + for (const element of document.querySelectorAll( + "[data-component=tabbedStuff]", + )) { + TSList[element.id] = new TabbedStuff({ orig: element }); + } +} +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initTabbedStuff); +} else { + initTabbedStuff(); +} diff --git a/bases/rsptx/interactives/runestone/tabbedStuff/test/tabbedstuff.test.js b/bases/rsptx/interactives/runestone/tabbedStuff/test/tabbedstuff.test.js new file mode 100644 index 000000000..f1386cbac --- /dev/null +++ b/bases/rsptx/interactives/runestone/tabbedStuff/test/tabbedstuff.test.js @@ -0,0 +1,156 @@ +// Characterization tests for the TabbedStuff component. The DOM-construction +// tests were written against the jQuery implementation; the tab-switching +// tests encode the contract the jQuery-free version must own itself (the old +// implementation delegated switching to Bootstrap 3's jQuery tab plugin). +// Note: deliberately NO jquery-globals import here -- tabbedStuff must work +// without jQuery. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { TabbedStuff } from "../js/tabbedstuff.js"; + +function makeFixture({ id = "test_tabs_1", attrs = "", tabs } = {}) { + tabs = + tabs ?? + ` +
      +

      The question text.

      +
      +
      +

      The answer text.

      +
      `; + document.body.innerHTML = ` +
      +
      ${tabs}
      +
      `; + return document.getElementById(id); +} + +async function makeTabs(fixtureOpts = {}) { + const orig = makeFixture(fixtureOpts); + const ts = new TabbedStuff({ orig }); + await ts.component_ready_promise; + return ts; +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; +}); + +describe("construction", () => { + it("replaces the original element with a tab list and panes", async () => { + const ts = await makeTabs(); + const container = document.getElementById("test_tabs_1"); + expect(container).toBe(ts.containerDiv); + expect(container.getAttribute("role")).toBe("tabpanel"); + const ul = container.querySelector("ul#test_tabs_1_tab"); + expect(ul.classList.contains("nav-tabs")).toBe(true); + expect(ul.getAttribute("role")).toBe("tablist"); + const titles = [...ul.querySelectorAll("li a span")].map( + (s) => s.textContent, + ); + expect(titles).toEqual(["Question", "Answer"]); + const panes = container.querySelectorAll(".tab-content .tab-pane"); + expect(panes.length).toBe(2); + expect(panes[0].id).toBe("test_tabs_1-0"); + expect(panes[0].getAttribute("role")).toBe("tabpanel"); + expect(panes[0].textContent).toContain("The question text."); + expect(panes[1].textContent).toContain("The answer text."); + }); + + it("carries the original classes over and links tabs to panes", async () => { + await makeTabs(); + const container = document.getElementById("test_tabs_1"); + expect(container.classList.contains("alert-warning")).toBe(true); + const anchors = container.querySelectorAll("li a"); + expect(anchors[0].getAttribute("href")).toBe("#test_tabs_1-0"); + expect(anchors[1].getAttribute("href")).toBe("#test_tabs_1-1"); + const lis = container.querySelectorAll("li"); + expect(lis[0].getAttribute("aria-controls")).toBe("test_tabs_1-0"); + }); + + it("activates the first tab by default", async () => { + await makeTabs(); + const container = document.getElementById("test_tabs_1"); + const lis = container.querySelectorAll("li"); + const panes = container.querySelectorAll(".tab-pane"); + expect(lis[0].classList.contains("active")).toBe(true); + expect(panes[0].classList.contains("active")).toBe(true); + expect(lis[1].classList.contains("active")).toBe(false); + expect(panes[1].classList.contains("active")).toBe(false); + }); + + it("activates the tab marked data-active instead", async () => { + await makeTabs({ + tabs: ` +

      1

      +

      2

      `, + }); + const container = document.getElementById("test_tabs_1"); + const lis = container.querySelectorAll("li"); + expect(lis[0].classList.contains("active")).toBe(false); + expect(lis[1].classList.contains("active")).toBe(true); + }); + + it("activates nothing when the component is marked data-inactive", async () => { + await makeTabs({ attrs: "data-inactive" }); + const container = document.getElementById("test_tabs_1"); + expect(container.querySelectorAll("li.active").length).toBe(0); + expect(container.querySelectorAll(".tab-pane.active").length).toBe(0); + }); +}); + +describe("tab switching", () => { + it("shows the clicked tab and hides the others", async () => { + await makeTabs(); + const container = document.getElementById("test_tabs_1"); + const anchors = container.querySelectorAll("li a"); + const lis = container.querySelectorAll("li"); + const panes = container.querySelectorAll(".tab-pane"); + anchors[1].dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(lis[1].classList.contains("active")).toBe(true); + expect(panes[1].classList.contains("active")).toBe(true); + expect(lis[0].classList.contains("active")).toBe(false); + expect(panes[0].classList.contains("active")).toBe(false); + anchors[0].dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(lis[0].classList.contains("active")).toBe(true); + expect(panes[1].classList.contains("active")).toBe(false); + }); + + it("refreshes CodeMirror instances in the shown tab", async () => { + await makeTabs(); + const container = document.getElementById("test_tabs_1"); + const pane = container.querySelector("#test_tabs_1-1"); + const cmHost = document.createElement("div"); + cmHost.classList.add("CodeMirror"); + cmHost.CodeMirror = { refresh: vi.fn() }; + pane.appendChild(cmHost); + container + .querySelectorAll("li a")[1] + .dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(cmHost.CodeMirror.refresh).toHaveBeenCalled(); + }); + + it("clicks deferred Disqus links in the shown tab", async () => { + await makeTabs(); + const container = document.getElementById("test_tabs_1"); + const pane = container.querySelector("#test_tabs_1-1"); + const link = document.createElement("a"); + link.classList.add("disqus_thread_link"); + const clickSpy = vi.fn(); + link.addEventListener("click", clickSpy); + pane.appendChild(link); + container + .querySelectorAll("li a")[1] + .dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + expect(clickSpy).toHaveBeenCalled(); + }); +}); From cc193cd0ae9d4588eb66b212cd3c67c0b6b21853 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 09:52:38 -0500 Subject: [PATCH 07/15] feat: remove jQuery and select2 from groupsub The partner picker falls back to the native multi-select that select2 was enhancing. The group-size limit is now actually enforced at submit time: the old check read group.len (always undefined), so only select2's UI cap ever limited selections. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/groupsub/js/groupsub.js | 20 +- .../runestone/groupsub/test/groupsub.test.js | 205 ++++++++++++++++++ 2 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/groupsub/test/groupsub.test.js diff --git a/bases/rsptx/interactives/runestone/groupsub/js/groupsub.js b/bases/rsptx/interactives/runestone/groupsub/js/groupsub.js index 3a72f0dbe..f4726ede1 100644 --- a/bases/rsptx/interactives/runestone/groupsub/js/groupsub.js +++ b/bases/rsptx/interactives/runestone/groupsub/js/groupsub.js @@ -11,19 +11,14 @@ import RunestoneBase from "../../common/js/runestonebase"; import "../css/groupsub.css"; -import "select2/dist/js/select2.min.js"; -import "select2/dist/css/select2.css"; - -var pageReveal; // Define Reveal object -class GroupSub extends RunestoneBase { +export class GroupSub extends RunestoneBase { constructor(opts) { super(opts); var orig = opts.orig; // entire
      element that will be replaced by new HTML this.origElem = orig; this.divid = orig.id; - self.group = []; this.limit = this.origElem.dataset.size_limit; this.isPretext = document.body.classList.contains("pretext"); // Create submit button @@ -94,12 +89,9 @@ class GroupSub extends RunestoneBase { opt.innerHTML = this.studentList[sid]; select.appendChild(opt); } - // Make the select element searchable with multiple selections - $(".assignment_partner_select").select2({ - placeholder: "Select up to 4 team members", - allowClear: true, - maximumSelectionLength: this.limit, - }); + // A native multi-select (select2 used to enhance this; the size + // limit is enforced in submitAll). + select.title = `Select up to ${this.limit} team members`; } async submitAll() { @@ -114,7 +106,7 @@ class GroupSub extends RunestoneBase { if (username && !group.includes(username)) { group.push(username); } - if (group.len > this.limit) { + if (group.length > this.limit) { alert( `You may not have more than ${this.limit} students in a group`, ); @@ -155,7 +147,7 @@ class GroupSub extends RunestoneBase { } } -$(document).on("runestone:login-complete", async function () { +document.addEventListener("runestone:login-complete", async function () { let gs = document.querySelectorAll("[data-component=groupsub]"); if (gs.length > 1) { alert("Only one Group Submit is allowed per page"); diff --git a/bases/rsptx/interactives/runestone/groupsub/test/groupsub.test.js b/bases/rsptx/interactives/runestone/groupsub/test/groupsub.test.js new file mode 100644 index 000000000..419eee306 --- /dev/null +++ b/bases/rsptx/interactives/runestone/groupsub/test/groupsub.test.js @@ -0,0 +1,205 @@ +// Characterization tests for the GroupSub component. These were first +// written against the jQuery implementation and now guard the jQuery-free +// version (which drops the select2 enhancement and uses the underlying +// native multi-select). +// Note: deliberately NO jquery-globals import here -- groupsub must work +// without jQuery. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { GroupSub } from "../js/groupsub.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +// The assignment page (doAssignment.html) provides the button container and +// the multi-select the component fills in. +function makeFixture({ id = "test_gs_1", limit = 4 } = {}) { + document.body.innerHTML = ` +
      +
      + +
      `; + return document.getElementById(id); +} + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function makeGroupSub(fixtureOpts = {}) { + const orig = makeFixture(fixtureOpts); + const gs = new GroupSub({ orig }); + await gs.initialize(); + return gs; +} + +function select(gs, ...sids) { + for (const option of gs.picker.options) { + if (sids.includes(option.value)) { + option.selected = true; + } + } +} + +beforeEach(() => { + document.body.innerHTML = ""; + document.body.classList.remove("pretext"); + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); + vi.stubGlobal("alert", vi.fn()); + eBookConfig.username = "leader"; +}); + +afterEach(() => { + delete eBookConfig.username; +}); + +describe("construction and initialize", () => { + it("adds a Submit Group button to the page's button container", async () => { + await makeGroupSub(); + const button = document.querySelector("#groupsub_button button"); + expect(button.textContent).toBe("Submit Group"); + expect(button.classList.contains("btn-success")).toBe(true); + }); + + it("fills the picker with the demo class list when services are off", async () => { + const gs = await makeGroupSub(); + expect(gs.picker).toBe(document.getElementById("assignment_group")); + const values = [...gs.picker.options].map((o) => o.value); + expect(values).toEqual(["s1", "s2", "s3", "s4", "s5"]); + expect(gs.picker.options[0].textContent).toBe("User 1"); + }); + + // Node's Request rejects the relative URLs the component uses; fetch is + // mocked in these tests, so a capturing stand-in is enough. + class FakeRequest { + constructor(url, opts) { + this.url = url; + Object.assign(this, opts); + } + } + + it("fills the picker from the course roster when services are on", async () => { + eBookConfig.useRunestoneServices = true; + vi.stubGlobal("Request", FakeRequest); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + redirected: false, + json: async () => ({ + detail: { + students: { alice: "Alice A", bob: "Bob B" }, + }, + }), + }), + ); + try { + const gs = await makeGroupSub(); + const values = [...gs.picker.options].map((o) => o.value); + expect(values).toEqual(["alice", "bob"]); + } finally { + eBookConfig.useRunestoneServices = false; + } + }); + + it("degrades to an explanatory entry when the roster fetch fails", async () => { + eBookConfig.useRunestoneServices = true; + vi.stubGlobal("Request", FakeRequest); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, redirected: false }), + ); + try { + const gs = await makeGroupSub(); + expect([...gs.picker.options][0].textContent).toContain( + "Failed to load students", + ); + } finally { + eBookConfig.useRunestoneServices = false; + } + }); +}); + +describe("submitAll", () => { + it("submits every component on the page for every group member", async () => { + const gs = await makeGroupSub(); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + const question = { + logCurrentAnswer: vi.fn().mockResolvedValue(undefined), + }; + window.allComponents = [question]; + select(gs, "s1", "s2"); + await gs.submitAll(); + // once per (member, question); the leader is added automatically + const students = question.logCurrentAnswer.mock.calls.map( + (c) => c[0], + ); + expect(students).toEqual(["s1", "s2", "leader"]); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "group_start", + act: "s1,s2,leader", + }), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "group_end", + act: "s1,s2,leader", + }), + ); + }); + + it("keeps submitting other members when one submission fails", async () => { + const gs = await makeGroupSub(); + vi.spyOn(RunestoneBase.prototype, "logBookEvent").mockResolvedValue( + undefined, + ); + const bad = { + logCurrentAnswer: vi.fn().mockRejectedValue(new Error("boom")), + }; + const good = { + logCurrentAnswer: vi.fn().mockResolvedValue(undefined), + }; + window.allComponents = [bad, good]; + select(gs, "s1"); + await gs.submitAll(); + expect(good.logCurrentAnswer).toHaveBeenCalledTimes(2); // s1 + leader + }); + + it("refuses a group larger than the size limit", async () => { + const gs = await makeGroupSub({ limit: 2 }); + const logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); + select(gs, "s1", "s2", "s3"); + await gs.submitAll(); + expect(alert).toHaveBeenCalledWith( + expect.stringContaining("You may not have more than 2"), + ); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); + +describe("pretext pages", () => { + it("scopes the button and picker to the component element", async () => { + document.body.classList.add("pretext"); + document.body.innerHTML = ` +
      +
      +
      + +
      +
      `; + const gs = new GroupSub({ + orig: document.getElementById("gs_ptx"), + }); + await gs.initialize(); + expect( + document.querySelector("#gs_ptx .groupsub_button button") + .textContent, + ).toBe("Submit Group"); + expect(gs.picker).toBe( + document.querySelector("#gs_ptx .assignment_partner_select"), + ); + }); +}); From dedb019255c9a0fc6bbf337fa9b0a6c83266e9ac Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 09:56:34 -0500 Subject: [PATCH 08/15] feat: remove jQuery from selectquestion component The toggle_current bookkeeping moves from jQuery's expando data store to data attributes on the selector element. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/selectquestion/js/selectone.js | 117 +++++----- .../selectquestion/test/selectone.test.js | 213 ++++++++++++++++++ .../test-support/stubs/renderComponent.js | 12 + 3 files changed, 291 insertions(+), 51 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/selectquestion/test/selectone.test.js diff --git a/bases/rsptx/interactives/runestone/selectquestion/js/selectone.js b/bases/rsptx/interactives/runestone/selectquestion/js/selectone.js index b6cd8a66e..1197e3104 100644 --- a/bases/rsptx/interactives/runestone/selectquestion/js/selectone.js +++ b/bases/rsptx/interactives/runestone/selectquestion/js/selectone.js @@ -8,6 +8,7 @@ import { createTimedComponent, } from "../../common/js/renderComponent.js"; import RunestoneBase from "../../common/js/runestonebase.js"; +import { getDataValue } from "../../common/js/domutil.js"; import "../css/selectquestion.css"; export default class SelectOne extends RunestoneBase { @@ -29,19 +30,19 @@ export default class SelectOne extends RunestoneBase { constructor(opts) { super(opts); this.origOpts = opts; - this.questions = $(opts.orig).data("questionlist"); - this.proficiency = $(opts.orig).data("proficiency"); - this.minDifficulty = $(opts.orig).data("minDifficulty"); - this.maxDifficulty = $(opts.orig).data("maxDifficulty"); - this.points = $(opts.orig).data("points"); - this.autogradable = $(opts.orig).data("autogradable"); - this.not_seen_ever = $(opts.orig).data("not_seen_ever"); - this.selector_id = $(opts.orig).first().attr("id"); - this.primaryOnly = $(opts.orig).data("primary"); - this.ABExperiment = $(opts.orig).data("ab"); - this.toggleOptions = $(opts.orig).data("toggleoptions"); - this.toggleLabels = $(opts.orig).data("togglelabels"); - this.limitBaseCourse = $(opts.orig).data("limit-basecourse"); + this.questions = getDataValue(opts.orig, "questionlist"); + this.proficiency = getDataValue(opts.orig, "proficiency"); + this.minDifficulty = getDataValue(opts.orig, "min-difficulty"); + this.maxDifficulty = getDataValue(opts.orig, "max-difficulty"); + this.points = getDataValue(opts.orig, "points"); + this.autogradable = getDataValue(opts.orig, "autogradable"); + this.not_seen_ever = getDataValue(opts.orig, "not_seen_ever"); + this.selector_id = opts.orig.getAttribute("id"); + this.primaryOnly = getDataValue(opts.orig, "primary"); + this.ABExperiment = getDataValue(opts.orig, "ab"); + this.toggleOptions = getDataValue(opts.orig, "toggleoptions"); + this.toggleLabels = getDataValue(opts.orig, "togglelabels"); + this.limitBaseCourse = getDataValue(opts.orig, "limit-basecourse"); opts.orig.id = this.selector_id; } /** @@ -234,7 +235,7 @@ export default class SelectOne extends RunestoneBase { useRunestoneServices: true, }); if (data.toggleOptions) { - $("#component-preview").hide(); + hideElement(document.getElementById("component-preview")); var toggleQuestionSelect = document.getElementById( selectorId + "-toggleQuestion", ); @@ -243,14 +244,11 @@ export default class SelectOne extends RunestoneBase { toggleQuestionSelect.options[i].value == toggleFirstID ) { toggleQuestionSelect.value = toggleFirstID; - $("#" + selectorId).data( - "toggle_current", - toggleFirstID, - ); - $("#" + selectorId).data( - "toggle_current_type", - toggleQuestionTypes[0], - ); + let selectorElement = + document.getElementById(selectorId); + selectorElement.dataset.toggleCurrent = toggleFirstID; + selectorElement.dataset.toggleCurrentType = + toggleQuestionTypes[0]; break; } } @@ -290,7 +288,8 @@ export default class SelectOne extends RunestoneBase { // on changing the value of toggle select dropdown, render selected question in preview panel, add appropriate buttons, then make preview panel visible async togglePreview(parentID, toggleOptions, toggleQuestionTypes) { - $("#toggle-buttons").html(""); + const toggleButtons = document.getElementById("toggle-buttons"); + toggleButtons.innerHTML = ""; var parentDiv = document.getElementById(parentID); var toggleQuestionSelect = parentDiv.getElementsByTagName("select")[0]; var selectedQuestion = @@ -306,16 +305,15 @@ export default class SelectOne extends RunestoneBase { // add "Close Preview" button to the preview panel let closeButton = document.createElement("button"); - $(closeButton).text("Close Preview"); - $(closeButton).addClass("btn btn-default"); + closeButton.textContent = "Close Preview"; + closeButton.classList.add("btn", "btn-default"); closeButton.addEventListener( "click", function () { - $("#toggle-preview").html(""); - toggleQuestionSelect.value = $("#" + parentID).data( - "toggle_current", - ); - $("#component-preview").hide(); + document.getElementById("toggle-preview").innerHTML = ""; + toggleQuestionSelect.value = + document.getElementById(parentID).dataset.toggleCurrent; + hideElement(document.getElementById("component-preview")); this.logBookEvent({ event: "close_toggle", act: toggleQuestionSelect.value, @@ -323,14 +321,15 @@ export default class SelectOne extends RunestoneBase { }); }.bind(this), ); - $("#toggle-buttons").append(closeButton); + toggleButtons.appendChild(closeButton); // if "lock" is not in toggle options, then allow adding more buttons to the preview panel if (!toggleOptions.includes("lock")) { let setButton = document.createElement("button"); - $(setButton).text("Select this Problem"); - $(setButton).addClass("btn btn-primary"); - $(setButton).click( + setButton.textContent = "Select this Problem"; + setButton.classList.add("btn", "btn-primary"); + setButton.addEventListener( + "click", async function () { await this.toggleSet( parentID, @@ -338,7 +337,7 @@ export default class SelectOne extends RunestoneBase { htmlsrc, toggleQuestionTypes, ); - $("#component-preview").hide(); + hideElement(document.getElementById("component-preview")); this.logBookEvent({ event: "select_toggle", act: selectedQuestion, @@ -346,11 +345,13 @@ export default class SelectOne extends RunestoneBase { }); }.bind(this), ); - $("#toggle-buttons").append(setButton); + toggleButtons.appendChild(setButton); // if "transfer" in toggle options, and if current question type is Parsons and selected question type is active code, then add "Transfer" button to preview panel if (toggleOptions.includes("transfer")) { - var currentType = $("#" + parentID).data("toggle_current_type"); + var currentType = + document.getElementById(parentID).dataset + .toggleCurrentType; var selectedType = toggleQuestionTypes[toggleQuestionSelect.selectedIndex]; if ( @@ -358,9 +359,10 @@ export default class SelectOne extends RunestoneBase { selectedType == "Active Write Code" ) { let transferButton = document.createElement("button"); - $(transferButton).text("Transfer Response"); - $(transferButton).addClass("btn btn-primary"); - $(transferButton).click( + transferButton.textContent = "Transfer Response"; + transferButton.classList.add("btn", "btn-primary"); + transferButton.addEventListener( + "click", async function () { await this.toggleTransfer( parentID, @@ -370,12 +372,12 @@ export default class SelectOne extends RunestoneBase { ); }.bind(this), ); - $("#toggle-buttons").append(transferButton); + toggleButtons.appendChild(transferButton); } } } - $("#component-preview").show(); + showElement(document.getElementById("component-preview")); } // on clicking "Select this Problem" button, close preview panel, replace current question in assignments page with selected question, and send request to update grading database @@ -397,12 +399,11 @@ export default class SelectOne extends RunestoneBase { {}, ); await fetch(request); - $("#toggle-preview").html(""); - $("#" + parentID).data("toggle_current", selectedQuestion); - $("#" + parentID).data( - "toggle_current_type", - toggleQuestionTypes[toggleQuestionSelect.selectedIndex], - ); + document.getElementById("toggle-preview").innerHTML = ""; + let parentDiv = document.getElementById(parentID); + parentDiv.dataset.toggleCurrent = selectedQuestion; + parentDiv.dataset.toggleCurrentType = + toggleQuestionTypes[toggleQuestionSelect.selectedIndex]; } // on clicking "Transfer" button, extract the current text and indentation of the Parsons blocks in the answer space, then paste that into the selected active code question @@ -505,7 +506,21 @@ export default class SelectOne extends RunestoneBase { htmlsrc, toggleQuestionTypes, ); - $("#component-preview").hide(); + hideElement(document.getElementById("component-preview")); + } +} + +// The preview panel is shared by all toggle questions on a page and may not +// exist yet when hidden/shown. +function hideElement(element) { + if (element) { + element.style.display = "none"; + } +} + +function showElement(element) { + if (element) { + element.style.display = ""; } } @@ -521,13 +536,13 @@ window.component_factory.selectquestion = function (opts) { * When the page is loaded and the login checks are complete find and render * each selectquestion component that is not part of a timedAssessment. **/ -$(document).on("runestone:login-complete", async function () { +document.addEventListener("runestone:login-complete", async function () { let selQuestions = document.querySelectorAll( "[data-component=selectquestion]", ); for (let cq of selQuestions) { try { - if ($(cq).closest("[data-component=timedAssessment]").length == 0) { + if (!cq.closest("[data-component=timedAssessment]")) { // If this element exists within a timed component, don't render it here let tmp = new SelectOne({ orig: cq }); await tmp.initialize(); diff --git a/bases/rsptx/interactives/runestone/selectquestion/test/selectone.test.js b/bases/rsptx/interactives/runestone/selectquestion/test/selectone.test.js new file mode 100644 index 000000000..b3f255662 --- /dev/null +++ b/bases/rsptx/interactives/runestone/selectquestion/test/selectone.test.js @@ -0,0 +1,213 @@ +// Characterization tests for the SelectOne (selectquestion) component. These +// were first written against the jQuery implementation and now guard the +// jQuery-free version. renderComponent.js is replaced by a stub (see +// vitest.config.js), so rendering means "html lands in the target div". +// Note: deliberately NO jquery-globals import here -- selectquestion must +// work without jQuery. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import SelectOne from "../js/selectone.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +const MC_SRC = + '

      Pick one.

      '; + +function makeFixture({ id = "test_selq_1", attrs = "" } = {}) { + document.body.innerHTML = ` +
      +
      +
      `; + return document.getElementById(id); +} + +// Node's Request rejects relative URLs; fetch is mocked, so a capturing +// stand-in suffices. +class FakeRequest { + constructor(url, opts = {}) { + this.url = url; + Object.assign(this, opts); + } +} + +// Route the component's fetches: get_question_source POSTs resolve with +// `source`, htmlsrc?acid= GETs resolve from `toggleSrcs`. +function stubFetch({ source = MC_SRC, toggleSrcs = {} } = {}) { + const fetchMock = vi.fn(async (request) => { + let detail = source; + const m = request.url.match(/htmlsrc\?acid=([\w-]+)/); + if (m) { + detail = toggleSrcs[m[1]]; + } + return { ok: true, json: async () => ({ detail }) }; + }); + vi.stubGlobal("Request", FakeRequest); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); + vi.stubGlobal("alert", vi.fn()); +}); + +describe("constructor", () => { + it("reads its configuration from data attributes with jQuery-style coercion", () => { + const orig = makeFixture({ + attrs: `data-questionlist="q_a, q_b" data-points="3" + data-proficiency="loops" data-min-difficulty="2" + data-max-difficulty="4" data-autogradable="true" + data-not_seen_ever="true" data-primary="true" + data-ab="mainq" data-limit-basecourse="true"`, + }); + const so = new SelectOne({ orig }); + expect(so.selector_id).toBe("test_selq_1"); + expect(so.questions).toBe("q_a, q_b"); + expect(so.points).toBe(3); + expect(so.proficiency).toBe("loops"); + expect(so.minDifficulty).toBe(2); + expect(so.maxDifficulty).toBe(4); + expect(so.autogradable).toBe(true); + expect(so.not_seen_ever).toBe(true); + expect(so.primaryOnly).toBe(true); + expect(so.ABExperiment).toBe("mainq"); + expect(so.limitBaseCourse).toBe(true); + }); +}); + +describe("initialize", () => { + it("asks the server for a question and renders it in place", async () => { + const fetchMock = stubFetch(); + const orig = makeFixture({ + attrs: 'data-questionlist="q_a, q_b" data-points="3"', + }); + const so = new SelectOne({ orig }); + await so.initialize(); + const body = JSON.parse(fetchMock.mock.calls[0][0].body); + expect(fetchMock.mock.calls[0][0].url).toContain( + "/assessment/get_question_source", + ); + expect(body.selector_id).toBe("test_selq_1"); + expect(body.questions).toBe("q_a, q_b"); + expect(body.points).toBe(3); + expect( + document.querySelector("#test_selq_1 #real_q_1"), + ).not.toBe(null); + }); + + it("alerts and throws when the server has no matching question", async () => { + stubFetch({ source: "No preview available for this question" }); + const orig = makeFixture(); + const so = new SelectOne({ orig }); + await expect(so.initialize()).rejects.toThrow( + "Unable to find a question", + ); + expect(alert).toHaveBeenCalledWith( + expect.stringContaining("test_selq_1"), + ); + }); + + it("defers rendering to createTimedComponent inside a timed exam", async () => { + stubFetch(); + const orig = makeFixture({ attrs: 'data-questionlist="q_a"' }); + const so = new SelectOne({ orig, timed: true, assessmentTaken: false }); + const rqaEntry = { question: so }; + so.origOpts.rqa = [rqaEntry]; + await so.initialize(); + // the placeholder in the timed assessment's question list is replaced + expect(rqaEntry.question).not.toBe(so); + expect(rqaEntry.question.htmlsrc).toBe(MC_SRC); + expect(so.realComponent).toBe(rqaEntry.question); + expect(so.realComponent.selectorId).toBe("test_selq_1"); + expect(so.containerDiv).toBe(rqaEntry.question.containerDiv); + }); +}); + +describe("page wiring", () => { + it("initializes selectquestions at login-complete, skipping timed ones", async () => { + stubFetch(); + document.body.innerHTML = ` +
      +
      +
      +
      +
      +
      `; + document.dispatchEvent(new CustomEvent("runestone:login-complete")); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(document.querySelector("#selq_free #real_q_1")).not.toBe(null); + expect( + document.querySelector("#selq_timed #real_q_1"), + ).toBe(null); + }); +}); + +describe("toggle questions (assignment page)", () => { + const PARSONS_SRC = + '

      Arrange.

      '; + + function makeToggleFixture() { + stubFetch({ + source: '
      ', + toggleSrcs: { + q_a: '
      ', + q_b: PARSONS_SRC, + }, + }); + return makeFixture({ + attrs: 'data-questionlist="q_a, q_b" data-toggleoptions="toggle"', + }); + } + + it("builds the toggle dropdown and hidden preview panel", async () => { + const orig = makeToggleFixture(); + const so = new SelectOne({ orig }); + await so.initialize(); + const select = document.getElementById( + "test_selq_1-toggleQuestion", + ); + const options = [...select.options]; + expect(options.map((o) => o.value)).toEqual(["q_a", "q_b"]); + expect(options[0].textContent).toContain("Multiple Choice - q_a"); + expect(options[1].textContent).toContain("Parsons Mixed-Up Code - q_b"); + expect(select.value).toBe("q_a"); + const preview = document.getElementById("component-preview"); + expect(preview.style.display).toBe("none"); + expect( + document.getElementById("test_selq_1-toggleSelectedQuestion"), + ).not.toBe(null); + }); + + it("previews the chosen question and can close the preview", async () => { + const orig = makeToggleFixture(); + const so = new SelectOne({ orig }); + vi.spyOn(RunestoneBase.prototype, "logBookEvent").mockResolvedValue( + undefined, + ); + await so.initialize(); + const select = document.getElementById("test_selq_1-toggleQuestion"); + select.value = "q_b"; + select.dispatchEvent(new Event("change")); + await new Promise((resolve) => setTimeout(resolve, 20)); + const preview = document.getElementById("component-preview"); + expect(preview.style.display).not.toBe("none"); + expect( + document.querySelector("#toggle-preview [data-component=parsons]"), + ).not.toBe(null); + const buttons = [ + ...document.querySelectorAll("#toggle-buttons button"), + ]; + expect(buttons.map((b) => b.textContent)).toEqual([ + "Close Preview", + "Select this Problem", + ]); + buttons[0].click(); + expect(preview.style.display).toBe("none"); + expect(document.getElementById("toggle-preview").innerHTML).toBe(""); + expect(select.value).toBe("q_a"); + }); +}); diff --git a/bases/rsptx/interactives/test-support/stubs/renderComponent.js b/bases/rsptx/interactives/test-support/stubs/renderComponent.js index 0ae6736c3..8912131ad 100644 --- a/bases/rsptx/interactives/test-support/stubs/renderComponent.js +++ b/bases/rsptx/interactives/test-support/stubs/renderComponent.js @@ -8,3 +8,15 @@ export async function renderRunestoneComponent(html, divid) { } export async function renderOneComponent() {} + +// Stand-in for the timed-assessment rendering path: hand back an inspectable +// fake question instead of instantiating a real component. +export function createTimedComponent(htmlsrc, opts) { + return { + question: { + containerDiv: document.createElement("div"), + htmlsrc, + opts, + }, + }; +} From 250b0879e74fc49dbae365e6b186209b88a4a068 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 10:11:44 -0500 Subject: [PATCH 09/15] feat: remove jQuery from timed assessment component The post-exam autograder call moves from jQuery.ajax to fetch (extracted as submitAutograde so it is testable), and the beforeunload guard is now a tracked native listener. The vendored pytutor bundle is stubbed in vitest config: it is sloppy-mode code that Vite's strict ESM transform rejects, and timed.js pulls it in via activecode/livecode. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../interactives/runestone/timed/js/timed.js | 451 +++++++++--------- .../runestone/timed/test/timed.test.js | 302 ++++++++++++ .../interactives/test-support/stubs/empty.js | 2 + bases/rsptx/interactives/vitest.config.js | 10 + 4 files changed, 535 insertions(+), 230 deletions(-) create mode 100644 bases/rsptx/interactives/runestone/timed/test/timed.test.js create mode 100644 bases/rsptx/interactives/test-support/stubs/empty.js diff --git a/bases/rsptx/interactives/runestone/timed/js/timed.js b/bases/rsptx/interactives/runestone/timed/js/timed.js index f7428e67e..b14a962b7 100644 --- a/bases/rsptx/interactives/runestone/timed/js/timed.js +++ b/bases/rsptx/interactives/runestone/timed/js/timed.js @@ -20,6 +20,7 @@ import TimedDragNDrop from "../../dragndrop/js/timeddnd.js"; import TimedParsons from "../../parsons/js/timedparsons.js"; import TimedMatching from "../../matching/js/timed_matching.js"; import SelectOne from "../../selectquestion/js/selectone"; +import { getDataValue } from "../../common/js/domutil.js"; import "../css/timed.less"; // Timed constructor @@ -33,8 +34,9 @@ export default class Timed extends RunestoneBase { this.visited = []; this.timeLimit = 0; this.limitedTime = false; - if (!isNaN($(this.origElem).data("time"))) { - this.timeLimit = parseInt($(this.origElem).data("time"), 10) * 60; // time in seconds to complete the exam + if (!isNaN(getDataValue(this.origElem, "time"))) { + this.timeLimit = + parseInt(getDataValue(this.origElem, "time"), 10) * 60; // time in seconds to complete the exam this.startingTime = this.timeLimit; this.limitedTime = true; } @@ -178,7 +180,7 @@ export default class Timed extends RunestoneBase { this.renderFeedbackContainer(); this.useRunestoneServices = eBookConfig.useRunestoneServices; // Replace intermediate HTML with rendered HTML - $(this.origElem).replaceWith(this.containerDiv); + this.origElem.replaceWith(this.containerDiv); // check if already taken and if so show results this.styleExamElements(); // rename to renderPossibleResults this.checkServer("timedExam", true); @@ -200,21 +202,15 @@ export default class Timed extends RunestoneBase { this.containerDiv = document.createElement("div"); // container for the entire Timed Component if (this.fullwidth) { // allow the container to fill the width - barb - $(this.containerDiv).attr({ - style: "max-width:none", - }); + this.containerDiv.style.maxWidth = "none"; } this.containerDiv.classList.add("runestone-sphinx"); this.containerDiv.id = this.divid; this.timedDiv = document.createElement("div"); // div that will hold the questions for the timed assessment this.navDiv = document.createElement("div"); // For navigation control - $(this.navDiv).attr({ - style: "text-align:center", - }); + this.navDiv.style.textAlign = "center"; this.flagDiv = document.createElement("div"); // div that will hold the "Flag Question" button - $(this.flagDiv).attr({ - style: "text-align: center", - }); + this.flagDiv.style.textAlign = "center"; this.switchContainer = document.createElement("div"); this.switchContainer.classList.add("switchcontainer"); this.switchDiv = document.createElement("div"); // is replaced by the questions @@ -222,10 +218,8 @@ export default class Timed extends RunestoneBase { this.timedDiv.appendChild(this.flagDiv); // add flagDiv to timedDiv, which holds components for navigation and questions for timed assessment this.timedDiv.appendChild(this.switchContainer); this.switchContainer.appendChild(this.switchDiv); - $(this.timedDiv).attr({ - id: "timed_Test", - style: "display:none", - }); + this.timedDiv.id = "timed_Test"; + this.timedDiv.style.display = "none"; } renderTimer() { @@ -239,24 +233,22 @@ export default class Timed extends RunestoneBase { renderControlButtons() { this.controlDiv = document.createElement("div"); - $(this.controlDiv).attr({ - id: "controls", - style: "text-align: center", - }); + this.controlDiv.id = "controls"; + this.controlDiv.style.textAlign = "center"; this.startBtn = document.createElement("button"); this.pauseBtn = document.createElement("button"); - $(this.startBtn).attr({ - class: "btn btn-success", - id: "start", - tabindex: "0", - role: "button", - }); + this.startBtn.setAttribute("class", "btn btn-success"); + this.startBtn.id = "start"; + this.startBtn.tabIndex = 0; + this.startBtn.setAttribute("role", "button"); this.startBtn.textContent = "Start"; this.startBtn.addEventListener( "click", async function () { - $(this.finishButton).hide(); // hide the finish button for now - $(this.flagButton).show(); + this.finishButton.style.display = "none"; // hide the finish button for now + if (this.flagButton) { + this.flagButton.style.display = ""; + } let mess = document.createElement("p"); mess.innerHTML = "Warning: You will not be able to continue the exam if you close this tab, close the window, or navigate away from this page! Make sure you click the Finish Exam button when you are done to submit your work!"; @@ -267,13 +259,11 @@ export default class Timed extends RunestoneBase { }.bind(this), false, ); - $(this.pauseBtn).attr({ - class: "btn btn-default", - id: "pause", - disabled: "true", - tabindex: "0", - role: "button", - }); + this.pauseBtn.setAttribute("class", "btn btn-default"); + this.pauseBtn.id = "pause"; + this.pauseBtn.disabled = true; + this.pauseBtn.tabIndex = 0; + this.pauseBtn.setAttribute("role", "button"); this.pauseBtn.textContent = "Pause"; this.pauseBtn.addEventListener( "click", @@ -295,40 +285,39 @@ export default class Timed extends RunestoneBase { renderNavControls() { // making "Prev" button this.pagNavList = document.createElement("ul"); - $(this.pagNavList).addClass("pagination"); + this.pagNavList.classList.add("pagination"); this.leftContainer = document.createElement("li"); this.leftNavButton = document.createElement("button"); this.leftNavButton.innerHTML = "‹ Prev"; - $(this.leftNavButton).attr("aria-label", "Previous"); - $(this.leftNavButton).attr("tabindex", "0"); - $(this.leftNavButton).attr("role", "button"); - $(this.rightNavButton).attr("id", "prev"); - $(this.leftNavButton).css("cursor", "pointer"); + this.leftNavButton.setAttribute("aria-label", "Previous"); + this.leftNavButton.tabIndex = 0; + this.leftNavButton.setAttribute("role", "button"); + this.leftNavButton.style.cursor = "pointer"; this.leftContainer.appendChild(this.leftNavButton); this.pagNavList.appendChild(this.leftContainer); // making "Flag Question" button this.flaggingPlace = document.createElement("ul"); - $(this.flaggingPlace).addClass("pagination"); + this.flaggingPlace.classList.add("pagination"); this.flagContainer = document.createElement("li"); this.flagButton = document.createElement("button"); - $(this.flagButton).addClass("flagBtn"); + this.flagButton.classList.add("flagBtn"); this.flagButton.innerHTML = "Flag Question"; // name on button - $(this.flagButton).attr("aria-labelledby", "Flag"); - $(this.flagButton).attr("tabindex", "5"); - $(this.flagButton).attr("role", "button"); - $(this.flagButton).attr("id", "flag"); - $(this.flagButton).css("cursor", "pointer"); + this.flagButton.setAttribute("aria-labelledby", "Flag"); + this.flagButton.tabIndex = 5; + this.flagButton.setAttribute("role", "button"); + this.flagButton.id = "flag"; + this.flagButton.style.cursor = "pointer"; this.flagContainer.appendChild(this.flagButton); // adding button to container this.flaggingPlace.appendChild(this.flagContainer); // adding container to flaggingPlace // making "Next" button this.rightContainer = document.createElement("li"); this.rightNavButton = document.createElement("button"); - $(this.rightNavButton).attr("aria-label", "Next"); - $(this.rightNavButton).attr("tabindex", "0"); - $(this.rightNavButton).attr("role", "button"); - $(this.rightNavButton).attr("id", "next"); + this.rightNavButton.setAttribute("aria-label", "Next"); + this.rightNavButton.tabIndex = 0; + this.rightNavButton.setAttribute("role", "button"); + this.rightNavButton.id = "next"; this.rightNavButton.innerHTML = "Next ›"; - $(this.rightNavButton).css("cursor", "pointer"); + this.rightNavButton.style.cursor = "pointer"; this.rightContainer.appendChild(this.rightNavButton); this.pagNavList.appendChild(this.rightContainer); this.ensureButtonSafety(); @@ -338,17 +327,17 @@ export default class Timed extends RunestoneBase { this.navDiv.appendChild(this.break); // render the question number jump buttons this.qNumList = document.createElement("ul"); - $(this.qNumList).attr("id", "pageNums"); + this.qNumList.id = "pageNums"; this.qNumWrapperList = document.createElement("ul"); - $(this.qNumWrapperList).addClass("pagination"); + this.qNumWrapperList.classList.add("pagination"); var tmpLi, tmpA; for (var i = 0; i < this.renderedQuestionArray.length; i++) { tmpLi = document.createElement("li"); tmpA = document.createElement("a"); tmpA.innerHTML = i + 1; - $(tmpA).css("cursor", "pointer"); + tmpA.style.cursor = "pointer"; if (i === 0) { - $(tmpLi).addClass("active"); + tmpLi.classList.add("active"); } tmpLi.appendChild(tmpA); this.qNumWrapperList.appendChild(tmpLi); @@ -357,7 +346,12 @@ export default class Timed extends RunestoneBase { this.navDiv.appendChild(this.qNumList); this.navBtnListeners(); this.flagBtnListener(); // listens for click on flag button - $(this.flagButton).hide(); + this.flagButton.style.display = "none"; + } + + // The li for question number `index` in the numbered navigation strip. + qNumItem(index) { + return document.querySelectorAll("ul#pageNums > ul > li")[index]; } // when moving off of a question in an active exam: @@ -368,25 +362,21 @@ export default class Timed extends RunestoneBase { this.renderedQuestionArray[this.currentQuestionIndex].state == "broken_exam" ) { - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).addClass("broken"); + this.qNumItem(this.currentQuestionIndex)?.classList.add("broken"); } if ( this.renderedQuestionArray[this.currentQuestionIndex].state == "exam_ended" ) { - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).addClass("toolate"); + this.qNumItem(this.currentQuestionIndex)?.classList.add("toolate"); } if ( this.renderedQuestionArray[this.currentQuestionIndex].question .isAnswered ) { - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).addClass("answered"); + this.qNumItem(this.currentQuestionIndex)?.classList.add( + "answered", + ); await this.renderedQuestionArray[ this.currentQuestionIndex ].question.checkCurrentAnswer(); @@ -401,16 +391,16 @@ export default class Timed extends RunestoneBase { if (!this.taken) { await this.navigateAway(); } - var target = $(event.target).text(); + var target = event.target.textContent; if (target.match(/Next/)) { // checks given text to match "Next" - if ($(this.rightContainer).hasClass("disabled")) { + if (this.rightContainer.classList.contains("disabled")) { return; } this.currentQuestionIndex++; } else if (target.match(/Prev/)) { // checks given text to match "Prev" - if ($(this.leftContainer).hasClass("disabled")) { + if (this.leftContainer.classList.contains("disabled")) { return; } this.currentQuestionIndex--; @@ -423,19 +413,14 @@ export default class Timed extends RunestoneBase { j < this.qNumList.childNodes[i].childNodes.length; j++ ) { - $(this.qNumList.childNodes[i].childNodes[j]).removeClass( + this.qNumList.childNodes[i].childNodes[j].classList.remove( "active", ); } } - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).addClass("active"); - if ( - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).hasClass("flagcolor") - ) { + let currentItem = this.qNumItem(this.currentQuestionIndex); + currentItem?.classList.add("active"); + if (currentItem?.classList.contains("flagcolor")) { // checking for class this.flagButton.innerHTML = "Unflag Question"; // changes text on button } else { @@ -445,16 +430,18 @@ export default class Timed extends RunestoneBase { async handleFlag(event) { // called when flag button is clicked - var target = $(event.target).text(); + var target = event.target.textContent; if (target.match(/Flag Question/)) { - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).addClass("flagcolor"); // class will change color of question block + // class will change color of question block + this.qNumItem(this.currentQuestionIndex)?.classList.add( + "flagcolor", + ); this.flagButton.innerHTML = "Unflag Question"; } else { - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).removeClass("flagcolor"); // will restore current color of question block + // will restore current color of question block + this.qNumItem(this.currentQuestionIndex)?.classList.remove( + "flagcolor", + ); this.flagButton.innerHTML = "Flag Question"; // also sets name back } } @@ -469,27 +456,22 @@ export default class Timed extends RunestoneBase { j < this.qNumList.childNodes[i].childNodes.length; j++ ) { - $(this.qNumList.childNodes[i].childNodes[j]).removeClass( + this.qNumList.childNodes[i].childNodes[j].classList.remove( "active", ); } } - var target = $(event.target).text(); + var target = event.target.textContent; let oldIndex = this.currentQuestionIndex; this.currentQuestionIndex = parseInt(target) - 1; if (this.currentQuestionIndex > this.renderedQuestionArray.length) { console.log(`Error: bad index for ${target}`); this.currentQuestionIndex = oldIndex; } - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", - ).addClass("active"); - if ( - $( - "ul#pageNums > ul > li:eq(" + this.currentQuestionIndex + ")", // checking for flagcolor class - ).hasClass("flagcolor") - ) { + let currentItem = this.qNumItem(this.currentQuestionIndex); + currentItem?.classList.add("active"); + if (currentItem?.classList.contains("flagcolor")) { this.flagButton.innerHTML = "Unflag Question"; } else { this.flagButton.innerHTML = "Flag Question"; @@ -526,14 +508,10 @@ export default class Timed extends RunestoneBase { renderSubmitButton() { this.buttonContainer = document.createElement("div"); - $(this.buttonContainer).attr({ - style: "text-align:center", - }); + this.buttonContainer.style.textAlign = "center"; this.finishButton = document.createElement("button"); - $(this.finishButton).attr({ - id: "finish", - class: "btn btn-primary", - }); + this.finishButton.id = "finish"; + this.finishButton.setAttribute("class", "btn btn-primary"); this.finishButton.textContent = "Finish Exam"; this.finishButton.addEventListener( "click", @@ -551,37 +529,37 @@ export default class Timed extends RunestoneBase { ) ) { await this.finishAssessment(); - $(this.flagButton).hide(); + this.flagButton.style.display = "none"; } }.bind(this), false, ); this.controlDiv.appendChild(this.finishButton); - $(this.finishButton).hide(); + this.finishButton.style.display = "none"; this.timedDiv.appendChild(this.buttonContainer); } ensureButtonSafety() { if (this.currentQuestionIndex === 0) { if (this.renderedQuestionArray.length != 1) { - $(this.rightContainer).removeClass("disabled"); + this.rightContainer.classList.remove("disabled"); } - $(this.leftContainer).addClass("disabled"); + this.leftContainer.classList.add("disabled"); } if ( this.currentQuestionIndex >= this.renderedQuestionArray.length - 1 ) { if (this.renderedQuestionArray.length != 1) { - $(this.leftContainer).removeClass("disabled"); + this.leftContainer.classList.remove("disabled"); } - $(this.rightContainer).addClass("disabled"); + this.rightContainer.classList.add("disabled"); } if ( this.currentQuestionIndex > 0 && this.currentQuestionIndex < this.renderedQuestionArray.length - 1 ) { - $(this.rightContainer).removeClass("disabled"); - $(this.leftContainer).removeClass("disabled"); + this.rightContainer.classList.remove("disabled"); + this.leftContainer.classList.remove("disabled"); } } renderFeedbackContainer() { @@ -621,11 +599,14 @@ export default class Timed extends RunestoneBase { timedWrapper: this.divid, initAttempts: 0, }; - if ($(tmpChild).children("[data-component]").length > 0) { - tmpChild = $(tmpChild).children("[data-component]")[0]; + let componentChild = tmpChild.querySelector( + ":scope > [data-component]", + ); + if (componentChild) { + tmpChild = componentChild; opts.orig = tmpChild; } - if ($(tmpChild).is("[data-component]")) { + if (tmpChild.matches("[data-component]")) { this.renderedQuestionArray.push(opts); } } @@ -664,7 +645,7 @@ export default class Timed extends RunestoneBase { (opts.state === "broken_exam" && opts.initAttempts < 3) ) { let tmpChild = opts.orig; - if ($(tmpChild).is("[data-component=selectquestion]")) { + if (tmpChild.matches("[data-component=selectquestion]")) { if (this.done && opts.state == "prepared") { this.renderedQuestionArray[ this.currentQuestionIndex @@ -681,9 +662,9 @@ export default class Timed extends RunestoneBase { await newq.initialize(); if (opts.state == "broken_exam") { // remove the broken class from this question if we get here. - $( - `ul#pageNums > ul > li:eq(${this.currentQuestionIndex})`, - ).removeClass("broken"); + this.qNumItem( + this.currentQuestionIndex, + )?.classList.remove("broken"); } } catch (e) { opts.state = "broken_exam"; @@ -694,8 +675,8 @@ export default class Timed extends RunestoneBase { ); } } - } else if ($(tmpChild).is("[data-component]")) { - let componentKind = $(tmpChild).data("component"); + } else if (tmpChild.matches("[data-component]")) { + let componentKind = tmpChild.dataset.component; this.renderedQuestionArray[this.currentQuestionIndex] = { question: window.component_factory[componentKind](opts), state: opts.state, @@ -720,7 +701,7 @@ export default class Timed extends RunestoneBase { this.visited.length === this.renderedQuestionArray.length && !this.done ) { - $(this.finishButton).show(); + this.finishButton.style.display = ""; } } @@ -731,7 +712,7 @@ export default class Timed extends RunestoneBase { ) { currentQuestion.containerDiv.classList.add("runestone"); } - $(this.switchDiv).replaceWith(currentQuestion.containerDiv); + this.switchDiv.replaceWith(currentQuestion.containerDiv); this.switchDiv = currentQuestion.containerDiv; } @@ -746,31 +727,31 @@ export default class Timed extends RunestoneBase { === Timer and control Functions === =================================*/ handlePrevAssessment() { - $(this.startBtn).hide(); - $(this.pauseBtn).attr("disabled", true); - $(this.finishButton).attr("disabled", true); + this.startBtn.style.display = "none"; + this.pauseBtn.disabled = true; + this.finishButton.disabled = true; this.running = 0; this.done = 1; // showFeedback sand showResults should both be true before we show the // questions and their state of correctness. if (this.showResults && this.showFeedback) { - $(this.timedDiv).show(); + this.timedDiv.style.display = ""; this.restoreAnsweredQuestions(); // do not log these results } else { - $(this.pauseBtn).hide(); - $(this.timerContainer).hide(); + this.pauseBtn.style.display = "none"; + this.timerContainer.style.display = "none"; } } startAssessment() { if (!this.taken) { - $("#relations-next").hide(); // hide the next page button for now - $("#relations-prev").hide(); // hide the previous button for now - $(this.startBtn).hide(); - $(this.pauseBtn).attr("disabled", false); + hideElement(document.getElementById("relations-next")); // hide the next page button for now + hideElement(document.getElementById("relations-prev")); // hide the previous button for now + this.startBtn.style.display = "none"; + this.pauseBtn.disabled = false; if (this.running === 0 && this.paused === 0) { this.running = 1; this.lastTime = Date.now(); - $(this.timedDiv).show(); + this.timedDiv.style.display = ""; this.increment(); this.logBookEvent({ event: "timedExam", @@ -787,19 +768,17 @@ export default class Timed extends RunestoneBase { JSON.stringify(storageObj), ); } - $(window).on( - "beforeunload", - function (event) { - // this actual value gets ignored by newer browsers - if (this.done) { - return; - } - event.preventDefault(); - event.returnValue = - "Are you sure you want to leave? Your work will be lost! And you will need your instructor to reset the exam!"; - return "Are you sure you want to leave? Your work will be lost!"; - }.bind(this), - ); + this.beforeUnloadHandler = function (event) { + // this actual value gets ignored by newer browsers + if (this.done) { + return; + } + event.preventDefault(); + event.returnValue = + "Are you sure you want to leave? Your work will be lost! And you will need your instructor to reset the exam!"; + return "Are you sure you want to leave? Your work will be lost!"; + }.bind(this); + window.addEventListener("beforeunload", this.beforeUnloadHandler); window.addEventListener( "pagehide", async function (event) { @@ -824,7 +803,7 @@ export default class Timed extends RunestoneBase { this.running = 0; this.paused = 1; this.pauseBtn.innerHTML = "Resume"; - $(this.timedDiv).hide(); + this.timedDiv.style.display = "none"; } else { this.logBookEvent({ event: "timedExam", @@ -835,7 +814,7 @@ export default class Timed extends RunestoneBase { this.paused = 0; this.increment(); this.pauseBtn.innerHTML = "Pause"; - $(this.timedDiv).show(); + this.timedDiv.style.display = ""; } } } @@ -874,7 +853,7 @@ export default class Timed extends RunestoneBase { timeTips[i].title = timeString; } } else { - $(this.timerContainer).hide(); + this.timerContainer.style.display = "none"; } } @@ -910,12 +889,8 @@ export default class Timed extends RunestoneBase { this.increment(); // ran out of time } else { - $(this.startBtn).attr({ - disabled: "true", - }); - $(this.finishButton).attr({ - disabled: "true", - }); + this.startBtn.disabled = true; + this.finishButton.disabled = true; this.running = 0; this.done = 1; if (!this.taken) { @@ -939,38 +914,34 @@ export default class Timed extends RunestoneBase { styleExamElements() { // Checks if this exam has been taken before - $(this.timerContainer).css({ + const summaryStyle = { width: "50%", margin: "0 auto", - "background-color": "#DFF0D8", - "text-align": "center", + backgroundColor: "#DFF0D8", + textAlign: "center", border: "2px solid #DFF0D8", - "border-radius": "25px", - }); - $(this.scoreDiv).css({ - width: "50%", - margin: "0 auto", - "background-color": "#DFF0D8", - "text-align": "center", - border: "2px solid #DFF0D8", - "border-radius": "25px", - }); - $(".tooltipTime").css({ - margin: "0", - padding: "0", - "background-color": "black", - color: "white", - }); + borderRadius: "25px", + }; + Object.assign(this.timerContainer.style, summaryStyle); + Object.assign(this.scoreDiv.style, summaryStyle); + for (const tip of document.querySelectorAll(".tooltipTime")) { + Object.assign(tip.style, { + margin: "0", + padding: "0", + backgroundColor: "black", + color: "white", + }); + } } async finishAssessment() { - $("#relations-next").show(); // show the next page button for now - $("#relations-prev").show(); // show the previous button for now + showElement(document.getElementById("relations-next")); // show the next page button for now + showElement(document.getElementById("relations-prev")); // show the previous button for now if (!this.showFeedback) { // bje - changed from showResults - $(this.timedDiv).hide(); - $(this.pauseBtn).hide(); - $(this.timerContainer).hide(); + this.timedDiv.style.display = "none"; + this.pauseBtn.style.display = "none"; + this.timerContainer.style.display = "none"; } this.findTimeTaken(); this.running = 0; @@ -981,31 +952,43 @@ export default class Timed extends RunestoneBase { this.displayScore(); this.storeScore(); this.logScore(); - $(this.pauseBtn).attr("disabled", true); + this.pauseBtn.disabled = true; this.finishButton.disabled = true; - $(window).off("beforeunload"); + if (this.beforeUnloadHandler) { + window.removeEventListener( + "beforeunload", + this.beforeUnloadHandler, + ); + } // turn off the pagehide listener - let assignment_id = this.divid; - setTimeout(function () { - jQuery.ajax({ - url: eBookConfig.app + "/assignments/student_autograde", - type: "POST", - dataType: "JSON", - data: { - assignment_id: assignment_id, - is_timed: true, - }, - success: function (retdata) { - if (retdata.success == false) { - console.log(retdata.message); - } else { - console.log("Autograder completed"); - } - }, - }); + setTimeout(() => { + this.submitAutograde(); }, 2000); } + async submitAutograde() { + try { + let response = await fetch( + eBookConfig.app + "/assignments/student_autograde", + { + method: "POST", + body: new URLSearchParams({ + assignment_id: this.divid, + is_timed: true, + }), + }, + ); + let retdata = await response.json(); + if (retdata.success == false) { + console.log(retdata.message); + } else { + console.log("Autograder completed"); + } + } catch (err) { + console.log(`Error submitting exam for autograding: ${err}`); + } + } + // finalizeProblems // ---------------- async finalizeProblems() { @@ -1296,7 +1279,7 @@ export default class Timed extends RunestoneBase { percentCorrect = (this.score / numQuestions) * 100; scoreString += "Percent Correct: " + percentCorrect.toFixed(2) + "%"; - $(this.scoreDiv).html(scoreString); + this.scoreDiv.innerHTML = scoreString; this.scoreDiv.style.display = "block"; } else { scoreString = `Num Correct: ${this.score}
      Num Wrong: ${this.incorrect}
      Num Skipped: ${this.skipped}
      `; @@ -1304,14 +1287,13 @@ export default class Timed extends RunestoneBase { percentCorrect = (this.score / numQuestions) * 100; scoreString += "Percent Correct: " + percentCorrect.toFixed(2) + "%"; - $(this.scoreDiv).html(scoreString); + this.scoreDiv.innerHTML = scoreString; this.scoreDiv.style.display = "block"; } this.highlightNumberedList(); } else { - $(this.scoreDiv).html( - "Thank you for taking the exam. Your answers have been recorded.", - ); + this.scoreDiv.innerHTML = + "Thank you for taking the exam. Your answers have been recorded."; this.scoreDiv.style.display = "block"; } } @@ -1322,39 +1304,48 @@ export default class Timed extends RunestoneBase { correctCount = correctCount.replace(/ /g, "").split(","); incorrectCount = incorrectCount.replace(/ /g, "").split(","); skippedCount = skippedCount.replace(/ /g, "").split(","); - $(function () { - var numberedBtns = $("ul#pageNums > ul > li"); - if (numberedBtns.hasClass("answered")) { - numberedBtns.removeClass("answered"); - } - for (var i = 0; i < correctCount.length; i++) { - var test = parseInt(correctCount[i]) - 1; - numberedBtns - .eq(parseInt(correctCount[i]) - 1) - .addClass("correctCount"); - } - for (var j = 0; j < incorrectCount.length; j++) { - numberedBtns - .eq(parseInt(incorrectCount[j]) - 1) - .addClass("incorrectCount"); - } - for (var k = 0; k < skippedCount.length; k++) { - numberedBtns - .eq(parseInt(skippedCount[k]) - 1) - .addClass("skippedCount"); + var numberedBtns = document.querySelectorAll("ul#pageNums > ul > li"); + for (const btn of numberedBtns) { + btn.classList.remove("answered"); + } + // entries may be "None", whose index parses to NaN and marks nothing + const mark = (entries, cls) => { + for (const entry of entries) { + const btn = numberedBtns[parseInt(entry) - 1]; + if (btn) { + btn.classList.add(cls); + } } - }); + }; + mark(correctCount, "correctCount"); + mark(incorrectCount, "incorrectCount"); + mark(skippedCount, "skippedCount"); + } +} + +// Page-level elements that may not exist on every page. +function hideElement(element) { + if (element) { + element.style.display = "none"; + } +} + +function showElement(element) { + if (element) { + element.style.display = ""; } } /*======================================================= === Function that calls the constructors on page load === =======================================================*/ -$(document).on("runestone:login-complete", function () { - $("[data-component=timedAssessment]").each(function (index) { - window.componentMap[this.id] = new Timed({ - orig: this, +document.addEventListener("runestone:login-complete", function () { + for (let element of document.querySelectorAll( + "[data-component=timedAssessment]", + )) { + window.componentMap[element.id] = new Timed({ + orig: element, useRunestoneServices: eBookConfig.useRunestoneServices, }); - }); + } }); diff --git a/bases/rsptx/interactives/runestone/timed/test/timed.test.js b/bases/rsptx/interactives/runestone/timed/test/timed.test.js new file mode 100644 index 000000000..dccae396c --- /dev/null +++ b/bases/rsptx/interactives/runestone/timed/test/timed.test.js @@ -0,0 +1,302 @@ +// Characterization tests for the Timed assessment component. These were +// first written against the jQuery implementation and now guard the +// jQuery-free version. +// Note: deliberately NO jquery-globals import here -- timed must work +// without jQuery. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import Timed from "../js/timed.js"; +import RunestoneBase from "../../common/js/runestonebase.js"; + +// A timed exam wraps each question in a .runestone div. Short answer +// questions keep the fixture light (no CodeMirror/Skulpt involvement). +function makeFixture({ id = "test_timed_1", attrs = 'data-time="10"' } = {}) { + document.body.innerHTML = ` +
      +
        +
        +
        First question?
        +
        +
        +
        Second question?
        +
        +
      +
      `; + return document.getElementById(id); +} + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +const exams = []; +async function makeTimed(fixtureOpts = {}) { + const orig = makeFixture(fixtureOpts); + const exam = new Timed({ orig, useRunestoneServices: false }); + exams.push(exam); + await exam.component_ready_promise; + await tick(); + return exam; +} + +async function startExam(exam) { + exam.startBtn.click(); + await tick(); +} + +const isHidden = (el) => el.style.display === "none"; +const qnums = () => [...document.querySelectorAll("ul#pageNums > ul > li")]; + +let logSpy; +beforeEach(() => { + document.body.innerHTML = ""; + window.componentMap = {}; + window.allComponents = []; + localStorage.clear(); + vi.restoreAllMocks(); + vi.stubGlobal("alert", vi.fn()); + vi.stubGlobal("confirm", vi.fn(() => true)); + logSpy = vi + .spyOn(RunestoneBase.prototype, "logBookEvent") + .mockResolvedValue(undefined); +}); + +afterEach(() => { + // stop any countdown chains so they don't outlive the test + for (const exam of exams) { + exam.running = 0; + exam.done = 1; + } + exams.length = 0; +}); + +describe("construction", () => { + it("renders timer and controls with the exam body hidden until start", async () => { + const exam = await makeTimed(); + expect(exam.startBtn.textContent).toBe("Start"); + expect(exam.pauseBtn.disabled).toBe(true); + expect(exam.timerContainer.innerHTML).toBe( + "Time Remaining 10:00", + ); + expect(isHidden(exam.timedDiv)).toBe(true); + expect(isHidden(exam.finishButton)).toBe(true); + expect(isHidden(exam.scoreDiv)).toBe(true); + expect(qnums().length).toBe(2); + expect(qnums()[0].classList.contains("active")).toBe(true); + // Prev is disabled on the first question + expect(exam.leftContainer.classList.contains("disabled")).toBe(true); + }); + + it("counts up when no time limit is given", async () => { + const exam = await makeTimed({ attrs: "" }); + expect(exam.limitedTime).toBe(false); + expect(exam.timerContainer.innerHTML).toBe("Time Taken 00:00"); + }); + + it("honors the presentation data attributes", async () => { + const exam = await makeTimed({ + attrs: 'data-time="10" data-no-timer data-no-pause data-fullwidth data-no-feedback', + }); + expect(exam.showTimer).toBe(false); + expect(isHidden(exam.timerContainer)).toBe(true); + expect(exam.nopause).toBe(true); + expect(exam.pauseBtn.parentElement).toBe(null); + expect(exam.containerDiv.style.maxWidth).toBe("none"); + expect(exam.showFeedback).toBe(false); + }); +}); + +describe("starting the exam", () => { + it("shows the first question and begins the clock", async () => { + const exam = await makeTimed(); + await startExam(exam); + expect(exam.running).toBe(1); + expect(isHidden(exam.startBtn)).toBe(true); + expect(exam.pauseBtn.disabled).toBe(false); + expect(isHidden(exam.timedDiv)).toBe(false); + expect( + exam.containerDiv.querySelector(".examwarning").textContent, + ).toContain("Warning"); + // the first question is rendered into the switch area + expect(exam.switchDiv.classList.contains("runestone")).toBe(true); + expect(exam.switchDiv.querySelector("textarea")).not.toBe(null); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "timedExam", + act: "start", + div_id: "test_timed_1", + }), + ); + const seeded = JSON.parse( + localStorage.getItem(exam.localStorageKey()), + ); + expect(seeded.answer).toEqual([0, 0, 2, 0]); + }); +}); + +describe("navigation", () => { + it("moves between questions with Next/Prev and updates the number strip", async () => { + const exam = await makeTimed(); + await startExam(exam); + exam.rightNavButton.click(); + await tick(); + expect(exam.currentQuestionIndex).toBe(1); + expect(qnums()[1].classList.contains("active")).toBe(true); + expect(qnums()[0].classList.contains("active")).toBe(false); + // on the last question the Next side is disabled + expect(exam.rightContainer.classList.contains("disabled")).toBe(true); + exam.rightNavButton.click(); + await tick(); + expect(exam.currentQuestionIndex).toBe(1); + exam.leftNavButton.click(); + await tick(); + expect(exam.currentQuestionIndex).toBe(0); + }); + + it("reveals the Finish button once every question has been visited", async () => { + const exam = await makeTimed(); + await startExam(exam); + expect(isHidden(exam.finishButton)).toBe(true); + exam.rightNavButton.click(); + await tick(); + expect(isHidden(exam.finishButton)).toBe(false); + }); + + it("jumps directly via the numbered buttons", async () => { + const exam = await makeTimed(); + await startExam(exam); + qnums()[1].querySelector("a").click(); + await tick(); + expect(exam.currentQuestionIndex).toBe(1); + expect(qnums()[1].classList.contains("active")).toBe(true); + }); + + it("marks a question answered (and logs it) when navigating away", async () => { + const exam = await makeTimed(); + await startExam(exam); + const textarea = exam.switchDiv.querySelector("textarea"); + textarea.value = "my answer"; + textarea.dispatchEvent(new Event("change")); + exam.rightNavButton.click(); + await tick(); + expect(qnums()[0].classList.contains("answered")).toBe(true); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "shortanswer", + answer: "my answer", + }), + ); + }); +}); + +describe("flagging", () => { + it("toggles the flag state of the current question", async () => { + const exam = await makeTimed(); + await startExam(exam); + exam.flagButton.click(); + await tick(); + expect(qnums()[0].classList.contains("flagcolor")).toBe(true); + expect(exam.flagButton.innerHTML).toBe("Unflag Question"); + exam.flagButton.click(); + await tick(); + expect(qnums()[0].classList.contains("flagcolor")).toBe(false); + expect(exam.flagButton.innerHTML).toBe("Flag Question"); + }); +}); + +describe("pausing", () => { + it("hides the questions while paused and resumes cleanly", async () => { + const exam = await makeTimed(); + await startExam(exam); + exam.pauseBtn.click(); + expect(exam.paused).toBe(1); + expect(exam.running).toBe(0); + expect(isHidden(exam.timedDiv)).toBe(true); + expect(exam.pauseBtn.innerHTML).toBe("Resume"); + exam.pauseBtn.click(); + expect(exam.running).toBe(1); + expect(isHidden(exam.timedDiv)).toBe(false); + expect(exam.pauseBtn.innerHTML).toBe("Pause"); + }); +}); + +describe("finishing", () => { + it("scores the exam, stores results, and thanks the student", async () => { + const exam = await makeTimed(); + await startExam(exam); + exam.rightNavButton.click(); + await tick(); + exam.finishButton.click(); + await tick(10); + expect(exam.done).toBe(1); + expect(exam.taken).toBe(1); + // short answer questions grade as "I" (ignored -> skipped) + expect(exam.skipped).toBe(2); + expect(exam.scoreDiv.innerHTML).toContain( + "Thank you for taking the exam", + ); + expect(isHidden(exam.scoreDiv)).toBe(false); + expect(exam.finishButton.disabled).toBe(true); + expect(exam.pauseBtn.disabled).toBe(true); + const stored = JSON.parse( + localStorage.getItem(exam.localStorageKey()), + ); + expect(stored.answer.length).toBe(7); + expect(stored.answer[4]).toBe(2); // skipped count + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "timedExam", + act: "finish", + skipped: 2, + }), + ); + }); + + it("submits the exam to the autograder", async () => { + const exam = await makeTimed(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + vi.stubGlobal("fetch", fetchMock); + eBookConfig.app = "/runestone"; + await exam.submitAutograde(); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("/runestone/assignments/student_autograde"); + expect(opts.method).toBe("POST"); + expect(String(opts.body)).toContain("assignment_id=test_timed_1"); + expect(String(opts.body)).toContain("is_timed=true"); + }); +}); + +describe("an exam that was already taken", () => { + it("restores the stored result and blocks a retake", async () => { + const key = `${eBookConfig.email}:${eBookConfig.course}:test_timed_2-given`; + localStorage.setItem( + key, + JSON.stringify({ + answer: [1, "1", 0, "None", 1, "2", 300], + timestamp: new Date(), + }), + ); + const exam = await makeTimed({ + id: "test_timed_2", + attrs: 'data-time="10"', + }); + expect(exam.taken).toBeTruthy(); + expect(exam.done).toBe(1); + expect(exam.score).toBe(1); + expect(exam.timeTaken).toBe(300); + expect(isHidden(exam.startBtn)).toBe(true); + expect(exam.scoreDiv.innerHTML).toContain("Thank you for taking"); + expect(exam.timerContainer.innerHTML).toBe("Time taken: 05:00"); + }); +}); + +describe("page wiring", () => { + it("builds a Timed exam for each timedAssessment at login-complete", async () => { + makeFixture({ id: "wired_timed" }); + document.dispatchEvent(new CustomEvent("runestone:login-complete")); + await tick(); + expect(window.componentMap.wired_timed).toBeDefined(); + exams.push(window.componentMap.wired_timed); + expect(window.componentMap.wired_timed.divid).toBe("wired_timed"); + }); +}); diff --git a/bases/rsptx/interactives/test-support/stubs/empty.js b/bases/rsptx/interactives/test-support/stubs/empty.js new file mode 100644 index 000000000..e7e1d27d3 --- /dev/null +++ b/bases/rsptx/interactives/test-support/stubs/empty.js @@ -0,0 +1,2 @@ +// Empty stand-in for vendored bundles that only produce page side effects. +export default {}; diff --git a/bases/rsptx/interactives/vitest.config.js b/bases/rsptx/interactives/vitest.config.js index ec159681d..92d0d0206 100644 --- a/bases/rsptx/interactives/vitest.config.js +++ b/bases/rsptx/interactives/vitest.config.js @@ -14,6 +14,16 @@ export default defineConfig({ "test-support/stubs/renderComponent.js", ), }, + // The vendored pytutor bundle is sloppy-mode code (bare `delete x`) + // that Vite's ESM transform rejects; livecode.js pulls it in for + // the codelens visualizer, which no test exercises. + { + find: /^.*\/pytutor-embed\.bundle\.js$/, + replacement: path.resolve( + __dirname, + "test-support/stubs/empty.js", + ), + }, ], }, test: { From 42cb89c2fff7d5f0d7b7a3f82c6aa9bff1b33a59 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Sat, 4 Jul 2026 13:21:15 -0500 Subject: [PATCH 10/15] feat: remove jQuery from common modules; delete dead jQuery plugins - renderComponent.js: native DOM parsing/insertion; renderOneComponent's timed-assessment guard now actually inspects the passed div (it used $(this) with an undefined this, so it never skipped anything). - presenter_mode.js: full native rewrite. Fixes exercise navigation, which threw a strict-mode ReferenceError in getActiveExercise, and guards the never-assigned presenterCssLink that broke leaving presenter mode. Dead configureCodelens/loadPresenterCss removed. - user-highlights.js: jQuery.ajax (one call synchronous) becomes fetch; nav-drawer animations use the domutil animate helper. - webpack.index.js: native listeners/queries for its own wiring. - jquery.idle-timer.js deleted (imported but never invoked anywhere); jquery.highlight.js deleted (no importers at all). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LFLtaVStyfvhuXsTtkHztA --- .../runestone/common/js/jquery.highlight.js | 53 -- .../runestone/common/js/jquery.idle-timer.js | 262 ---------- .../runestone/common/js/presenter_mode.js | 259 ++++----- .../runestone/common/js/renderComponent.js | 53 +- .../runestone/common/js/user-highlights.js | 491 +++++++++--------- .../common/test/presenter_mode.test.js | 87 ++++ .../common/test/user_highlights.test.js | 118 +++++ bases/rsptx/interactives/webpack.index.js | 25 +- 8 files changed, 605 insertions(+), 743 deletions(-) delete mode 100644 bases/rsptx/interactives/runestone/common/js/jquery.highlight.js delete mode 100644 bases/rsptx/interactives/runestone/common/js/jquery.idle-timer.js create mode 100644 bases/rsptx/interactives/runestone/common/test/presenter_mode.test.js create mode 100644 bases/rsptx/interactives/runestone/common/test/user_highlights.test.js diff --git a/bases/rsptx/interactives/runestone/common/js/jquery.highlight.js b/bases/rsptx/interactives/runestone/common/js/jquery.highlight.js deleted file mode 100644 index 0d97ad9d7..000000000 --- a/bases/rsptx/interactives/runestone/common/js/jquery.highlight.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - -highlight v4 - -Highlights arbitrary terms. - - - -MIT license. - -Johann Burkard - - - -*/ - -jQuery.fn.highlight = function(pat) { - function innerHighlight(node, pat) { - var skip = 0; - if (node.nodeType == 3) { - var pos = node.data.toUpperCase().indexOf(pat); - if (pos >= 0) { - var spannode = document.createElement('span'); - spannode.className = 'highlight'; - var middlebit = node.splitText(pos); - var endbit = middlebit.splitText(pat.length); - var middleclone = middlebit.cloneNode(true); - spannode.appendChild(middleclone); - middlebit.parentNode.replaceChild(spannode, middlebit); - skip = 1; - } - } - else if (node.nodeType == 1 && node.childNodes && !/(script|style)/i.test(node.tagName)) { - for (var i = 0; i < node.childNodes.length; ++i) { - i += innerHighlight(node.childNodes[i], pat); - } - } - return skip; - } - return this.length && pat && pat.length ? this.each(function() { - innerHighlight(this, pat.toUpperCase()); - }) : this; -}; - -jQuery.fn.removeHighlight = function() { - return this.find("span.highlight").each(function() { - this.parentNode.firstChild.nodeName; - with (this.parentNode) { - replaceChild(this.firstChild, this); - normalize(); - } - }).end(); -}; diff --git a/bases/rsptx/interactives/runestone/common/js/jquery.idle-timer.js b/bases/rsptx/interactives/runestone/common/js/jquery.idle-timer.js deleted file mode 100644 index dff582b11..000000000 --- a/bases/rsptx/interactives/runestone/common/js/jquery.idle-timer.js +++ /dev/null @@ -1,262 +0,0 @@ -/*! - * jQuery idleTimer plugin - * version 0.9.100511 - * by Paul Irish. - * http://github.com/paulirish/yui-misc/tree/ - * MIT license - - * adapted from YUI idle timer by nzakas: - * http://github.com/nzakas/yui-misc/ -*/ -/* - * Copyright (c) 2009 Nicholas C. Zakas - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -/* updated to fix Chrome setTimeout issue by Zaid Zawaideh */ - - // API available in <= v0.8 - /******************************* - - // idleTimer() takes an optional argument that defines the idle timeout - // timeout is in milliseconds; defaults to 30000 - $.idleTimer(10000); - - - $(document).bind("idle.idleTimer", function(){ - // function you want to fire when the user goes idle - }); - - - $(document).bind("active.idleTimer", function(){ - // function you want to fire when the user becomes active again - }); - - // pass the string 'destroy' to stop the timer - $.idleTimer('destroy'); - - // you can query if the user is idle or not with data() - $.data(document,'idleTimer'); // 'idle' or 'active' - - // you can get time elapsed since user when idle/active - $.idleTimer('getElapsedTime'); // time since state change in ms - - ********/ - - - - // API available in >= v0.9 - /************************* - - // bind to specific elements, allows for multiple timer instances - $(elem).idleTimer(timeout|'destroy'|'getElapsedTime'); - $.data(elem,'idleTimer'); // 'idle' or 'active' - - // if you're using the old $.idleTimer api, you should not do $(document).idleTimer(...) - - // element bound timers will only watch for events inside of them. - // you may just want page-level activity, in which case you may set up - // your timers on document, document.documentElement, and document.body - - // You can optionally provide a second argument to override certain options. - // Here are the defaults, so you can omit any or all of them. - $(elem).idleTimer(timeout, { - startImmediately: true, //starts a timeout as soon as the timer is set up; otherwise it waits for the first event. - idle: false, //indicates if the user is idle - enabled: true, //indicates if the idle timer is enabled - events: 'mousemove keydown DOMMouseScroll mousewheel mousedown touchstart touchmove' // activity is one of these events - }); - - ********/ - -(function($){ - -$.idleTimer = function(newTimeout, elem, opts){ - - // defaults that are to be stored as instance props on the elem - - opts = $.extend({ - startImmediately: true, //starts a timeout as soon as the timer is set up - idle: false, //indicates if the user is idle - enabled: true, //indicates if the idle timer is enabled - timeout: 30000, //the amount of time (ms) before the user is considered idle - events: 'mousemove keydown DOMMouseScroll mousewheel mousedown touchstart touchmove' // activity is one of these events - }, opts); - - - elem = elem || document; - - /* (intentionally not documented) - * Toggles the idle state and fires an appropriate event. - * @return {void} - */ - var toggleIdleState = function(myelem){ - - // curse you, mozilla setTimeout lateness bug! - if (typeof myelem === 'number'){ - myelem = undefined; - } - - var obj = $.data(myelem || elem,'idleTimerObj'); - - //toggle the state - obj.idle = !obj.idle; - - // reset timeout - var elapsed = (+new Date()) - obj.olddate; - obj.olddate = +new Date(); - - // handle Chrome always triggering idle after js alert or comfirm popup - if (obj.idle && (elapsed < opts.timeout)) { - obj.idle = false; - clearTimeout($.idleTimer.tId); - if (opts.enabled) - $.idleTimer.tId = setTimeout(toggleIdleState, opts.timeout); - return; - } - - //fire appropriate event - - // create a custom event, but first, store the new state on the element - // and then append that string to a namespace - var event = jQuery.Event( $.data(elem,'idleTimer', obj.idle ? "idle" : "active" ) + '.idleTimer' ); - - // we do want this to bubble, at least as a temporary fix for jQuery 1.7 - // event.stopPropagation(); - $(elem).trigger(event); - }, - - /** - * Stops the idle timer. This removes appropriate event handlers - * and cancels any pending timeouts. - * @return {void} - * @method stop - * @static - */ - stop = function(elem){ - - var obj = $.data(elem,'idleTimerObj') || {}; - - //set to disabled - obj.enabled = false; - - //clear any pending timeouts - clearTimeout(obj.tId); - - //detach the event handlers - $(elem).off('.idleTimer'); - }, - - - /* (intentionally not documented) - * Handles a user event indicating that the user isn't idle. - * @param {Event} event A DOM2-normalized event object. - * @return {void} - */ - handleUserEvent = function(){ - - var obj = $.data(this,'idleTimerObj'); - - //clear any existing timeout - clearTimeout(obj.tId); - - - - //if the idle timer is enabled - if (obj.enabled){ - - - //if it's idle, that means the user is no longer idle - if (obj.idle){ - toggleIdleState(this); - } - - //set a new timeout - obj.tId = setTimeout(toggleIdleState, obj.timeout); - - } - }; - - - /** - * Starts the idle timer. This adds appropriate event handlers - * and starts the first timeout. - * @param {int} newTimeout (Optional) A new value for the timeout period in ms. - * @return {void} - * @method $.idleTimer - * @static - */ - - - var obj = $.data(elem,'idleTimerObj') || {}; - - obj.olddate = obj.olddate || +new Date(); - - //assign a new timeout if necessary - if (typeof newTimeout === "number"){ - opts.timeout = newTimeout; - } else if (newTimeout === 'destroy') { - stop(elem); - return this; - } else if (newTimeout === 'getElapsedTime'){ - return (+new Date()) - obj.olddate; - } - - //assign appropriate event handlers - $(elem).on($.trim((opts.events+' ').split(' ').join('.idleTimer ')),handleUserEvent); - - - obj.idle = opts.idle; - obj.enabled = opts.enabled; - obj.timeout = opts.timeout; - - - //set a timeout to toggle state. May wish to omit this in some situations - if (opts.startImmediately) { - obj.tId = setTimeout(toggleIdleState, obj.timeout); - } - - // assume the user is active for the first x seconds. - $.data(elem,'idleTimer',"active"); - - // store our instance on the object - $.data(elem,'idleTimerObj',obj); - - - -}; // end of $.idleTimer() - - -// v0.9 API for defining multiple timers. -$.fn.idleTimer = function(newTimeout,opts){ - // Allow omission of opts for backward compatibility - if (!opts) { - opts = {}; - } - - if(this[0]){ - $.idleTimer(newTimeout,this[0],opts); - } - - return this; -}; - - -})(jQuery); diff --git a/bases/rsptx/interactives/runestone/common/js/presenter_mode.js b/bases/rsptx/interactives/runestone/common/js/presenter_mode.js index c3cd01247..3baf24bc1 100644 --- a/bases/rsptx/interactives/runestone/common/js/presenter_mode.js +++ b/bases/rsptx/interactives/runestone/common/js/presenter_mode.js @@ -1,86 +1,114 @@ -var codeExercises; -var presenterCssLink; +// Instructor-only "Code Presenter" view for Sphinx book pages: hides the +// prose and steps through the interactive exercises one at a time. + +var codeExercises = []; // Array of top-level .runestone exercise divs var presentModeInitialized = false; +// Elements a presenter wants to keep on screen. +const KEEP_VISIBLE = + "h1, .presentation-title, .btn-presenter, .runestone, .runestone *, section, .pre, code"; + +function sectionContent() { + return [...document.querySelectorAll("section *")].filter( + (el) => !el.matches(KEEP_VISIBLE), + ); +} + function presentToggle() { if (!presentModeInitialized) { presentModeSetup(); presentModeInitialized = true; } - let bod = $("body"); + let bod = document.body; let presentClass = "present"; let fullHeightClass = "full-height"; let bottomClass = "bottom"; - if (bod.hasClass(presentClass)) { - $("section *") - .not( - "h1, .presentation-title, .btn-presenter, .runestone, .runestone *, section, .pre, code", - ) - .removeClass("hidden"); //show everything - $("#completionButton").removeClass("hidden"); - bod.removeClass(presentClass); - $("." + fullHeightClass).removeClass(fullHeightClass); - $("." + bottomClass).removeClass(bottomClass); + if (bod.classList.contains(presentClass)) { + for (const el of sectionContent()) { + el.classList.remove("hidden"); //show everything + } + document + .getElementById("completionButton") + ?.classList.remove("hidden"); + bod.classList.remove(presentClass); + for (const el of document.querySelectorAll("." + fullHeightClass)) { + el.classList.remove(fullHeightClass); + } + for (const el of document.querySelectorAll("." + bottomClass)) { + el.classList.remove(bottomClass); + } localStorage.setItem("presentMode", "text"); - codeExercises.removeClass("hidden"); - presenterCssLink.disabled = true; // disable present_mode.css + for (const exercise of codeExercises) { + exercise.classList.remove("hidden"); + } } else { - $("section *") - .not( - "h1, .presentation-title, .btn-presenter, .runestone, .runestone *, section, .pre, code", - ) - .addClass("hidden"); // hide extraneous stuff - $("#completionButton").addClass("hidden"); - bod.addClass(presentClass); - bod.addClass(fullHeightClass); - $("html").addClass(fullHeightClass); - $("section .runestone").addClass(fullHeightClass); - $(".ac-caption").addClass(bottomClass); + for (const el of sectionContent()) { + el.classList.add("hidden"); // hide extraneous stuff + } + document.getElementById("completionButton")?.classList.add("hidden"); + bod.classList.add(presentClass); + bod.classList.add(fullHeightClass); + document.documentElement.classList.add(fullHeightClass); + for (const el of document.querySelectorAll("section .runestone")) { + el.classList.add(fullHeightClass); + } + for (const el of document.querySelectorAll(".ac-caption")) { + el.classList.add(bottomClass); + } localStorage.setItem("presentMode", presentClass); - // presenter_mode.css is loaded by webpack - //loadPresenterCss(); // present_mode.css should only apply when in presenter mode. activateExercise(); } } -function loadPresenterCss() { - presenterCssLink = document.createElement("link"); - presenterCssLink.type = "text/css"; - presenterCssLink.href = "../_static/presenter_mode.less"; - presenterCssLink.rel = "stylesheet"; - document.getElementsByTagName("head")[0].appendChild(presenterCssLink); -} - function presentModeSetup() { // moved this out of configure - let dataComponent = $("[data-childcomponent]"); // this still leaves some things semi-messed up when you exit presenter mode. // but instructors will probably just learn to refresh the page. - dataComponent.addClass("runestone"); - dataComponent.parent().closest("div").not("section").addClass("runestone"); - dataComponent.parent().closest("div").css("max-width", "none"); - - dataComponent.each(function (index) { - let me = $(this); - $(this) - .find(".ac_code_div, .ac_output") - .wrapAll("
      "); - }); + for (const component of document.querySelectorAll( + "[data-childcomponent]", + )) { + component.classList.add("runestone"); + const parentDiv = component.parentElement?.closest("div"); + if (parentDiv && !parentDiv.matches("section")) { + parentDiv.classList.add("runestone"); + parentDiv.style.maxWidth = "none"; + } + const parts = component.querySelectorAll(".ac_code_div, .ac_output"); + if (parts.length) { + const block = document.createElement("div"); + block.className = "ac-block"; + block.style.width = "100%"; + parts[0].before(block); + for (const part of parts) { + block.appendChild(part); + } + } + } - codelensListener(500); - $("section img").wrap('
      '); - codeExercises = $(".runestone").not(".runestone .runestone"); - // codeExercises.each(function(){ - $("h1").before( - "
      \ + for (const img of document.querySelectorAll("section img")) { + const wrapper = document.createElement("div"); + wrapper.className = "runestone"; + img.replaceWith(wrapper); + wrapper.appendChild(img); + } + // top-level exercises only: skip .runestone nested inside another + codeExercises = [...document.querySelectorAll(".runestone")].filter( + (el) => !el.parentElement.closest(".runestone"), + ); + for (const heading of document.querySelectorAll("h1")) { + heading.insertAdjacentHTML( + "beforebegin", + "
      \ \ \
      ", - ); + ); + } } + function getActiveExercise() { - return (active = codeExercises.filter(".active")); + return codeExercises.filter((el) => el.classList.contains("active")); } function activateExercise(index) { @@ -88,35 +116,44 @@ function activateExercise(index) { index = 0; } - let active = getActiveExercise(); - if (codeExercises.length) { - active.removeClass("active"); - active = $(codeExercises[index]).addClass("active"); - active.removeClass("hidden"); - codeExercises.not(codeExercises.filter(".active")).addClass("hidden"); + for (const el of getActiveExercise()) { + el.classList.remove("active"); + } + const active = codeExercises[index]; + active.classList.add("active"); + active.classList.remove("hidden"); + for (const el of codeExercises) { + if (el !== active) { + el.classList.add("hidden"); + } + } } } window.nextExercise = function () { - let active = getActiveExercise(); - let nextIndex = codeExercises.index(active) + 1; + let active = getActiveExercise()[0]; + let nextIndex = codeExercises.indexOf(active) + 1; if (nextIndex < codeExercises.length) { activateExercise(nextIndex); } }; window.prevExercise = function () { - let active = getActiveExercise(); - let prevIndex = codeExercises.index(active) - 1; + let active = getActiveExercise()[0]; + let prevIndex = codeExercises.indexOf(active) - 1; if (prevIndex >= 0) { activateExercise(prevIndex); } }; function configure() { - let rightNav = $(".navbar-right"); - rightNav.prepend( + let rightNav = document.querySelector(".navbar-right"); + if (!rightNav) { + return; + } + rightNav.insertAdjacentHTML( + "afterbegin", "