@webspellchecker/wproofreader-learnosity 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@webspellchecker/wproofreader-learnosity`.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [1.2.1]
8
+
9
+ ### Features
10
+
11
+ - **Published to npm.** `@webspellchecker/wproofreader-learnosity` is now available on the public npm registry. v1.2.1 is functionally identical to v1.2.0; the bump exists to mark the npm publication event so the jsDelivr URL documented in the README resolves.
12
+
13
+ ## [1.2.0]
14
+
15
+ ### Features
16
+
17
+ - **Two install paths.** Existing ESM via npm, plus a new IIFE bundle (`dist/wproofreader-learnosity.min.js`) served from jsDelivr, exposing `window.LearnosityWProofreader.init` for host apps that load the connector via a `<script>` tag (PHP, Python, Java, ASP.NET, Ruby, classic HTML). About 5 KB minified, no runtime dependencies. Built with esbuild via `npm run build`; produced automatically at `npm publish` time via the `prepack` hook.
18
+
19
+ ## [1.1.0]
20
+
21
+ ### Features
22
+
23
+ - **Self-hosted WProofreader support.** `init()` accepts self-hosted service options (`serviceProtocol`, `serviceHost`, `servicePort`, `servicePath`) under the `wproofreader` block instead of (or alongside) `serviceId`, so customers running the WProofreader Server on their own infrastructure (Docker, Helm, on-prem) integrate without a Cloud service ID. Either `wproofreader.serviceId` or `wproofreader.serviceHost` activates `init()`.
24
+
25
+ ## [1.0.0]
26
+
27
+ Initial public release.
28
+
29
+ ### Features
30
+
31
+ - **Drop-in initialisation.** A single `LearnosityWProofreader.init({ wproofreader: { serviceId } })` call from a host app is enough to start proofreading inside Learnosity widgets. Idempotent: subsequent calls update the live configuration.
32
+ - **ESM npm package.** Default export imported as `import LearnosityWProofreader from '@webspellchecker/wproofreader-learnosity'`; the call site mirrors the import shape.
33
+ - **All three Learnosity APIs supported.** Questions API (`longtextV2` rich-text responses), Author API (every rich-text field across the authoring UI), and Items API (`assess` rendering type).
34
+ - **Two-tier options.** WProofreader runtime options live under a nested `wproofreader` block and are forwarded to WProofreader unchanged (including unknown future options); connector-only fields (`surfaces`, `enableShorttext`, `customSelectors`, `bundleUrl`) live at the top level. The two groups never collide.
35
+ - **Per-editor surface filter.** The `surfaces` option (default `['author']`) walks each editor's ancestry to detect which Learnosity API placed it (`lrn-author*` → Author, `lrn_player*` → Items, otherwise → Questions) and skips editors whose surface is not allowed. Multi-API pages are handled correctly. Opt in to assessment proofreading explicitly with `['questions', 'items', 'author']`.
36
+ - **Assessment-safe default.** The default `surfaces: ['author']` keeps proofreading off in student-facing assessments unless the host app derives an explicit opt-in from server-side activity metadata.
37
+ - **Locale-aware default.** `wproofreader.lang` reads from the host page's `<html lang>` (with `-` normalised to `_`), so a French page proofreads in French without extra config. Falls back to `en_US` when the page does not set a `lang` or sets a bare un-regioned one like `en`.
38
+ - **TypeScript definitions** at `src/index.d.ts`, exposed via `package.json` `types` and `exports.types`. `WProofreaderOptions` has an `[other: string]: unknown` index signature so unknown WProofreader options still type-check.
39
+ - **Lifecycle cleanup.** A scoped `MutationObserver` watches for editors appearing and disappearing. WProofreader instances are destroyed when their host elements leave the DOM, so single-page-app hosts that rebuild Learnosity widgets do not leak instances.
40
+ - **Targeted attachment, not autoSearch.** Attaches only to editors inside a Learnosity-rendered subtree (any ancestor with a class starting with `lrn`, plus optional caller-supplied selectors). The host app's own editables are left untouched.
41
+ - **`customSelectors` escape hatch.** Caller-supplied ancestor selectors extend the Learnosity scope for unusual host apps without giving up the built-in scoping for the rest.
42
+ - **Optional `shorttext` surface.** Off by default. When `enableShorttext: true`, the connector also attaches to `<input type="text">` inside Learnosity scope.
43
+ - **Configurable WProofreader bundle URL** via `bundleUrl`, for customers running WProofreader from a non-default CDN host.
44
+ - **Apache-2.0 licensed.**
45
+
46
+ [1.2.1]: https://github.com/WebSpellChecker/wproofreader-learnosity/releases/tag/v1.2.1
47
+ [1.2.0]: https://github.com/WebSpellChecker/wproofreader-learnosity/releases/tag/v1.2.0
48
+ [1.1.0]: https://github.com/WebSpellChecker/wproofreader-learnosity/releases/tag/v1.1.0
49
+ [1.0.0]: https://github.com/WebSpellChecker/wproofreader-learnosity/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for describing the origin of the Work and
141
+ reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Support. While redistributing the Work or
166
+ Derivative Works thereof, You may choose to offer, and charge a
167
+ fee for, acceptance of support, warranty, indemnity, or other
168
+ liability obligations and/or rights consistent with this License.
169
+ However, in accepting such obligations, You may act only on Your
170
+ own behalf and on Your sole responsibility, not on behalf of any
171
+ other Contributor, and only if You agree to indemnify, defend,
172
+ and hold each Contributor harmless for any liability incurred by,
173
+ or claims asserted against, such Contributor by reason of your
174
+ accepting any such warranty or support.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 WebSpellChecker LLC
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
189
+ implied. See the License for the specific language governing permissions
190
+ and limitations under the License.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @webspellchecker/wproofreader-learnosity
2
+
3
+ WProofreader for Learnosity. Spelling, grammar and style assistance inside Learnosity rich-text widgets, with one function call.
4
+
5
+ > v1.2.1. Apache-2.0. TypeScript definitions included. Two install paths: npm (ESM) and CDN (IIFE). Cloud and self-hosted WProofreader. Per-editor surface filter, lifecycle cleanup, locale-aware defaults, optional escape hatches for unusual host apps.
6
+
7
+ ## Install
8
+
9
+ The connector offers two install paths. The runtime API is identical, `LearnosityWProofreader.init({...})` in both.
10
+
11
+ ### Via npm (Node host apps)
12
+
13
+ ```bash
14
+ npm install @webspellchecker/wproofreader-learnosity
15
+ ```
16
+
17
+ ```js
18
+ import LearnosityWProofreader from '@webspellchecker/wproofreader-learnosity';
19
+
20
+ LearnosityWProofreader.init({
21
+ wproofreader: { serviceId: 'YOUR_WPROOFREADER_SERVICE_ID' }
22
+ });
23
+ ```
24
+
25
+ ### Via `<script>` tag (non-Node host apps)
26
+
27
+ For host apps where adding a bundler is overkill (classic HTML, PHP, Python, Java, ASP.NET, Ruby), load the connector from jsDelivr:
28
+
29
+ ```html
30
+ <script src="https://cdn.jsdelivr.net/npm/@webspellchecker/wproofreader-learnosity@1.2.1/dist/wproofreader-learnosity.min.js"></script>
31
+ <script>
32
+ LearnosityWProofreader.init({
33
+ wproofreader: { serviceId: 'YOUR_WPROOFREADER_SERVICE_ID' }
34
+ });
35
+ </script>
36
+ ```
37
+
38
+ The bundle exposes a single global, `window.LearnosityWProofreader`, with the same `init` method. About 5 KB minified, no runtime dependencies. jsDelivr serves from npm, so the pinned URL above resolves once the package is published.
39
+
40
+ ## Quickstart
41
+
42
+ ```js
43
+ import LearnosityWProofreader from '@webspellchecker/wproofreader-learnosity';
44
+
45
+ // Before Learnosity widgets render.
46
+ LearnosityWProofreader.init({
47
+ wproofreader: { serviceId: 'YOUR_WPROOFREADER_SERVICE_ID' }
48
+ });
49
+
50
+ // Initialise Learnosity as normal. The connector attaches to longtextV2 editors automatically.
51
+ LearnosityApp.init(signedRequest, {
52
+ readyListener() { console.log('Learnosity ready'); },
53
+ errorListener(err) { console.error(err); }
54
+ });
55
+ ```
56
+
57
+ `init()` is idempotent: calling it again updates the live configuration without reloading the WProofreader bundle.
58
+
59
+ ## Getting a service ID
60
+
61
+ WProofreader is a commercial service from WebSpellChecker. The service ID passed to `init({ wproofreader: { serviceId } })` is your account's activation key.
62
+
63
+ - **Free trial**: <https://webspellchecker.com/free-trial/> issues a service ID for evaluation.
64
+ - **Paid subscription** converts the trial to a production licence with the domain set you need.
65
+ - **Domain locking**: every service ID is bound to a list of allowed origins. Whitelist your production and development domains in the WebSpellChecker dashboard; otherwise the spell-check API returns 403 for those origins.
66
+
67
+ If you run WProofreader on your own infrastructure (Docker, Helm, on-prem) you skip the service ID entirely. See [Self-hosted WProofreader](#self-hosted-wproofreader) below.
68
+
69
+ ## Self-hosted WProofreader
70
+
71
+ For customers running the [WProofreader Server](https://hub.docker.com/r/webspellchecker/wproofreader) on their own infrastructure (Docker, Helm, on-prem), pass the service connection options inside the `wproofreader` block instead of (or alongside) `serviceId`. Every property on `wproofreader` is forwarded to WProofreader unchanged:
72
+
73
+ ```js
74
+ LearnosityWProofreader.init({
75
+ wproofreader: {
76
+ serviceProtocol: 'https',
77
+ serviceHost: 'localhost',
78
+ servicePort: 443,
79
+ servicePath: '/wscservice/api'
80
+ },
81
+ bundleUrl: 'https://localhost/wscservice/wscbundle/wscbundle.js'
82
+ });
83
+ ```
84
+
85
+ Either `wproofreader.serviceId` or `wproofreader.serviceHost` must be set; otherwise `init()` no-ops with a console warning.
86
+
87
+ ## Configuration
88
+
89
+ `init()` takes a single options object with two groups: a nested `wproofreader` sub-object (forwarded to WProofreader unchanged) and connector-only fields at the top level.
90
+
91
+ ### WProofreader options (under `wproofreader`)
92
+
93
+ These pass straight through to WProofreader's runtime config. The tables below cover the frequently-used ones; the full surface is documented in [Documentation and support](#documentation-and-support).
94
+
95
+ | Option | Default | Notes |
96
+ |-------------------|--------------------------------------|--------------------------------------------------------|
97
+ | `serviceId` | _none_ | WProofreader Cloud service ID. Domain-locked server-side. Required for the Cloud path. Omit when using a self-hosted deployment. |
98
+ | `serviceProtocol` | _none_ | Self-hosted: `'http'` or `'https'`. |
99
+ | `serviceHost` | _none_ | Self-hosted: WProofreader server hostname. Presence of `serviceHost` (or `serviceId`) is what activates `init()`. |
100
+ | `servicePort` | _none_ | Self-hosted: port. |
101
+ | `servicePath` | _none_ | Self-hosted: URL path to the WProofreader API. |
102
+ | `enableGrammar` | `true` | Grammar checking on top of spell checking. |
103
+ | `autocorrect` | `true` | Suggested replacement on typo accept. |
104
+ | `lang` | host page's `<html lang>`, or `en_US`| Read from `<html lang>` with `-` normalised to `_` (e.g. `en-US` → `en_US`). Falls back to `en_US` if the page does not set a `lang` or sets a bare un-regioned one (`en`, `fr`), because WProofreader expects region-qualified codes. Pass explicitly to override. |
105
+
106
+ If neither `wproofreader.serviceId` nor `wproofreader.serviceHost` is set, `init()` no-ops with a console warning so the host app can call it unconditionally.
107
+
108
+ ### Connector options (top level)
109
+
110
+ | Option | Default | Notes |
111
+ |-------------------|---------------|--------------------------------------------------------|
112
+ | `surfaces` | `['author']` | Subset of `['questions','items','author']`. **Per-editor** filter: each editor's ancestry is walked for surface-specific markers (`lrn-author*` → author, `lrn_player*` → items, otherwise → questions); editors whose surface is not in the allowed list are skipped. Default is Author only, for assessment integrity. Opt in to assessment proofreading explicitly with `['questions', 'items', 'author']`. |
113
+ | `enableShorttext` | `false` | Also attach to `shorttext` inputs (`<input type="text">`) inside Learnosity scope. Off by default because plain text inputs are usually short and proofreading them is noisy. |
114
+ | `customSelectors` | `[]` | Extra ancestor CSS selectors that count as "inside Learnosity scope" for unusual host apps. Each entry is matched via `element.matches`. |
115
+ | `bundleUrl` | (default CDN) | Override the WProofreader bundle URL for non-default CDNs or self-hosted servers. |
116
+
117
+ ### Examples
118
+
119
+ ```js
120
+ // Authoring-only (the default). No-ops on assessment pages.
121
+ LearnosityWProofreader.init({
122
+ wproofreader: { serviceId: '...' }
123
+ });
124
+
125
+ // All three surfaces, including assessment. The host app should derive this
126
+ // from server-side activity metadata so it cannot be flipped from the console.
127
+ LearnosityWProofreader.init({
128
+ wproofreader: { serviceId: '...' },
129
+ surfaces: ['questions', 'items', 'author']
130
+ });
131
+
132
+ // Unusual host app that wraps Learnosity inside its own container class.
133
+ LearnosityWProofreader.init({
134
+ wproofreader: { serviceId: '...' },
135
+ customSelectors: ['.my-host-app-learnosity-region']
136
+ });
137
+ ```
138
+
139
+ ## Supported surfaces
140
+
141
+ | Learnosity API | Surface |
142
+ |----------------|-------------------------------|
143
+ | Questions API | `longtextV2` (rich-text essay) |
144
+ | Author API | All rich-text fields |
145
+ | Items API | `assess` rendering type |
146
+
147
+ `shorttext` (plain text input) is opt-in via `enableShorttext: true`.
148
+
149
+ ## Supported Learnosity versions
150
+
151
+ Tested manually against the `latest-lts` channel. The connector tracks editor placement via the `lrn`-prefixed class convention plus a small per-surface marker set; class-rename changes between LTS releases are an isolated update in `src/index.js`.
152
+
153
+ ## Documentation and support
154
+
155
+ - **WProofreader product page**: <https://webspellchecker.com/wsc-proofreader/>
156
+ - **Configuration reference** (every WProofreader option beyond what this connector documents): <https://webspellchecker.com/docs/api/wscbundle/Options.html>
157
+ - **WProofreader SDK source** (the underlying library the connector wraps): <https://github.com/WebSpellChecker/wproofreader-sdk-js>
158
+ - **Self-hosted server image**: <https://hub.docker.com/r/webspellchecker/wproofreader>
159
+ - **Contact and commercial enquiries**: <https://webspellchecker.com/contact-us/>
160
+
161
+ ## Tests
162
+
163
+ ```bash
164
+ npm install
165
+ npm test
166
+ ```
167
+
168
+ `npm test` runs the unit suite under `node --test`.
169
+
170
+ ## License
171
+
172
+ Apache-2.0. See [LICENSE](./LICENSE).
@@ -0,0 +1,2 @@
1
+ var LearnosityWProofreader=(()=>{var l=Object.defineProperty;var L=Object.getOwnPropertyDescriptor;var S=Object.getOwnPropertyNames;var A=Object.prototype.hasOwnProperty;var E=(e,t)=>{for(var n in t)l(e,n,{get:t[n],enumerable:!0})},v=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of S(t))!A.call(e,r)&&r!==n&&l(e,r,{get:()=>t[r],enumerable:!(o=L(t,r))||o.enumerable});return e};var g=e=>v(l({},"__esModule",{value:!0}),e);var k={};E(k,{default:()=>D});var _="https://svc.webspellchecker.net/spellcheck31/wscbundle/wscbundle.js",C=["questions","items","author"],O={questions:"LearnosityApp",items:"LearnosityItems",author:"LearnosityAuthor"},I=["author"],x=3e4,T=100,h=!1,p=null,f=new WeakSet,d=new WeakMap;function U(e){e=e||{};let t=e.wproofreader||{};if(!t.serviceId&&!t.serviceHost){typeof console<"u"&&console.warn&&console.warn("[wproofreader-learnosity] No wproofreader.serviceId or wproofreader.serviceHost provided. Proofreading will not activate. Pass serviceId for WProofreader Cloud, or serviceHost (plus serviceProtocol, servicePort, servicePath) for a self-hosted deployment.");return}let n=P(e);if(h){Object.assign(window.WEBSPELLCHECKER_CONFIG,n.wproofreader),Object.assign(p,n);return}window.WEBSPELLCHECKER_CONFIG=n.wproofreader,p=n,q(e.bundleUrl||_),W(n),h=!0}function P(e){let t=e.wproofreader||{};return{wproofreader:{...t,enableGrammar:t.enableGrammar!==!1,autocorrect:t.autocorrect!==!1,lang:t.lang||F()},surfaces:R(e.surfaces),enableShorttext:e.enableShorttext===!0,customSelectors:Array.isArray(e.customSelectors)?e.customSelectors.slice():[]}}function F(){if(typeof document>"u")return"en_US";let e=document.documentElement;if(!e||!e.lang)return"en_US";let t=e.lang;return t.indexOf("-")===-1&&t.indexOf("_")===-1?"en_US":t.replace("-","_")}function R(e){return Array.isArray(e)?e.filter(function(t){return C.indexOf(t)!==-1}):I.slice()}function B(e){if(typeof window>"u"||e.length===0)return!1;for(let t=0;t<e.length;t++){let n=O[e[t]];if(n&&typeof window[n]<"u")return!0}return!1}function N(e){let t=e&&e.parentElement;for(;t;){let n=t.classList;if(n&&n.length)for(let o=0;o<n.length;o++){let r=n[o];if(r.indexOf("lrn-author")===0)return"author";if(r.indexOf("lrn_player")===0)return"items"}t=t.parentElement}return"questions"}function m(e,t){let n=e&&e.parentElement;for(;n;){let o=n.classList;if(o)for(let r=0;r<o.length;r++){let i=o[r];if(i==="learnosity-response"||i.indexOf("lrn")===0)return!0}if(t&&t.length&&n.matches)for(let r=0;r<t.length;r++)try{if(n.matches(t[r]))return!0}catch{}n=n.parentElement}return!1}function q(e){if(typeof document>"u"||document.querySelector("script[data-wproofreader-bundle]"))return;let t=document.createElement("script");t.src=e,t.async=!0,t.dataset.wproofreaderBundle="",document.head.appendChild(t)}function W(e){if(typeof document>"u")return;let t=function(o){G(o,e)};t(document),new MutationObserver(function(o){for(let r=0;r<o.length;r++){let i=o[r];if(i.type==="childList"){let s=i.addedNodes;for(let a=0;a<s.length;a++){let c=s[a];c&&c.nodeType===1&&t(c)}let u=i.removedNodes;for(let a=0;a<u.length;a++){let c=u[a];c&&c.nodeType===1&&H(c)}}else if(i.type==="attributes"&&i.attributeName==="contenteditable"){let s=i.target;s&&y(s,e)&&m(s,e.customSelectors)&&b(s,e)}}}).observe(document.body||document.documentElement,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["contenteditable"]})}function G(e,t){if(!e||!e.querySelectorAll)return;let n=[];y(e,t)&&n.push(e);let o=e.querySelectorAll(j(t));for(let r=0;r<o.length;r++)n.push(o[r]);for(let r=0;r<n.length;r++){let i=n[r];m(i,t.customSelectors)&&b(i,t)}}function j(e){let t='iframe, [contenteditable]:not([contenteditable="false"])';return e.enableShorttext&&(t+=', input[type="text"], input:not([type])'),t}function y(e,t){return!e||e.nodeType!==1?!1:e.tagName==="IFRAME"?!0:e.tagName==="INPUT"&&t.enableShorttext?(e.getAttribute("type")||"text").toLowerCase()==="text":!e.hasAttribute||!e.hasAttribute("contenteditable")?!1:e.getAttribute("contenteditable")!=="false"}function b(e,t){if(!e||f.has(e)||!B(t.surfaces))return;let n=N(e);if(t.surfaces.indexOf(n)===-1)return;f.add(e);let o=function(){M("WEBSPELLCHECKER",function(i){let s=Object.assign({},t.wproofreader,{container:e});try{i.init(s,function(u){u&&d.set(e,u)})}catch{}})};if(e.tagName&&e.tagName.toLowerCase()==="iframe"){let i=null;try{i=e.contentDocument}catch{}i&&i.readyState==="complete"?o():e.addEventListener("load",o,{once:!0})}else o()}function H(e){if(e&&(f.has(e)&&w(e),e.querySelectorAll)){let t=e.querySelectorAll('iframe, [contenteditable], input[type="text"], input:not([type])');for(let n=0;n<t.length;n++)f.has(t[n])&&w(t[n])}}function w(e){let t=d.get(e);if(t){try{typeof t.destroy=="function"&&t.destroy()}catch{}d.delete(e)}f.delete(e)}function M(e,t){if(typeof window>"u")return;if(window[e]){t(window[e]);return}let n=setInterval(function(){window[e]&&(clearInterval(n),clearTimeout(o),t(window[e]))},T),o=setTimeout(function(){clearInterval(n),typeof console<"u"&&console.warn&&console.warn("[wproofreader-learnosity] window."+e+" never appeared. Proofreading will not activate.")},x)}var D={init:U};return g(k);})();
2
+ LearnosityWProofreader=LearnosityWProofreader.default;
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@webspellchecker/wproofreader-learnosity",
3
+ "version": "1.2.1",
4
+ "description": "WProofreader for Learnosity. Spelling, grammar and style assistance inside Learnosity rich-text widgets.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src/",
16
+ "dist/",
17
+ "README.md",
18
+ "LICENSE",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test tests/*.test.js",
23
+ "build": "esbuild src/index.js --bundle --format=iife --global-name=LearnosityWProofreader '--footer:js=LearnosityWProofreader=LearnosityWProofreader.default;' --minify --outfile=dist/wproofreader-learnosity.min.js",
24
+ "prepack": "npm run build"
25
+ },
26
+ "devDependencies": {
27
+ "esbuild": "^0.23.0"
28
+ },
29
+ "keywords": [
30
+ "learnosity",
31
+ "wproofreader",
32
+ "webspellchecker",
33
+ "spell-check",
34
+ "grammar",
35
+ "ckeditor"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "homepage": "https://github.com/WebSpellChecker/wproofreader-learnosity",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/WebSpellChecker/wproofreader-learnosity.git"
44
+ },
45
+ "private": false,
46
+ "license": "Apache-2.0",
47
+ "dependencies": {
48
+ "user": "0.0.0"
49
+ }
50
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Type definitions for @webspellchecker/wproofreader-learnosity.
3
+ * Hand-written to match src/index.js. Keep in sync.
4
+ */
5
+
6
+ /**
7
+ * Learnosity API surfaces the connector knows how to scope to. Each editor's
8
+ * ancestry is walked for surface-specific markers; editors whose surface is
9
+ * not in the allowed list are skipped.
10
+ */
11
+ export type Surface = 'questions' | 'items' | 'author';
12
+
13
+ /**
14
+ * Runtime options forwarded to WProofreader unchanged. The connector does
15
+ * not enforce any schema beyond `serviceId` / `serviceHost` activation, so
16
+ * any other WProofreader option may also be set.
17
+ */
18
+ export interface WProofreaderOptions {
19
+ /**
20
+ * WProofreader Cloud service ID. Domain-locked server-side. Required for
21
+ * the Cloud path. Omit when using a self-hosted deployment.
22
+ */
23
+ serviceId?: string;
24
+
25
+ /** Self-hosted: protocol. */
26
+ serviceProtocol?: 'http' | 'https';
27
+
28
+ /**
29
+ * Self-hosted: WProofreader server hostname. Presence of `serviceHost` (or
30
+ * `serviceId`) activates `init()`.
31
+ */
32
+ serviceHost?: string;
33
+
34
+ /** Self-hosted: port. */
35
+ servicePort?: number | string;
36
+
37
+ /** Self-hosted: URL path to the WProofreader API (e.g. `/wscservice/api`). */
38
+ servicePath?: string;
39
+
40
+ /** Default `true`. */
41
+ enableGrammar?: boolean;
42
+
43
+ /** Default `true`. */
44
+ autocorrect?: boolean;
45
+
46
+ /**
47
+ * Default: the host page's `<html lang>` attribute, with `-` normalised to
48
+ * `_` (e.g. `en-US` becomes `en_US`). Falls back to `en_US` if the page
49
+ * does not set a `lang` or sets a bare un-regioned one (WProofreader
50
+ * requires region-qualified codes).
51
+ */
52
+ lang?: string;
53
+
54
+ /**
55
+ * Forward-compatible escape hatch: any other property is passed through to
56
+ * WProofreader's runtime config unchanged. Useful for WProofreader options
57
+ * the connector does not enumerate explicitly.
58
+ */
59
+ [other: string]: unknown;
60
+ }
61
+
62
+ export interface InitOptions {
63
+ /** WProofreader runtime options. Forwarded to WProofreader unchanged. */
64
+ wproofreader?: WProofreaderOptions;
65
+
66
+ /**
67
+ * Subset of `['questions','items','author']`. Default `['author']` for
68
+ * assessment integrity. The connector walks each editor's ancestry to
69
+ * detect which Learnosity API placed it (`lrn-author*` → author,
70
+ * `lrn_player*` → items, otherwise → questions) and skips editors whose
71
+ * surface is not in the allowed list. Opt in to assessment proofreading
72
+ * explicitly with `['questions', 'items', 'author']`.
73
+ */
74
+ surfaces?: ReadonlyArray<Surface>;
75
+
76
+ /**
77
+ * Default `false`. Also attach to `shorttext` inputs (`<input type="text">`)
78
+ * inside Learnosity scope.
79
+ */
80
+ enableShorttext?: boolean;
81
+
82
+ /**
83
+ * Extra ancestor CSS selectors that count as "inside Learnosity scope" for
84
+ * unusual host apps. Each entry is matched via `element.matches`.
85
+ */
86
+ customSelectors?: ReadonlyArray<string>;
87
+
88
+ /** Override the WProofreader bundle URL for non-default CDNs or self-hosted servers. */
89
+ bundleUrl?: string;
90
+ }
91
+
92
+ /**
93
+ * Public API surface. The default export is the namespace object; the call
94
+ * site mirrors that shape.
95
+ */
96
+ export interface LearnosityWProofreader {
97
+ /**
98
+ * Initialise the connector. Should be called before Learnosity widgets
99
+ * render. Safe to call multiple times: subsequent calls update the live
100
+ * configuration without reloading the WProofreader bundle.
101
+ */
102
+ init(options?: InitOptions): void;
103
+ }
104
+
105
+ declare const LearnosityWProofreader: LearnosityWProofreader;
106
+ export default LearnosityWProofreader;
package/src/index.js ADDED
@@ -0,0 +1,502 @@
1
+ /**
2
+ * WProofreader connector for Learnosity.
3
+ *
4
+ * Targeted attachment to Learnosity-rendered editables, scoped by an ancestry
5
+ * walk for any `lrn`-prefixed class plus optional caller-supplied selectors.
6
+ * Per-editor `surfaces` filter (default `['author']`) covers assessment
7
+ * integrity by excluding Questions and Items by default; the host app opts in
8
+ * explicitly. Cleans up WProofreader instances when their host elements are
9
+ * removed from the DOM (single-page apps that rebuild Learnosity widgets do
10
+ * not leak).
11
+ *
12
+ * Usage in a host app:
13
+ *
14
+ * import LearnosityWProofreader from '@webspellchecker/wproofreader-learnosity';
15
+ *
16
+ * LearnosityWProofreader.init({
17
+ * wproofreader: { serviceId: 'YOUR_WPROOFREADER_SERVICE_ID' }
18
+ * });
19
+ *
20
+ * LearnosityApp.init(signedRequest, callbacks);
21
+ */
22
+
23
+ const DEFAULT_BUNDLE_URL =
24
+ 'https://svc.webspellchecker.net/spellcheck31/wscbundle/wscbundle.js';
25
+
26
+ /*
27
+ * Surfaces the connector recognises. `SURFACE_GLOBALS` maps each to the
28
+ * `window` global Learnosity exposes for that API. Used in two places:
29
+ * - `isAnyAllowedSurfaceLoaded`: cheap page-level gate; skip everything if
30
+ * no allowed-surface global has loaded yet.
31
+ * - `detectSurface`: per-editor classification via DOM-ancestor markers,
32
+ * so multi-API pages are filtered correctly.
33
+ */
34
+ const ALLOWED_SURFACES = ['questions', 'items', 'author'];
35
+ const SURFACE_GLOBALS = {
36
+ questions: 'LearnosityApp',
37
+ items: 'LearnosityItems',
38
+ author: 'LearnosityAuthor'
39
+ };
40
+ /*
41
+ * Default surfaces. Author only, because Questions and Items render
42
+ * student-facing assessments where spell-check can compromise the activity
43
+ * itself. Host apps that want proofreading in those contexts opt in
44
+ * explicitly with `surfaces: ['questions', 'items', 'author']`.
45
+ */
46
+ const DEFAULT_SURFACES = ['author'];
47
+
48
+ /*
49
+ * Maximum wait for `window.WEBSPELLCHECKER` to appear after the bundle
50
+ * `<script>` is injected. The bundle loads async, so it may not be on
51
+ * `window` by the time the first editor needs attaching. After this timeout,
52
+ * `whenAvailable` logs a console warning and stops waiting.
53
+ */
54
+ const GLOBAL_WAIT_TIMEOUT_MS = 30000;
55
+ const GLOBAL_POLL_INTERVAL_MS = 100;
56
+
57
+ let initialized = false;
58
+ let currentConnectorConfig = null;
59
+ const attachedContainers = new WeakSet();
60
+ const containerToInstance = new WeakMap();
61
+
62
+ /**
63
+ * Initialise the connector. Should be called before Learnosity widgets render.
64
+ * Safe to call multiple times: subsequent calls update the live configuration
65
+ * without reloading the WProofreader bundle or re-binding the observer.
66
+ *
67
+ * Options are split into two groups:
68
+ * - `wproofreader`: passed through to WProofreader unchanged (its runtime
69
+ * options surface). `serviceId` for Cloud or `serviceHost` for self-hosted
70
+ * activates the connector; without one of these, init() no-ops with a
71
+ * console warning.
72
+ * - Top-level keys: connector-only configuration (`surfaces`,
73
+ * `enableShorttext`, `customSelectors`, `bundleUrl`).
74
+ *
75
+ * @param {Object} [options]
76
+ * @param {Object} [options.wproofreader] WProofreader runtime options.
77
+ * Forwarded to WProofreader unchanged.
78
+ * @param {string} [options.wproofreader.serviceId] WProofreader Cloud service ID.
79
+ * Required for the Cloud path. Omit
80
+ * when using a self-hosted deployment
81
+ * (see `serviceHost`).
82
+ * @param {string} [options.wproofreader.serviceProtocol] Self-hosted: 'http' or 'https'.
83
+ * @param {string} [options.wproofreader.serviceHost] Self-hosted: hostname of the
84
+ * WProofreader server. Presence of
85
+ * `serviceHost` (or `serviceId`)
86
+ * activates init().
87
+ * @param {number} [options.wproofreader.servicePort] Self-hosted: port.
88
+ * @param {string} [options.wproofreader.servicePath] Self-hosted: URL path to the
89
+ * WProofreader API (e.g.
90
+ * '/wscservice/api').
91
+ * @param {boolean} [options.wproofreader.enableGrammar] Default true.
92
+ * @param {boolean} [options.wproofreader.autocorrect] Default true.
93
+ * @param {string} [options.wproofreader.lang] Default: the host page's
94
+ * `<html lang>` attribute, with `-`
95
+ * normalised to `_` (e.g. `en-US`
96
+ * becomes `en_US`). Falls back to
97
+ * `en_US` if the page does not set a
98
+ * `lang` or sets a bare un-regioned
99
+ * one (WProofreader requires
100
+ * region-qualified codes).
101
+ * @param {string[]} [options.surfaces] Connector-only. Subset of
102
+ * ['questions','items','author'].
103
+ * Default: `['author']`
104
+ * (assessment-safe). The connector
105
+ * walks each editor's ancestry to
106
+ * detect which Learnosity API placed
107
+ * it (`lrn-author*` → author,
108
+ * `lrn_player*` → items, otherwise →
109
+ * questions) and skips editors whose
110
+ * surface is not in the allowed list.
111
+ * @param {boolean} [options.enableShorttext] Connector-only. Default false. When
112
+ * true, also attach to `shorttext`
113
+ * inputs (`<input type="text">`)
114
+ * inside Learnosity scope. Off by
115
+ * default because plain text inputs
116
+ * are often short and proofreading
117
+ * them feels noisy.
118
+ * @param {string[]} [options.customSelectors] Connector-only. Extra ancestor CSS
119
+ * selectors that count as "inside
120
+ * Learnosity scope" for unusual host
121
+ * apps.
122
+ * @param {string} [options.bundleUrl] Connector-only. Override the
123
+ * WProofreader bundle URL.
124
+ */
125
+ function init(options) {
126
+ options = options || {};
127
+ const wp = options.wproofreader || {};
128
+
129
+ if (!wp.serviceId && !wp.serviceHost) {
130
+ if (typeof console !== 'undefined' && console.warn) {
131
+ console.warn(
132
+ '[wproofreader-learnosity] No wproofreader.serviceId or wproofreader.serviceHost ' +
133
+ 'provided. Proofreading will not activate. Pass serviceId for WProofreader Cloud, or ' +
134
+ 'serviceHost (plus serviceProtocol, servicePort, servicePath) for a self-hosted deployment.'
135
+ );
136
+ }
137
+ return;
138
+ }
139
+
140
+ const config = buildConfig(options);
141
+
142
+ if (initialized) {
143
+ // Idempotent: update the live WProofreader config in place so the observer's
144
+ // captured config keeps the same identity, while WProofreader's bundle picks
145
+ // up the new values next time it reads `window.WEBSPELLCHECKER_CONFIG`.
146
+ Object.assign(window.WEBSPELLCHECKER_CONFIG, config.wproofreader);
147
+ Object.assign(currentConnectorConfig, config);
148
+ return;
149
+ }
150
+
151
+ /*
152
+ * WProofreader's bundle reads its runtime config from
153
+ * `window.WEBSPELLCHECKER_CONFIG` when it boots, so the object must exist
154
+ * before the bundle `<script>` runs. `autoSearch` is intentionally absent
155
+ * from the config: we attach explicitly via `observeLearnosity` below so
156
+ * attachment stays scoped to Learnosity-rendered editors only.
157
+ */
158
+ window.WEBSPELLCHECKER_CONFIG = config.wproofreader;
159
+ currentConnectorConfig = config;
160
+ loadBundle(options.bundleUrl || DEFAULT_BUNDLE_URL);
161
+ observeLearnosity(config);
162
+ initialized = true;
163
+ }
164
+
165
+ /*
166
+ * Two-tier config:
167
+ * - `wproofreader`: everything WProofreader's bundle reads from
168
+ * `window.WEBSPELLCHECKER_CONFIG`. Spread-then-default so any unknown
169
+ * option (self-hosted service*, future WProofreader knobs) flows through
170
+ * unchanged, while our defaults (enableGrammar, autocorrect, lang) apply
171
+ * only when the caller did not supply them.
172
+ * - Top-level: connector-only fields the observer reads (surfaces,
173
+ * enableShorttext, customSelectors). `bundleUrl` is consumed by
174
+ * `loadBundle()` directly from `options` and is not stored here.
175
+ */
176
+ function buildConfig(options) {
177
+ const wp = options.wproofreader || {};
178
+ return {
179
+ wproofreader: {
180
+ ...wp,
181
+ enableGrammar: wp.enableGrammar !== false,
182
+ autocorrect: wp.autocorrect !== false,
183
+ lang: wp.lang || defaultLang()
184
+ },
185
+ surfaces: normalizeSurfaces(options.surfaces),
186
+ enableShorttext: options.enableShorttext === true,
187
+ customSelectors: Array.isArray(options.customSelectors) ? options.customSelectors.slice() : []
188
+ };
189
+ }
190
+
191
+ /*
192
+ * Default WProofreader language. Read from the host page's `<html lang>` so a
193
+ * Learnosity activity rendered into a French page proofreads in French without
194
+ * the host app having to pass `lang` explicitly. Normalise `en-US` style to
195
+ * `en_US` (WProofreader's expected separator).
196
+ *
197
+ * WProofreader requires a region-qualified code: bare `en` is rejected with
198
+ * "Language 'en' is unsupported or doesn't exist." So we only use the host
199
+ * page's lang when it includes a region; otherwise we fall back to `en_US`.
200
+ */
201
+ function defaultLang() {
202
+ if (typeof document === 'undefined') return 'en_US';
203
+ const html = document.documentElement;
204
+ if (!html || !html.lang) return 'en_US';
205
+ const lang = html.lang;
206
+ if (lang.indexOf('-') === -1 && lang.indexOf('_') === -1) return 'en_US';
207
+ return lang.replace('-', '_');
208
+ }
209
+
210
+ /*
211
+ * Coerce caller input into a valid subset of ALLOWED_SURFACES. Non-arrays
212
+ * fall back to DEFAULT_SURFACES; unrecognised entries are dropped silently.
213
+ */
214
+ function normalizeSurfaces(input) {
215
+ if (!Array.isArray(input)) return DEFAULT_SURFACES.slice();
216
+ return input.filter(function (s) { return ALLOWED_SURFACES.indexOf(s) !== -1; });
217
+ }
218
+
219
+ /*
220
+ * Cheap page-level pre-check: is any allowed-surface Learnosity global on the
221
+ * page at all? If not, no point doing any per-editor work.
222
+ */
223
+ function isAnyAllowedSurfaceLoaded(surfaces) {
224
+ if (typeof window === 'undefined') return false;
225
+ if (surfaces.length === 0) return false;
226
+ for (let i = 0; i < surfaces.length; i++) {
227
+ const g = SURFACE_GLOBALS[surfaces[i]];
228
+ if (g && typeof window[g] !== 'undefined') return true;
229
+ }
230
+ return false;
231
+ }
232
+
233
+ /*
234
+ * Per-editor surface detection. Walks the editor's ancestry looking for the
235
+ * smallest set of class names that distinguish each Learnosity API in the
236
+ * rendered DOM. Markers confirmed from `Learnosity/learnosity-apis-css`:
237
+ *
238
+ * - Author API uses the hyphenated `lrn-author` / `lrn-author-*` prefix
239
+ * exclusively (no other API does).
240
+ * - Items API (Assess) wraps its player in `.lrn_player`, also unique.
241
+ * - Everything else `lrn_*` falls through to Questions API as the catch-all.
242
+ *
243
+ * Best effort. If Learnosity renames these between LTS versions, the update
244
+ * is isolated to this function. The `customSelectors` option is the escape
245
+ * hatch for unusual host apps.
246
+ */
247
+ function detectSurface(el) {
248
+ let cur = el && el.parentElement;
249
+ while (cur) {
250
+ const list = cur.classList;
251
+ if (list && list.length) {
252
+ for (let i = 0; i < list.length; i++) {
253
+ const cls = list[i];
254
+ if (cls.indexOf('lrn-author') === 0) return 'author';
255
+ if (cls.indexOf('lrn_player') === 0) return 'items';
256
+ }
257
+ }
258
+ cur = cur.parentElement;
259
+ }
260
+ return 'questions';
261
+ }
262
+
263
+ /*
264
+ * Learnosity rewrites the host-page placeholder span during render and does
265
+ * not preserve our `learnosity-response` class on the result. So instead of
266
+ * scoping by a single anchor class, we walk up the DOM from each candidate
267
+ * editable and check for any ancestor whose class matches Learnosity's
268
+ * conventions ("learnosity-response" exact, or any class starting with "lrn").
269
+ * Caller-supplied selectors (customSelectors) extend the scope for unusual
270
+ * host apps without giving up the Learnosity-internal scoping for the rest.
271
+ */
272
+ function hasLearnosityAncestor(el, customSelectors) {
273
+ let cur = el && el.parentElement;
274
+ while (cur) {
275
+ const list = cur.classList;
276
+ if (list) {
277
+ for (let i = 0; i < list.length; i++) {
278
+ const cls = list[i];
279
+ if (cls === 'learnosity-response' || cls.indexOf('lrn') === 0) return true;
280
+ }
281
+ }
282
+ if (customSelectors && customSelectors.length && cur.matches) {
283
+ for (let j = 0; j < customSelectors.length; j++) {
284
+ try { if (cur.matches(customSelectors[j])) return true; } catch (_) { /* bad selector */ }
285
+ }
286
+ }
287
+ cur = cur.parentElement;
288
+ }
289
+ return false;
290
+ }
291
+
292
+ /*
293
+ * Inject the WProofreader bundle `<script>` exactly once per page. The
294
+ * `data-wproofreader-bundle` attribute serves as the idempotency marker.
295
+ */
296
+ function loadBundle(url) {
297
+ if (typeof document === 'undefined') return;
298
+ if (document.querySelector('script[data-wproofreader-bundle]')) return;
299
+ const script = document.createElement('script');
300
+ script.src = url;
301
+ script.async = true;
302
+ script.dataset.wproofreaderBundle = '';
303
+ document.head.appendChild(script);
304
+ }
305
+
306
+ /*
307
+ * Watch the document for editables appearing or disappearing inside Learnosity
308
+ * scope. On addition, attach. On removal, detach so SPA hosts that rebuild
309
+ * Learnosity widgets do not leak WProofreader instances.
310
+ */
311
+ function observeLearnosity(config) {
312
+ if (typeof document === 'undefined') return;
313
+
314
+ const scanRoot = function (root) { scanForEditables(root, config); };
315
+ scanRoot(document);
316
+
317
+ const observer = new MutationObserver(function (mutations) {
318
+ for (let i = 0; i < mutations.length; i++) {
319
+ const m = mutations[i];
320
+ if (m.type === 'childList') {
321
+ const added = m.addedNodes;
322
+ for (let j = 0; j < added.length; j++) {
323
+ const node = added[j];
324
+ if (node && node.nodeType === 1 /* ELEMENT_NODE */) scanRoot(node);
325
+ }
326
+ const removed = m.removedNodes;
327
+ for (let k = 0; k < removed.length; k++) {
328
+ const node = removed[k];
329
+ if (node && node.nodeType === 1) detachInSubtree(node);
330
+ }
331
+ } else if (m.type === 'attributes' && m.attributeName === 'contenteditable') {
332
+ const el = m.target;
333
+ if (el && matchesCandidate(el, config) && hasLearnosityAncestor(el, config.customSelectors)) {
334
+ attachToContainer(el, config);
335
+ }
336
+ }
337
+ }
338
+ });
339
+ observer.observe(document.body || document.documentElement, {
340
+ childList: true,
341
+ subtree: true,
342
+ // Catch editors whose `contenteditable` flips to truthy after insertion.
343
+ attributes: true,
344
+ attributeFilter: ['contenteditable']
345
+ });
346
+ }
347
+
348
+ /*
349
+ * Find every editable under `root` that sits inside a Learnosity-rendered
350
+ * subtree, and attach. `root` can be the document or a freshly-added subtree.
351
+ */
352
+ function scanForEditables(root, config) {
353
+ if (!root || !root.querySelectorAll) return;
354
+ const candidates = [];
355
+ if (matchesCandidate(root, config)) candidates.push(root);
356
+ const inside = root.querySelectorAll(candidateSelector(config));
357
+ for (let i = 0; i < inside.length; i++) candidates.push(inside[i]);
358
+
359
+ for (let i = 0; i < candidates.length; i++) {
360
+ const el = candidates[i];
361
+ if (hasLearnosityAncestor(el, config.customSelectors)) attachToContainer(el, config);
362
+ }
363
+ }
364
+
365
+ /*
366
+ * Selector for "could plausibly be an editable WProofreader should attach to."
367
+ * Always includes iframes (CKEditor classic) and truthy contenteditables.
368
+ * Includes plain text inputs only when enableShorttext is on.
369
+ */
370
+ function candidateSelector(config) {
371
+ let s = 'iframe, [contenteditable]:not([contenteditable="false"])';
372
+ if (config.enableShorttext) s += ', input[type="text"], input:not([type])';
373
+ return s;
374
+ }
375
+
376
+ /*
377
+ * Element-level equivalent of `candidateSelector`. Used by the
378
+ * attribute-mutation branch of `observeLearnosity`, where we already have the
379
+ * target element and don't want to re-query the DOM.
380
+ */
381
+ function matchesCandidate(el, config) {
382
+ if (!el || el.nodeType !== 1) return false;
383
+ if (el.tagName === 'IFRAME') return true;
384
+ if (el.tagName === 'INPUT' && config.enableShorttext) {
385
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
386
+ return t === 'text';
387
+ }
388
+ if (!el.hasAttribute || !el.hasAttribute('contenteditable')) return false;
389
+ return el.getAttribute('contenteditable') !== 'false';
390
+ }
391
+
392
+ /*
393
+ * Initialise WProofreader against a container element. Three gates before we
394
+ * actually attach:
395
+ * - dedupe against `attachedContainers` (no double-attaches across observer
396
+ * events).
397
+ * - page-level pre-check: at least one allowed-surface Learnosity global
398
+ * must be loaded.
399
+ * - per-editor surface filter: `detectSurface(container)` must be in
400
+ * `config.surfaces`.
401
+ * Then, for an iframe whose document is not yet loaded, defer until the
402
+ * `load` event so WProofreader sees the editable body. WProofreader's bundle
403
+ * may still be loading, so we also wait for `window.WEBSPELLCHECKER` to
404
+ * appear. The returned instance is tracked so we can destroy it later when
405
+ * the host element is removed.
406
+ */
407
+ function attachToContainer(container, config) {
408
+ if (!container || attachedContainers.has(container)) return;
409
+ if (!isAnyAllowedSurfaceLoaded(config.surfaces)) return;
410
+ const surface = detectSurface(container);
411
+ if (config.surfaces.indexOf(surface) === -1) return;
412
+ attachedContainers.add(container);
413
+
414
+ const go = function () {
415
+ whenAvailable('WEBSPELLCHECKER', function (WSC) {
416
+ const initArgs = Object.assign({}, config.wproofreader, { container: container });
417
+ try {
418
+ WSC.init(initArgs, function (instance) {
419
+ if (instance) containerToInstance.set(container, instance);
420
+ });
421
+ } catch (_) { /* WProofreader handled the failure internally */ }
422
+ });
423
+ };
424
+
425
+ const isIframe = container.tagName && container.tagName.toLowerCase() === 'iframe';
426
+ if (isIframe) {
427
+ let doc = null;
428
+ try { doc = container.contentDocument; } catch (_) { /* cross-origin */ }
429
+ if (doc && doc.readyState === 'complete') {
430
+ go();
431
+ } else {
432
+ container.addEventListener('load', go, { once: true });
433
+ }
434
+ } else {
435
+ go();
436
+ }
437
+ }
438
+
439
+ /*
440
+ * Walk a removed subtree, find any container we previously attached, destroy
441
+ * its WProofreader instance, and forget it. WeakSet/WeakMap don't enumerate,
442
+ * so we re-walk the DOM with `has()` checks instead.
443
+ */
444
+ function detachInSubtree(root) {
445
+ if (!root) return;
446
+ if (attachedContainers.has(root)) detachFromContainer(root);
447
+ if (root.querySelectorAll) {
448
+ // Match the broadest possible candidate selector so we catch anything we
449
+ // could have attached to under any config.
450
+ const candidates = root.querySelectorAll('iframe, [contenteditable], input[type="text"], input:not([type])');
451
+ for (let i = 0; i < candidates.length; i++) {
452
+ if (attachedContainers.has(candidates[i])) detachFromContainer(candidates[i]);
453
+ }
454
+ }
455
+ }
456
+
457
+ /*
458
+ * Destroy the WProofreader instance attached to `container` and forget the
459
+ * mapping. Safe to call on containers we never attached to.
460
+ */
461
+ function detachFromContainer(container) {
462
+ const inst = containerToInstance.get(container);
463
+ if (inst) {
464
+ try {
465
+ if (typeof inst.destroy === 'function') inst.destroy();
466
+ } catch (_) { /* swallow */ }
467
+ containerToInstance.delete(container);
468
+ }
469
+ attachedContainers.delete(container);
470
+ }
471
+
472
+ /*
473
+ * Resolve a global once it appears on `window`, with a hard timeout so a
474
+ * missing script does not leak an interval forever.
475
+ */
476
+ function whenAvailable(globalName, callback) {
477
+ if (typeof window === 'undefined') return;
478
+ if (window[globalName]) { callback(window[globalName]); return; }
479
+ const interval = setInterval(function () {
480
+ if (window[globalName]) {
481
+ clearInterval(interval);
482
+ clearTimeout(timeout);
483
+ callback(window[globalName]);
484
+ }
485
+ }, GLOBAL_POLL_INTERVAL_MS);
486
+ const timeout = setTimeout(function () {
487
+ clearInterval(interval);
488
+ if (typeof console !== 'undefined' && console.warn) {
489
+ console.warn('[wproofreader-learnosity] window.' + globalName +
490
+ ' never appeared. Proofreading will not activate.');
491
+ }
492
+ }, GLOBAL_WAIT_TIMEOUT_MS);
493
+ }
494
+
495
+ /*
496
+ * Public API. Exported as a default object so the import shape mirrors the
497
+ * call site:
498
+ *
499
+ * import LearnosityWProofreader from '@webspellchecker/wproofreader-learnosity';
500
+ * LearnosityWProofreader.init({ wproofreader: { serviceId: '...' } });
501
+ */
502
+ export default { init };