@prosopo/client-bundle-example 2.10.22 → 2.10.24

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @prosopo/client-bundle-example@2.10.22 build:cjs
2
+ > @prosopo/client-bundle-example@2.10.24 build:cjs
3
3
  > NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV
4
4
 
5
5
  ViteCommonJSConfig: .
@@ -32,4 +32,4 @@ rendering chunks...
32
32
  computing gzip size...
33
33
  dist/cjs/index.cjs 1.41 kB │ gzip: 0.64 kB
34
34
 
35
- ✓ built in 50ms
35
+ ✓ built in 51ms
@@ -1,8 +1,8 @@
1
1
 
2
- > @prosopo/client-bundle-example@2.10.22 build:tsc
2
+ > @prosopo/client-bundle-example@2.10.24 build:tsc
3
3
  > tsc --build --verbose
4
4
 
5
- 10:09:56 PM - Projects in this build:
5
+ 1:04:08 PM - Projects in this build:
6
6
  * ../../dev/config/tsconfig.json
7
7
  * ../../packages/util/tsconfig.json
8
8
  * ../../packages/logger/tsconfig.json
@@ -12,21 +12,21 @@
12
12
  * ../../packages/types/tsconfig.json
13
13
  * tsconfig.json
14
14
 
15
- 10:09:56 PM - Project '../../dev/config/tsconfig.json' is up to date because newest input '../../dev/config/src/webpack/webpack.config.ts' is older than output '../../dev/config/tsconfig.tsbuildinfo'
15
+ 1:04:08 PM - Project '../../dev/config/tsconfig.json' is up to date because newest input '../../dev/config/src/webpack/webpack.config.ts' is older than output '../../dev/config/tsconfig.tsbuildinfo'
16
16
 
17
- 10:09:57 PM - Project '../../packages/util/tsconfig.json' is up to date because newest input '../../packages/util/src/url.ts' is older than output '../../packages/util/tsconfig.tsbuildinfo'
17
+ 1:04:08 PM - Project '../../packages/util/tsconfig.json' is up to date because newest input '../../packages/util/src/url.ts' is older than output '../../packages/util/tsconfig.tsbuildinfo'
18
18
 
19
- 10:09:57 PM - Project '../../packages/logger/tsconfig.json' is up to date because newest input '../../packages/logger/src/index.ts' is older than output '../../packages/logger/tsconfig.tsbuildinfo'
19
+ 1:04:08 PM - Project '../../packages/logger/tsconfig.json' is up to date because newest input '../../packages/logger/src/logger.ts' is older than output '../../packages/logger/tsconfig.tsbuildinfo'
20
20
 
21
- 10:09:57 PM - Project '../../packages/dotenv/tsconfig.json' is up to date because newest input '../../packages/dotenv/src/index.ts' is older than output '../../packages/dotenv/tsconfig.tsbuildinfo'
21
+ 1:04:08 PM - Project '../../packages/dotenv/tsconfig.json' is up to date because newest input '../../packages/dotenv/src/env.ts' is older than output '../../packages/dotenv/tsconfig.tsbuildinfo'
22
22
 
23
- 10:09:57 PM - Project '../../packages/locale/tsconfig.json' is up to date because newest input '../../packages/locale/src/translationKey.ts' is older than output '../../packages/locale/tsconfig.tsbuildinfo'
23
+ 1:04:08 PM - Project '../../packages/locale/tsconfig.json' is up to date because newest input '../../packages/locale/src/translationKey.ts' is older than output '../../packages/locale/tsconfig.tsbuildinfo'
24
24
 
25
- 10:09:57 PM - Project '../../packages/util-crypto/tsconfig.json' is up to date because newest input '../../packages/util-crypto/src/types.ts' is older than output '../../packages/util-crypto/tsconfig.tsbuildinfo'
25
+ 1:04:08 PM - Project '../../packages/util-crypto/tsconfig.json' is up to date because newest input '../../packages/util-crypto/src/types.ts' is older than output '../../packages/util-crypto/tsconfig.tsbuildinfo'
26
26
 
27
- 10:09:57 PM - Project '../../packages/types/tsconfig.json' is up to date because newest input '../../packages/types/src/types.test-d.ts' is older than output '../../packages/types/tsconfig.tsbuildinfo'
27
+ 1:04:08 PM - Project '../../packages/types/tsconfig.json' is up to date because newest input '../../packages/types/src/types.test-d.ts' is older than output '../../packages/types/tsconfig.tsbuildinfo'
28
28
 
29
- 10:09:57 PM - Project 'tsconfig.json' is out of date because output file 'tsconfig.tsbuildinfo' does not exist
29
+ 1:04:08 PM - Project 'tsconfig.json' is out of date because output file 'tsconfig.tsbuildinfo' does not exist
30
30
 
31
- 10:09:57 PM - Building project '/home/runner/work/captcha/captcha/demos/client-bundle-example/tsconfig.json'...
31
+ 1:04:08 PM - Building project '/home/runner/work/captcha/captcha/demos/client-bundle-example/tsconfig.json'...
32
32
 
@@ -1,9 +1,9 @@
1
1
 
2
- > @prosopo/client-bundle-example@2.10.22 build
2
+ > @prosopo/client-bundle-example@2.10.24 build
3
3
  > npm run build:cross-env -- --mode ${NODE_ENV:-development}
4
4
 
5
5
 
6
- > @prosopo/client-bundle-example@2.10.22 build:cross-env
6
+ > @prosopo/client-bundle-example@2.10.24 build:cross-env
7
7
  > vite build --config vite.esm.config.ts --mode production
8
8
 
9
9
  ViteEsmConfig: .
@@ -37,4 +37,4 @@ computing gzip size...
37
37
  dist/_virtual/_rolldown/runtime.js 0.04 kB │ gzip: 0.06 kB
38
38
  dist/index.js 1.47 kB │ gzip: 0.68 kB
39
39
 
40
- ✓ built in 112ms
40
+ ✓ built in 124ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @prosopo/client-bundle-example
2
2
 
3
+ ## 2.10.24
4
+ ### Patch Changes
5
+
6
+ - f4e4a83: chore(deps): roll up the open dependabot bumps (react 19.3, mongoose 9.10, @polkadot/util 14, redis 6, cron-parser 5, react-i18next 17 with i18next 26, @scure/base 2, cypress 16, rollup/babel plugin majors, vitest 4.1.11, angular 20.3.28, js-yaml)
7
+
8
+ ## 2.10.23
9
+ ### Patch Changes
10
+
11
+ - 89dd38a: chore(deps): batch the outstanding dependabot bumps into one upgrade
12
+
13
+ Rolls up dependabot PRs #3112, #3127-#3134 and #3159. Majors: `mongoose`
14
+ 8 -> 9, `bson` 6 -> 7, `@noble/curves` 1 -> 2, `@polkadot/util-crypto`
15
+ 13 -> 14, `@typegoose/auto-increment` 4 -> 5, `@babel/preset-env` 7 -> 8,
16
+ `@types/jsdom` 21 -> 30, `@types/bcrypt` 5 -> 6, `@actions/github` 6 -> 9,
17
+ `testcontainers` 11 -> 12. The rest are minor/patch.
18
+
19
+ Code changes the majors forced:
20
+ - `@noble/curves` v2 requires `.js` specifiers and renamed the point API,
21
+ so `secp256k1.ProjectivePoint.fromHex(...).toRawBytes()` becomes
22
+ `secp256k1.Point.fromBytes(...).toBytes()`, `RistrettoPoint` becomes
23
+ `ristretto255.Point`, and `abstract/utils` moves to `utils.js`.
24
+ - mongoose 9 drops `RootFilterQuery` (now `QueryFilter`), no longer sets
25
+ `background: true` on schema indexes by default, and no longer declares
26
+ `id` on `Document`, which un-hid a mismatch between
27
+ `updateDappUserCommitment`'s `Hash` parameter and the `string` `id` it
28
+ filters on.
29
+ - mongoose 9 rejects an aggregation-pipeline update (an array) unless the
30
+ call passes `updatePipeline: true`, so the six pipeline writes in
31
+ `ProviderDatabase` now opt in explicitly.
32
+ - mongoose 9's `castUpdate` throws on a `$setOnInsert` key inside `$set`.
33
+ `storeUserImageCaptchaSolution` passed its record straight in as the
34
+ update, and mongoose's `moveImmutableProperties` mutates that object on
35
+ an upsert -- adding the very `$setOnInsert` key the record then carried
36
+ into `CentralDbStreamer.streamImageRecord`. Image records stopped
37
+ reaching the central DB (the streamer is fire-and-forget, so it only
38
+ logged) and signup verification returned 500. The update is now an
39
+ explicit `$set` over a shallow copy.
40
+ - `@prosopo/database` moves from mongodb 6.20 to 7.5 to match the driver
41
+ mongoose 9 pulls, so bson 7 is the only copy resolvable in the package.
42
+ - `vitest`/`@vitest/coverage-v8` go to 4.1.11 alongside dependabot's
43
+ `@vitest/spy` bump; leaving them at 4.1.10 installed a second copy of
44
+ `@vitest/spy` and broke type inference in the provider test utils.
45
+ - 80f73c1: Sites can now control when the widget starts working.
46
+
47
+ By default the widget runs bot detection, starts the behavioural collectors and calls `/frictionless` as soon as it mounts. Rendering with `data-start-mode="manual"` (or `startMode: "manual"` in the render options) keeps all of that off the page load: the checkbox still appears immediately, at its final size, so nothing shifts, but the widget does nothing else until one of two things happens.
48
+
49
+ - The site calls `window.procaptcha.start()`, optionally with a widget id, or dispatches a `procaptcha:start` event on `document`. The frictionless flow runs and the widget then waits for a click exactly as it does today.
50
+ - The visitor clicks the checkbox. The frictionless flow runs and whichever challenge the provider chooses opens straight away, carrying that click's position, so the visitor is never asked to click twice.
51
+
52
+ Both triggers are one-shot: whichever comes first wins and the other is ignored. `window.procaptcha.execute()` also starts a manual widget, opening its challenge immediately. Widgets in the default `auto` mode are unaffected.
53
+
3
54
  ## 2.10.22
4
55
  ### Patch Changes
5
56
 
package/package.json CHANGED
@@ -25,26 +25,26 @@
25
25
  "start": "NODE_ENV=${NODE_ENV:-development}; vite serve ./src --port 9232 --config vite.config.ts --mode=$NODE_ENV",
26
26
  "test": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --config ./vite.test.config.ts"
27
27
  },
28
- "version": "2.10.22",
28
+ "version": "2.10.24",
29
29
  "devDependencies": {
30
30
  "@emotion/cache": "11.14.0",
31
- "@prosopo/config": "3.3.12",
32
- "@prosopo/dotenv": "3.0.53",
33
- "@prosopo/locale": "3.3.1",
34
- "@prosopo/types": "5.2.6",
31
+ "@prosopo/config": "3.3.17",
32
+ "@prosopo/dotenv": "3.0.56",
33
+ "@prosopo/locale": "3.4.3",
34
+ "@prosopo/types": "5.8.4",
35
35
  "@types/node": "22.5.5",
36
- "@vitest/coverage-v8": "4.1.10",
36
+ "@vitest/coverage-v8": "4.1.11",
37
37
  "concurrently": "9.0.1",
38
38
  "del-cli": "6.0.0",
39
39
  "jsdom": "25.0.0",
40
40
  "npm-run-all": "4.1.5",
41
- "react": "19.2.8",
42
- "react-dom": "19.2.8",
41
+ "react": "19.3.0",
42
+ "react-dom": "19.3.0",
43
43
  "tslib": "2.7.0",
44
44
  "tsx": "4.20.3",
45
45
  "typescript": "5.6.2",
46
46
  "vite": "8.1.5",
47
- "vitest": "4.1.10"
47
+ "vitest": "4.1.11"
48
48
  },
49
49
  "repository": {
50
50
  "type": "git",
@@ -193,9 +193,17 @@
193
193
  // Get the Element using elementId
194
194
  const captchaContainer = document.getElementById('procaptcha-container');
195
195
 
196
+ // ?placement=float anchors the challenge to the widget; the default is popup.
197
+ const placement =
198
+ new URLSearchParams(window.location.search).get("placement") === "float"
199
+ ? "float"
200
+ : "popup";
201
+ window.updateCaptchaStatus('Challenge placement: ' + placement, 'info');
202
+
196
203
  // Render the CAPTCHA
197
204
  const widgetId = render(captchaContainer, {
198
205
  siteKey: import.meta.env.PROSOPO_SITE_KEY_FRICTIONLESS,
206
+ placement,
199
207
  callback: onCaptchaVerified,
200
208
  "failed-callback": function () {
201
209
  console.log('Challenge failed');
@@ -0,0 +1,224 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <link href="https://cdn.muicss.com/mui-0.10.3/css/mui.min.css" rel="stylesheet" type="text/css"/>
5
+ <link href="styles/field.css" rel="stylesheet" type="text/css"/>
6
+
7
+ <script src="index.js"></script>
8
+ <title>Procaptcha demo: Frictionless - Manual Start</title>
9
+ <link href="styles/captcha.css" rel="stylesheet" type="text/css"/>
10
+ <script type="module">
11
+ // Function to update CAPTCHA status display is now provided by status-log-injector
12
+
13
+ window.onStartCaptcha = () => {
14
+ updateCaptchaStatus('window.procaptcha.start() called - frictionless flow starting', 'info');
15
+ window.procaptcha.start();
16
+ };
17
+
18
+ window.addEventListener('load', () => {
19
+
20
+ window.setIsError = (isError) => {
21
+ const messageContainer = document.getElementById('messageContainer');
22
+ messageContainer.style.color = isError ? 'red' : 'black';
23
+ messageContainer.style.display = 'block';
24
+ };
25
+
26
+ window.setMessage = (message) => {
27
+ document.getElementById('message').innerText = message;
28
+ };
29
+
30
+ window.onLoggedIn = (token) => {
31
+ updateCaptchaStatus('Logged in, fetching private resource', 'info');
32
+ const url = new URL("/private", config.serverUrl).href;
33
+ console.log("getting private resource with token ", token, "at", url);
34
+ fetch(url, {
35
+ method: "GET",
36
+ headers: {
37
+ Origin: "http://localhost:9232", // TODO: change this to env var
38
+ "Content-Type": "application/json",
39
+ Authorization: `Bearer ${token}`,
40
+ },
41
+ })
42
+ .then(async (res) => {
43
+ try {
44
+ const jsonRes = await res.json();
45
+ if (res.status === 200) {
46
+ updateCaptchaStatus(`Successfully accessed private resource`, 'success');
47
+ setMessage(jsonRes.message);
48
+ } else {
49
+ updateCaptchaStatus(`Failed to access private resource: ${res.status}`, 'error');
50
+ }
51
+ } catch (err) {
52
+ console.log(err);
53
+ updateCaptchaStatus(`Error parsing response: ${err}`, 'error');
54
+ }
55
+ })
56
+ .catch((err) => {
57
+ console.log(err);
58
+ updateCaptchaStatus(`Fetch error: ${err}`, 'error');
59
+ });
60
+ };
61
+
62
+ window.onActionHandler = () => {
63
+ updateCaptchaStatus('Form submission initiated', 'info');
64
+
65
+ const procaptchaElements = document.getElementsByName('procaptcha-response');
66
+
67
+ if (!procaptchaElements.length) {
68
+ updateCaptchaStatus('Error: No CAPTCHA response found', 'error');
69
+ alert("Must complete captcha");
70
+ return
71
+ }
72
+
73
+ const procaptchaToken = procaptchaElements[0].value;
74
+ updateCaptchaStatus(`Token received: ${procaptchaToken.substring(0, 15)}...`, 'success');
75
+
76
+ const payload = {
77
+ email: document.getElementById('email').value,
78
+ name: document.getElementById('name').value,
79
+ password: document.getElementById('password').value,
80
+ siteKey: import.meta.env.PROSOPO_SITE_KEY_FRICTIONLESS,
81
+ "procaptcha-response": procaptchaToken,
82
+ };
83
+ const url = new URL('signup', import.meta.env.PROSOPO_SERVER_URL).href;
84
+ console.log("posting to", url, "with payload", payload);
85
+ fetch(url, {
86
+ method: "POST",
87
+ headers: {
88
+ "Content-Type": "application/json",
89
+ },
90
+ body: JSON.stringify(payload),
91
+ contentType: "application/json",
92
+ }).then((response) => {
93
+ return new Promise((resolve) => response.json()
94
+ .then((json) => resolve({
95
+ status: response.status,
96
+ ok: response.ok,
97
+ json,
98
+ })))
99
+ })
100
+ .then(async ({status, json, ok}) => {
101
+ console.log("status", status, "json", json.message, "ok", ok);
102
+ console.log("json", json)
103
+ try {
104
+ if (status !== 200) {
105
+ updateCaptchaStatus(`API Error: ${json.message}`, 'error');
106
+ setIsError(true);
107
+ setMessage(json.message);
108
+ } else {
109
+ updateCaptchaStatus(`API Success: ${json.message}`, 'success');
110
+ setIsError(false);
111
+ setMessage(json.message);
112
+ }
113
+ } catch (err) {
114
+ console.log(err);
115
+ updateCaptchaStatus(`Error processing response: ${err}`, 'error');
116
+ }
117
+ })
118
+ .catch((err) => {
119
+ console.log(err);
120
+ updateCaptchaStatus(`Fetch error: ${err}`, 'error');
121
+ setIsError(true);
122
+ setMessage("Error: " + err);
123
+ });
124
+ };
125
+
126
+ updateCaptchaStatus('Page loaded - Initializing CAPTCHA system', 'info');
127
+
128
+ // Monitor DOM for CAPTCHA initialization
129
+ const observer = new MutationObserver((mutations) => {
130
+ mutations.forEach((mutation) => {
131
+ if (mutation.addedNodes.length) {
132
+ for (let i = 0; i < mutation.addedNodes.length; i++) {
133
+ const node = mutation.addedNodes[i];
134
+ if (node.classList && (node.classList.contains('procaptcha') ||
135
+ node.querySelector && node.querySelector('.procaptcha'))) {
136
+ updateCaptchaStatus('CAPTCHA DOM elements initialized', 'info');
137
+ observer.disconnect();
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ });
143
+ });
144
+
145
+ observer.observe(document.body, { childList: true, subtree: true });
146
+ });
147
+
148
+
149
+ window.onCaptchaFailed = function () {
150
+ console.log('Challenge failed');
151
+ updateCaptchaStatus('Challenge failed - CAPTCHA verification could not be completed', 'error');
152
+ }
153
+
154
+ window.onCaptchaVerified = (output) => {
155
+ console.log('Challenge passed');
156
+ updateCaptchaStatus('Challenge passed successfully!', 'success');
157
+ updateCaptchaStatus(`Token generated: ${output.substring(0, 15)}...`, 'success');
158
+ }
159
+
160
+ // Add custom event listener for CAPTCHA verification stages
161
+ document.addEventListener('DOMContentLoaded', function() {
162
+ setTimeout(() => {
163
+ updateCaptchaStatus('CAPTCHA script loaded and initialized', 'info');
164
+ updateCaptchaStatus('Waiting for user interaction...', 'info');
165
+ }, 500);
166
+ });
167
+ </script>
168
+ <script id="procaptchaScript" type="module" src="%VITE_BUNDLE_URL%" async defer></script>
169
+ </head>
170
+ <body>
171
+
172
+ <div class="mui-container">
173
+ <h1>Frictionless CAPTCHA - Manual Start</h1>
174
+ <p>
175
+ This example renders the widget with <code>data-start-mode="manual"</code>. The checkbox appears
176
+ immediately at its final size, but no bot detection, behavioural collection or provider request runs
177
+ until either the page calls <code>window.procaptcha.start()</code> (the button below) or the visitor
178
+ clicks the checkbox. A click that starts the flow also opens whichever challenge the provider picks,
179
+ so the visitor never has to click twice.
180
+ </p>
181
+
182
+ <!-- CAPTCHA Status Display will be injected by the status-log-injector plugin -->
183
+
184
+ <form action="%PROSOPO_SERVER_URL%/signup" method="POST" class="mui-form">
185
+ <h2>Example Login Form</h2>
186
+ <div class="mui-textfield mui-textfield--float-label">
187
+ <label for="name">Name</label>
188
+ <input id="name" type="text" name="name" required/>
189
+ </div>
190
+ <div class="mui-textfield mui-textfield--float-label">
191
+ <label for="email">Email Address</label>
192
+ <input id="email" type="email" name="email" required/>
193
+ </div>
194
+ <div class="mui-textfield mui-textfield--float-label">
195
+ <label for="password">Password</label>
196
+ <input id="password" type="password" name="password" required/>
197
+ </div>
198
+ <div class="mui-textfield mui-textfield--float-label">
199
+ <!-- Dev sitekey -->
200
+ <div
201
+ class="procaptcha"
202
+ data-theme="light"
203
+ data-sitekey="%PROSOPO_SITE_KEY_FRICTIONLESS%"
204
+ data-start-mode="manual"
205
+ data-failed-callback="onCaptchaFailed"
206
+ data-callback="onCaptchaVerified"
207
+ ></div>
208
+ </div>
209
+ <div class="mui-textfield">
210
+ <button type="button" class="mui-btn mui-btn--flat" onclick="onStartCaptcha()" data-cy="start-captcha-button">
211
+ Start CAPTCHA now
212
+ </button>
213
+ </div>
214
+ <div id="messageContainer" style="display: none; color: black;"><span id="message"></span></div>
215
+ <button type="button" class="mui-btn mui-btn--raised" onclick="onActionHandler()" data-cy="submit-button">Submit</button>
216
+ </form>
217
+
218
+ <!-- Console output display area -->
219
+ <div id="console-output" class="console-output" style="display: none;"></div>
220
+
221
+ <!-- Explanation will be injected by the explanation-injector plugin -->
222
+ </div>
223
+ </body>
224
+ </html>
@@ -135,8 +135,16 @@
135
135
  window.updateCaptchaStatus('Initializing CAPTCHA with explicit render call', 'info');
136
136
 
137
137
  // Render the CAPTCHA
138
+ // ?placement=float anchors the challenge to the widget; the default is popup.
139
+ const placement =
140
+ new URLSearchParams(window.location.search).get("placement") === "float"
141
+ ? "float"
142
+ : "popup";
143
+ window.updateCaptchaStatus('Challenge placement: ' + placement, 'info');
144
+
138
145
  const widgetId = render(document.getElementById('procaptcha-container'), {
139
146
  siteKey: import.meta.env.PROSOPO_SITE_KEY_IMAGE,
147
+ placement,
140
148
  callback: handleCaptchaResponse,
141
149
  "failed-callback": handleCaptchaFailed
142
150
  });
@@ -47,10 +47,12 @@ export default function navigationInjector(): Plugin {
47
47
  frictionless: {
48
48
  implicit: { path: "frictionless-implicit.html", exists: true },
49
49
  explicit: { path: "frictionless-explicit.html", exists: true },
50
+ manual: { path: "frictionless-manual-start.html", exists: true },
50
51
  },
51
52
  puzzle: {
52
53
  implicit: { path: "puzzle-implicit.html", exists: true },
53
54
  explicit: { path: "puzzle-explicit.html", exists: true },
55
+ bound: { path: "puzzle-bind-explicit.html", exists: true },
54
56
  },
55
57
  },
56
58
  invisible: {
@@ -0,0 +1,153 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ // Vite plugin to inject a popup/float placement switcher into HTML files.
15
+ //
16
+ // Placement is resolved once, when the widget renders, so switching it means
17
+ // re-rendering. The switcher therefore drives `?placement=` and reloads rather
18
+ // than mutating a live widget. The explicit pages read that query parameter
19
+ // themselves and pass `placement` as a render option; implicit pages have no
20
+ // script of their own, so the injected script stamps `data-placement` onto
21
+ // their containers instead, which exercises the attribute path.
22
+ import type { IndexHtmlTransformContext, Plugin } from "vite";
23
+
24
+ export default function placementInjector(): Plugin {
25
+ const placementCss = `
26
+ <style>
27
+ .placement-switcher {
28
+ margin: 20px 0;
29
+ padding: 12px 15px;
30
+ background-color: #f0f8ff;
31
+ border: 2px solid #2196F3;
32
+ border-radius: 5px;
33
+ font-family: monospace;
34
+ font-size: 14px;
35
+ }
36
+ .placement-switcher-title {
37
+ font-weight: bold;
38
+ margin-bottom: 10px;
39
+ border-bottom: 1px solid #2196F3;
40
+ padding-bottom: 5px;
41
+ }
42
+ .placement-option {
43
+ display: inline-block;
44
+ text-decoration: none;
45
+ margin-right: 8px;
46
+ padding: 6px 14px;
47
+ border: 1px solid #2196F3;
48
+ border-radius: 4px;
49
+ background-color: white;
50
+ color: #2196F3;
51
+ cursor: pointer;
52
+ font-family: inherit;
53
+ font-size: inherit;
54
+ }
55
+ .placement-option[aria-current="page"] {
56
+ background-color: #2196F3;
57
+ color: white;
58
+ font-weight: bold;
59
+ }
60
+ .placement-note {
61
+ margin-top: 10px;
62
+ color: #555;
63
+ }
64
+ </style>
65
+ `;
66
+
67
+ const placementHtml = `
68
+ <!-- Challenge placement switcher -->
69
+ <div id="placement-switcher" class="placement-switcher">
70
+ <div class="placement-switcher-title">Challenge placement</div>
71
+ <a class="placement-option" data-placement-option="popup" href="?placement=popup">popup</a>
72
+ <a class="placement-option" data-placement-option="float" href="?placement=float">float</a>
73
+ <div id="placement-note" class="placement-note"></div>
74
+ </div>
75
+ `;
76
+
77
+ // Runs before DOMContentLoaded, so the attribute is on the container by the
78
+ // time the bundle's implicit render reads it.
79
+ const placementJs = `
80
+ <script>
81
+ (function () {
82
+ var requested = new URLSearchParams(window.location.search).get("placement");
83
+ var placement = requested === "float" ? "float" : "popup";
84
+
85
+ // Buttons are the invisible variant, which resolves float back to
86
+ // popup -- stamped anyway so the downgrade is visible rather than
87
+ // untested.
88
+ var containers = document.getElementsByClassName("procaptcha");
89
+ for (var i = 0; i < containers.length; i++) {
90
+ containers[i].setAttribute("data-placement", placement);
91
+ }
92
+
93
+ var invisible = containers.length > 0 &&
94
+ document.querySelectorAll('.procaptcha[data-size="invisible"], button.procaptcha').length > 0;
95
+
96
+ var note = document.getElementById("placement-note");
97
+ if (note) {
98
+ note.textContent = placement === "float" && invisible
99
+ ? "float requested, but an invisible widget has nothing to anchor to, so the challenge opens as a popup."
100
+ : placement === "float"
101
+ ? "The challenge opens directly above the widget and stays pinned there while you scroll. The page stays usable behind it."
102
+ : "The challenge opens centred over the page. This is the default.";
103
+ }
104
+
105
+ // Links rather than buttons, and not only because navigation is what
106
+ // they do: several specs reach for an element with
107
+ // button[type='button']:nth-of-type(2), which a pair of injected
108
+ // buttons answers to, and following one mid-test reloads the page.
109
+ var options = document.querySelectorAll("[data-placement-option]");
110
+ for (var j = 0; j < options.length; j++) {
111
+ var option = options[j];
112
+ var value = option.getAttribute("data-placement-option");
113
+ // Rebuilt from the live URL so the switch keeps any other query
114
+ // parameters the page was opened with.
115
+ var url = new URL(window.location.href);
116
+ url.searchParams.set("placement", value);
117
+ option.setAttribute("href", url.toString());
118
+ if (value === placement) option.setAttribute("aria-current", "page");
119
+ }
120
+ })();
121
+ </script>
122
+ `;
123
+
124
+ return {
125
+ name: "placement-injector",
126
+ transformIndexHtml: {
127
+ order: "post", // after the nav bar and status log are in place
128
+ handler(html: string, _ctx: IndexHtmlTransformContext): string {
129
+ if (!html.includes("<body") || !html.includes("</body>")) {
130
+ return html;
131
+ }
132
+
133
+ if (html.includes('id="placement-switcher"')) {
134
+ return html;
135
+ }
136
+
137
+ const withCss = html.replace("</head>", `${placementCss}</head>`);
138
+
139
+ // The switcher goes above the status log so the widget and its
140
+ // controls stay together, and the script goes last so every
141
+ // element it touches has been parsed.
142
+ const withSwitcher = withCss.includes('<div id="captcha-status"')
143
+ ? withCss.replace(
144
+ '<div id="captcha-status"',
145
+ `${placementHtml}<div id="captcha-status"`,
146
+ )
147
+ : withCss.replace("</body>", `${placementHtml}</body>`);
148
+
149
+ return withSwitcher.replace("</body>", `${placementJs}</body>`);
150
+ },
151
+ },
152
+ };
153
+ }