@silverassist/recaptcha 0.1.0 → 0.2.0
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 +18 -0
- package/README.md +209 -0
- package/dist/client/index.d.mts +25 -1
- package/dist/client/index.d.ts +25 -1
- package/dist/client/index.js +98 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +100 -7
- package/dist/client/index.mjs.map +1 -1
- package/dist/index.d.mts +12 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +98 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +100 -7
- package/dist/index.mjs.map +1 -1
- package/dist/types/index.d.mts +13 -0
- package/dist/types/index.d.ts +13 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-02-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Lazy loading support** for better page performance
|
|
13
|
+
- New `lazy` prop to defer reCAPTCHA script loading until form is visible
|
|
14
|
+
- New `lazyRootMargin` prop to configure IntersectionObserver threshold (default: "200px")
|
|
15
|
+
- Reduces initial JS by ~320KB and improves TBT/TTI metrics
|
|
16
|
+
- **Singleton script loading** prevents duplicate script loads across multiple forms
|
|
17
|
+
- Comprehensive test coverage for lazy loading scenarios
|
|
18
|
+
- Documentation for lazy loading with performance metrics
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Add IntersectionObserver feature detection with fallback to eager loading for older browsers/SSR environments
|
|
23
|
+
- Add error handling tests for non-lazy Script component to ensure proper callback notification
|
|
24
|
+
- Queued callbacks now properly receive error notifications on script load failure
|
|
25
|
+
|
|
8
26
|
## [0.1.0] - 2026-01-27
|
|
9
27
|
|
|
10
28
|
### Added
|
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Google reCAPTCHA v3 integration for Next.js applications with Server Actions sup
|
|
|
14
14
|
- ✅ **Auto Token Refresh**: Tokens refresh automatically before expiration
|
|
15
15
|
- ✅ **Graceful Degradation**: Works in development without credentials
|
|
16
16
|
- ✅ **Configurable Thresholds**: Custom score thresholds per form
|
|
17
|
+
- ✅ **Lazy Loading**: Optional lazy loading for better performance
|
|
18
|
+
- ✅ **Singleton Script Loading**: Prevents duplicate script loads across multiple forms
|
|
17
19
|
|
|
18
20
|
## Installation
|
|
19
21
|
|
|
@@ -92,6 +94,197 @@ export async function submitForm(formData: FormData) {
|
|
|
92
94
|
}
|
|
93
95
|
```
|
|
94
96
|
|
|
97
|
+
## ⚠️ Important: Custom FormData
|
|
98
|
+
|
|
99
|
+
`RecaptchaWrapper` injects a **hidden input field** containing the reCAPTCHA token. If your form handler creates a custom `FormData` object, you must ensure the hidden token is included.
|
|
100
|
+
|
|
101
|
+
### ❌ This will fail (token is missing):
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
"use client";
|
|
105
|
+
|
|
106
|
+
import { RecaptchaWrapper } from "@silverassist/recaptcha";
|
|
107
|
+
import { submitForm } from "./actions"; // Your server action
|
|
108
|
+
|
|
109
|
+
export function ContactForm() {
|
|
110
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
|
|
113
|
+
// ❌ Creating empty FormData - hidden reCAPTCHA input is NOT included!
|
|
114
|
+
const formData = new FormData();
|
|
115
|
+
formData.set("email", "user@example.com");
|
|
116
|
+
formData.set("message", "Hello");
|
|
117
|
+
|
|
118
|
+
await submitForm(formData);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<form onSubmit={handleSubmit}>
|
|
123
|
+
<RecaptchaWrapper action="contact_form" />
|
|
124
|
+
<input name="email" type="email" required />
|
|
125
|
+
<textarea name="message" required />
|
|
126
|
+
<button type="submit">Send</button>
|
|
127
|
+
</form>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### ✅ Recommended: Pass form element to capture all inputs
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
"use client";
|
|
136
|
+
|
|
137
|
+
import { RecaptchaWrapper } from "@silverassist/recaptcha";
|
|
138
|
+
import { submitForm } from "./actions"; // Your server action
|
|
139
|
+
|
|
140
|
+
export function ContactForm() {
|
|
141
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
|
|
144
|
+
// ✅ Pass form element - captures ALL inputs including hidden reCAPTCHA token
|
|
145
|
+
const formData = new FormData(e.currentTarget);
|
|
146
|
+
|
|
147
|
+
await submitForm(formData);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<form onSubmit={handleSubmit}>
|
|
152
|
+
<RecaptchaWrapper action="contact_form" />
|
|
153
|
+
<input name="email" type="email" required />
|
|
154
|
+
<textarea name="message" required />
|
|
155
|
+
<button type="submit">Send</button>
|
|
156
|
+
</form>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### ✅ Alternative: Start with form element, then modify
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
"use client";
|
|
165
|
+
|
|
166
|
+
import { RecaptchaWrapper } from "@silverassist/recaptcha";
|
|
167
|
+
import { submitForm } from "./actions"; // Your server action
|
|
168
|
+
|
|
169
|
+
export function ContactForm() {
|
|
170
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
|
|
173
|
+
// ✅ Start with form element (includes hidden token)
|
|
174
|
+
const formData = new FormData(e.currentTarget);
|
|
175
|
+
|
|
176
|
+
// Then add/override specific fields
|
|
177
|
+
formData.set("customField", "customValue");
|
|
178
|
+
formData.set("timestamp", Date.now().toString());
|
|
179
|
+
|
|
180
|
+
await submitForm(formData);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<form onSubmit={handleSubmit}>
|
|
185
|
+
<RecaptchaWrapper action="contact_form" />
|
|
186
|
+
<input name="email" type="email" required />
|
|
187
|
+
<textarea name="message" required />
|
|
188
|
+
<button type="submit">Send</button>
|
|
189
|
+
</form>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Performance Optimization: Lazy Loading
|
|
195
|
+
|
|
196
|
+
The `lazy` prop enables lazy loading of the reCAPTCHA script, which defers loading until the form becomes visible in the viewport. This significantly improves initial page load performance.
|
|
197
|
+
|
|
198
|
+
### Performance Benefits
|
|
199
|
+
|
|
200
|
+
> **Note**: These metrics are approximate values measured on production websites using Google reCAPTCHA v3. Actual performance improvements will vary based on network conditions, device capabilities, and page complexity.
|
|
201
|
+
|
|
202
|
+
| Metric | Without Lazy Loading | With Lazy Loading | Improvement |
|
|
203
|
+
|--------|---------------------|-------------------|-------------|
|
|
204
|
+
| **Initial JS** | 320KB+ | 0 KB (until visible) | -320KB |
|
|
205
|
+
| **TBT (Total Blocking Time)** | ~470ms | ~0ms (deferred) | -470ms |
|
|
206
|
+
| **TTI (Time to Interactive)** | +2-3s | Minimal impact | -2-3s |
|
|
207
|
+
|
|
208
|
+
### Basic Lazy Loading
|
|
209
|
+
|
|
210
|
+
Enable lazy loading by adding the `lazy` prop:
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
"use client";
|
|
214
|
+
|
|
215
|
+
import { RecaptchaWrapper } from "@silverassist/recaptcha";
|
|
216
|
+
|
|
217
|
+
export function ContactForm() {
|
|
218
|
+
return (
|
|
219
|
+
<form action={submitForm}>
|
|
220
|
+
{/* Script loads only when form is near viewport */}
|
|
221
|
+
<RecaptchaWrapper action="contact_form" lazy />
|
|
222
|
+
<input name="email" type="email" required />
|
|
223
|
+
<textarea name="message" required />
|
|
224
|
+
<button type="submit">Send</button>
|
|
225
|
+
</form>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Custom Root Margin
|
|
231
|
+
|
|
232
|
+
Control when the script loads with the `lazyRootMargin` prop (default: `"200px"`):
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
// Load script earlier (400px before entering viewport)
|
|
236
|
+
<RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
|
|
237
|
+
|
|
238
|
+
// Load script later (load only when fully visible)
|
|
239
|
+
<RecaptchaWrapper action="contact_form" lazy lazyRootMargin="0px" />
|
|
240
|
+
|
|
241
|
+
// Load with negative margin (load only after scrolling past)
|
|
242
|
+
<RecaptchaWrapper action="contact_form" lazy lazyRootMargin="-100px" />
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Best Practices
|
|
246
|
+
|
|
247
|
+
#### 1. Use lazy loading for below-the-fold forms
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
// Hero form (above the fold) - load immediately
|
|
251
|
+
<RecaptchaWrapper action="hero_signup" />
|
|
252
|
+
|
|
253
|
+
// Footer form (below the fold) - lazy load
|
|
254
|
+
<RecaptchaWrapper action="footer_contact" lazy />
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
#### 2. Multiple forms on the same page
|
|
258
|
+
|
|
259
|
+
The package automatically uses singleton script loading, so the script is only loaded once even with multiple forms:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
export function MultiFormPage() {
|
|
263
|
+
return (
|
|
264
|
+
<>
|
|
265
|
+
{/* First form triggers script load */}
|
|
266
|
+
<RecaptchaWrapper action="newsletter" lazy />
|
|
267
|
+
|
|
268
|
+
{/* Second form reuses the same script */}
|
|
269
|
+
<RecaptchaWrapper action="contact" lazy />
|
|
270
|
+
|
|
271
|
+
{/* Third form also reuses the script */}
|
|
272
|
+
<RecaptchaWrapper action="feedback" lazy />
|
|
273
|
+
</>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
#### 3. Adjust root margin based on form position
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
// Form near top - smaller margin for faster load
|
|
282
|
+
<RecaptchaWrapper action="signup" lazy lazyRootMargin="100px" />
|
|
283
|
+
|
|
284
|
+
// Form far down page - larger margin to load in advance
|
|
285
|
+
<RecaptchaWrapper action="newsletter" lazy lazyRootMargin="500px" />
|
|
286
|
+
```
|
|
287
|
+
|
|
95
288
|
## API Reference
|
|
96
289
|
|
|
97
290
|
### RecaptchaWrapper
|
|
@@ -107,9 +300,25 @@ Client component that loads reCAPTCHA and generates tokens.
|
|
|
107
300
|
refreshInterval={90000} // Optional: token refresh interval in ms (default: 90000)
|
|
108
301
|
onTokenGenerated={(token) => {}} // Optional: callback when token is generated
|
|
109
302
|
onError={(error) => {}} // Optional: callback on error
|
|
303
|
+
lazy={false} // Optional: enable lazy loading (default: false)
|
|
304
|
+
lazyRootMargin="200px" // Optional: IntersectionObserver rootMargin (default: "200px")
|
|
110
305
|
/>
|
|
111
306
|
```
|
|
112
307
|
|
|
308
|
+
#### Props
|
|
309
|
+
|
|
310
|
+
| Prop | Type | Default | Description |
|
|
311
|
+
|------|------|---------|-------------|
|
|
312
|
+
| `action` | `string` | **Required** | Action name for reCAPTCHA analytics (e.g., "contact_form", "signup") |
|
|
313
|
+
| `inputName` | `string` | `"recaptchaToken"` | Name attribute for the hidden input field |
|
|
314
|
+
| `inputId` | `string` | `"recaptcha-token"` | ID attribute for the hidden input field |
|
|
315
|
+
| `siteKey` | `string` | `process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY` | Override the site key from environment variable |
|
|
316
|
+
| `refreshInterval` | `number` | `90000` | Token refresh interval in milliseconds (90 seconds) |
|
|
317
|
+
| `onTokenGenerated` | `(token: string) => void` | `undefined` | Callback invoked when a new token is generated |
|
|
318
|
+
| `onError` | `(error: Error) => void` | `undefined` | Callback invoked when an error occurs |
|
|
319
|
+
| `lazy` | `boolean` | `false` | Enable lazy loading to defer script until form is visible |
|
|
320
|
+
| `lazyRootMargin` | `string` | `"200px"` | IntersectionObserver rootMargin (used when `lazy` is true) |
|
|
321
|
+
|
|
113
322
|
### validateRecaptcha
|
|
114
323
|
|
|
115
324
|
Server-side token validation function.
|
package/dist/client/index.d.mts
CHANGED
|
@@ -18,6 +18,10 @@ interface RecaptchaWrapperProps {
|
|
|
18
18
|
onTokenGenerated?: (token: string) => void;
|
|
19
19
|
/** Callback when an error occurs */
|
|
20
20
|
onError?: (error: Error) => void;
|
|
21
|
+
/** Enable lazy loading (default: false for backward compatibility) */
|
|
22
|
+
lazy?: boolean;
|
|
23
|
+
/** IntersectionObserver rootMargin for lazy loading (default: "200px") */
|
|
24
|
+
lazyRootMargin?: string;
|
|
21
25
|
}
|
|
22
26
|
/**
|
|
23
27
|
* Global window interface extension for reCAPTCHA
|
|
@@ -30,6 +34,15 @@ declare global {
|
|
|
30
34
|
action: string;
|
|
31
35
|
}) => Promise<string>;
|
|
32
36
|
};
|
|
37
|
+
/** Flag to track if reCAPTCHA script has loaded */
|
|
38
|
+
__recaptchaLoaded?: boolean;
|
|
39
|
+
/** Flag to track if reCAPTCHA script is currently loading */
|
|
40
|
+
__recaptchaLoading?: boolean;
|
|
41
|
+
/** Callbacks to execute when script finishes loading */
|
|
42
|
+
__recaptchaCallbacks?: Array<{
|
|
43
|
+
onLoad: () => void;
|
|
44
|
+
onError: (error: Error) => void;
|
|
45
|
+
}>;
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
48
|
|
|
@@ -42,6 +55,7 @@ declare global {
|
|
|
42
55
|
* - Refreshes token periodically (tokens expire after 2 minutes)
|
|
43
56
|
* - Stores token in hidden input field for form submission
|
|
44
57
|
* - Graceful fallback when not configured
|
|
58
|
+
* - Lazy loading support to defer script loading until visible
|
|
45
59
|
*
|
|
46
60
|
* @example Basic usage
|
|
47
61
|
* ```tsx
|
|
@@ -69,7 +83,17 @@ declare global {
|
|
|
69
83
|
* onError={(error) => console.error("Error:", error)}
|
|
70
84
|
* />
|
|
71
85
|
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example Lazy loading for better performance
|
|
88
|
+
* ```tsx
|
|
89
|
+
* <RecaptchaWrapper action="contact_form" lazy />
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @example Lazy loading with custom root margin
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
|
|
95
|
+
* ```
|
|
72
96
|
*/
|
|
73
|
-
declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
|
|
97
|
+
declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, lazy, lazyRootMargin, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
|
|
74
98
|
|
|
75
99
|
export { RecaptchaWrapper, RecaptchaWrapper as default };
|
package/dist/client/index.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ interface RecaptchaWrapperProps {
|
|
|
18
18
|
onTokenGenerated?: (token: string) => void;
|
|
19
19
|
/** Callback when an error occurs */
|
|
20
20
|
onError?: (error: Error) => void;
|
|
21
|
+
/** Enable lazy loading (default: false for backward compatibility) */
|
|
22
|
+
lazy?: boolean;
|
|
23
|
+
/** IntersectionObserver rootMargin for lazy loading (default: "200px") */
|
|
24
|
+
lazyRootMargin?: string;
|
|
21
25
|
}
|
|
22
26
|
/**
|
|
23
27
|
* Global window interface extension for reCAPTCHA
|
|
@@ -30,6 +34,15 @@ declare global {
|
|
|
30
34
|
action: string;
|
|
31
35
|
}) => Promise<string>;
|
|
32
36
|
};
|
|
37
|
+
/** Flag to track if reCAPTCHA script has loaded */
|
|
38
|
+
__recaptchaLoaded?: boolean;
|
|
39
|
+
/** Flag to track if reCAPTCHA script is currently loading */
|
|
40
|
+
__recaptchaLoading?: boolean;
|
|
41
|
+
/** Callbacks to execute when script finishes loading */
|
|
42
|
+
__recaptchaCallbacks?: Array<{
|
|
43
|
+
onLoad: () => void;
|
|
44
|
+
onError: (error: Error) => void;
|
|
45
|
+
}>;
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
48
|
|
|
@@ -42,6 +55,7 @@ declare global {
|
|
|
42
55
|
* - Refreshes token periodically (tokens expire after 2 minutes)
|
|
43
56
|
* - Stores token in hidden input field for form submission
|
|
44
57
|
* - Graceful fallback when not configured
|
|
58
|
+
* - Lazy loading support to defer script loading until visible
|
|
45
59
|
*
|
|
46
60
|
* @example Basic usage
|
|
47
61
|
* ```tsx
|
|
@@ -69,7 +83,17 @@ declare global {
|
|
|
69
83
|
* onError={(error) => console.error("Error:", error)}
|
|
70
84
|
* />
|
|
71
85
|
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example Lazy loading for better performance
|
|
88
|
+
* ```tsx
|
|
89
|
+
* <RecaptchaWrapper action="contact_form" lazy />
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @example Lazy loading with custom root margin
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
|
|
95
|
+
* ```
|
|
72
96
|
*/
|
|
73
|
-
declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
|
|
97
|
+
declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, lazy, lazyRootMargin, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
|
|
74
98
|
|
|
75
99
|
export { RecaptchaWrapper, RecaptchaWrapper as default };
|
package/dist/client/index.js
CHANGED
|
@@ -17,6 +17,39 @@ var RECAPTCHA_CONFIG = {
|
|
|
17
17
|
/** Default token refresh interval */
|
|
18
18
|
tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL
|
|
19
19
|
};
|
|
20
|
+
function loadRecaptchaScript(siteKey, onLoad, onError) {
|
|
21
|
+
if (typeof window !== "undefined" && window.__recaptchaLoaded) {
|
|
22
|
+
onLoad();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (typeof window !== "undefined" && window.__recaptchaLoading) {
|
|
26
|
+
window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
|
|
27
|
+
window.__recaptchaCallbacks.push({ onLoad, onError });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (typeof window !== "undefined") {
|
|
31
|
+
window.__recaptchaLoading = true;
|
|
32
|
+
window.__recaptchaCallbacks = [];
|
|
33
|
+
const script = document.createElement("script");
|
|
34
|
+
script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
|
|
35
|
+
script.async = true;
|
|
36
|
+
script.onload = () => {
|
|
37
|
+
window.__recaptchaLoaded = true;
|
|
38
|
+
window.__recaptchaLoading = false;
|
|
39
|
+
onLoad();
|
|
40
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
|
|
41
|
+
window.__recaptchaCallbacks = [];
|
|
42
|
+
};
|
|
43
|
+
script.onerror = () => {
|
|
44
|
+
window.__recaptchaLoading = false;
|
|
45
|
+
const error = new Error("Failed to load reCAPTCHA script");
|
|
46
|
+
onError(error);
|
|
47
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
|
|
48
|
+
window.__recaptchaCallbacks = [];
|
|
49
|
+
};
|
|
50
|
+
document.head.appendChild(script);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
20
53
|
function RecaptchaWrapper({
|
|
21
54
|
action,
|
|
22
55
|
inputName = "recaptchaToken",
|
|
@@ -24,11 +57,16 @@ function RecaptchaWrapper({
|
|
|
24
57
|
siteKey: propSiteKey,
|
|
25
58
|
refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,
|
|
26
59
|
onTokenGenerated,
|
|
27
|
-
onError
|
|
60
|
+
onError,
|
|
61
|
+
lazy = false,
|
|
62
|
+
lazyRootMargin = "200px"
|
|
28
63
|
}) {
|
|
29
64
|
const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
|
|
30
65
|
const tokenInputRef = react.useRef(null);
|
|
31
66
|
const refreshIntervalRef = react.useRef(null);
|
|
67
|
+
const containerRef = react.useRef(null);
|
|
68
|
+
const [isVisible, setIsVisible] = react.useState(!lazy);
|
|
69
|
+
const [scriptLoaded, setScriptLoaded] = react.useState(false);
|
|
32
70
|
const executeRecaptcha = react.useCallback(async () => {
|
|
33
71
|
if (!siteKey) {
|
|
34
72
|
return;
|
|
@@ -60,7 +98,49 @@ function RecaptchaWrapper({
|
|
|
60
98
|
}
|
|
61
99
|
}, [siteKey, action, onTokenGenerated, onError]);
|
|
62
100
|
react.useEffect(() => {
|
|
63
|
-
|
|
101
|
+
if (!lazy || !containerRef.current) return;
|
|
102
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
103
|
+
setIsVisible(true);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const observer = new IntersectionObserver(
|
|
107
|
+
([entry]) => {
|
|
108
|
+
if (entry.isIntersecting) {
|
|
109
|
+
setIsVisible(true);
|
|
110
|
+
observer.disconnect();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{ rootMargin: lazyRootMargin }
|
|
114
|
+
);
|
|
115
|
+
observer.observe(containerRef.current);
|
|
116
|
+
return () => observer.disconnect();
|
|
117
|
+
}, [lazy, lazyRootMargin]);
|
|
118
|
+
react.useEffect(() => {
|
|
119
|
+
if (!siteKey) return;
|
|
120
|
+
if (lazy) return;
|
|
121
|
+
if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
|
|
122
|
+
window.__recaptchaLoading = true;
|
|
123
|
+
window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
|
|
124
|
+
}
|
|
125
|
+
}, [siteKey, lazy]);
|
|
126
|
+
react.useEffect(() => {
|
|
127
|
+
if (!siteKey) return;
|
|
128
|
+
if (!lazy) return;
|
|
129
|
+
if (!isVisible) return;
|
|
130
|
+
const handleLoad = () => {
|
|
131
|
+
setScriptLoaded(true);
|
|
132
|
+
executeRecaptcha();
|
|
133
|
+
};
|
|
134
|
+
const handleError = (error) => {
|
|
135
|
+
console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
|
|
136
|
+
if (onError) {
|
|
137
|
+
onError(error);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
loadRecaptchaScript(siteKey, handleLoad, handleError);
|
|
141
|
+
}, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
|
|
142
|
+
react.useEffect(() => {
|
|
143
|
+
if (!scriptLoaded) return;
|
|
64
144
|
refreshIntervalRef.current = setInterval(() => {
|
|
65
145
|
executeRecaptcha();
|
|
66
146
|
}, refreshInterval);
|
|
@@ -69,7 +149,7 @@ function RecaptchaWrapper({
|
|
|
69
149
|
clearInterval(refreshIntervalRef.current);
|
|
70
150
|
}
|
|
71
151
|
};
|
|
72
|
-
}, [executeRecaptcha, refreshInterval]);
|
|
152
|
+
}, [scriptLoaded, executeRecaptcha, refreshInterval]);
|
|
73
153
|
if (!siteKey) {
|
|
74
154
|
if (process.env.NODE_ENV === "development") {
|
|
75
155
|
console.warn(
|
|
@@ -78,7 +158,7 @@ function RecaptchaWrapper({
|
|
|
78
158
|
}
|
|
79
159
|
return null;
|
|
80
160
|
}
|
|
81
|
-
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
161
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
|
|
82
162
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
83
163
|
"input",
|
|
84
164
|
{
|
|
@@ -89,15 +169,28 @@ function RecaptchaWrapper({
|
|
|
89
169
|
"data-testid": "recaptcha-token-input"
|
|
90
170
|
}
|
|
91
171
|
),
|
|
92
|
-
/* @__PURE__ */ jsxRuntime.jsx(
|
|
172
|
+
!lazy && /* @__PURE__ */ jsxRuntime.jsx(
|
|
93
173
|
Script__default.default,
|
|
94
174
|
{
|
|
95
175
|
src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
|
96
176
|
strategy: "afterInteractive",
|
|
97
177
|
onLoad: () => {
|
|
178
|
+
if (typeof window !== "undefined") {
|
|
179
|
+
window.__recaptchaLoaded = true;
|
|
180
|
+
window.__recaptchaLoading = false;
|
|
181
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
|
|
182
|
+
window.__recaptchaCallbacks = [];
|
|
183
|
+
}
|
|
184
|
+
setScriptLoaded(true);
|
|
98
185
|
executeRecaptcha();
|
|
99
186
|
},
|
|
100
187
|
onError: () => {
|
|
188
|
+
if (typeof window !== "undefined") {
|
|
189
|
+
window.__recaptchaLoading = false;
|
|
190
|
+
const error = new Error("Failed to load reCAPTCHA script");
|
|
191
|
+
window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
|
|
192
|
+
window.__recaptchaCallbacks = [];
|
|
193
|
+
}
|
|
101
194
|
console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
|
|
102
195
|
if (onError) {
|
|
103
196
|
onError(new Error("Failed to load reCAPTCHA script"));
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":["useRef","useCallback","useEffect","jsxs","Fragment","jsx","Script"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * reCAPTCHA Configuration Constants\n *\n * Default configuration values for reCAPTCHA v3 integration.\n *\n * @packageDocumentation\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * reCAPTCHA v3 Client Component\n *\n * Loads the Google reCAPTCHA script and generates tokens automatically.\n * Place inside a form to add invisible spam protection.\n *\n * @see https://developers.google.com/recaptcha/docs/v3\n * @packageDocumentation\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\n * />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n if (typeof window !== \"undefined\" && window.grecaptcha) {\n window.grecaptcha.ready(async () => {\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Generate token immediately when component mounts\n executeRecaptcha();\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [executeRecaptcha, refreshInterval]);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <>\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script */}\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n executeRecaptcha();\n }}\n onError={() => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n </>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":["useRef","useState","useCallback","useEffect","jsxs","jsx","Script"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * reCAPTCHA Configuration Constants\n *\n * Default configuration values for reCAPTCHA v3 integration.\n *\n * @packageDocumentation\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * reCAPTCHA v3 Client Component\n *\n * Loads the Google reCAPTCHA script and generates tokens automatically.\n * Place inside a form to add invisible spam protection.\n *\n * @see https://developers.google.com/recaptcha/docs/v3\n * @packageDocumentation\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * Load reCAPTCHA script manually (singleton pattern)\n * Ensures script is only loaded once globally\n */\nfunction loadRecaptchaScript(\n siteKey: string,\n onLoad: () => void,\n onError: (error: Error) => void\n): void {\n // Already loaded\n if (typeof window !== \"undefined\" && window.__recaptchaLoaded) {\n onLoad();\n return;\n }\n\n // Currently loading - add callbacks\n if (typeof window !== \"undefined\" && window.__recaptchaLoading) {\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n window.__recaptchaCallbacks.push({ onLoad, onError });\n return;\n }\n\n // Start loading\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = [];\n\n const script = document.createElement(\"script\");\n script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;\n script.async = true;\n\n script.onload = () => {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n onLoad();\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n };\n\n script.onerror = () => {\n window.__recaptchaLoading = false;\n const error = new Error(\"Failed to load reCAPTCHA script\");\n onError(error);\n // Notify all queued callbacks about the failure\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n };\n\n document.head.appendChild(script);\n }\n}\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n * - Lazy loading support to defer script loading until visible\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\n * />\n * ```\n *\n * @example Lazy loading for better performance\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy />\n * ```\n *\n * @example Lazy loading with custom root margin\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy lazyRootMargin=\"400px\" />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n lazy = false,\n lazyRootMargin = \"200px\",\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const [isVisible, setIsVisible] = useState(!lazy); // If not lazy, start visible\n const [scriptLoaded, setScriptLoaded] = useState(false);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n if (typeof window !== \"undefined\" && window.grecaptcha) {\n window.grecaptcha.ready(async () => {\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // IntersectionObserver for lazy loading\n useEffect(() => {\n if (!lazy || !containerRef.current) return;\n\n // Fallback to eager loading if IntersectionObserver is not supported\n // (older browsers, some SSR/test environments)\n if (typeof IntersectionObserver === \"undefined\") {\n setIsVisible(true);\n return;\n }\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setIsVisible(true);\n observer.disconnect();\n }\n },\n { rootMargin: lazyRootMargin }\n );\n\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, [lazy, lazyRootMargin]);\n\n // Mark loading flag for non-lazy mode to prevent duplicate loads\n useEffect(() => {\n if (!siteKey) return;\n if (lazy) return; // Only for non-lazy mode\n\n // Set loading flag before Script component loads\n if (typeof window !== \"undefined\" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n }\n }, [siteKey, lazy]);\n\n // Load script when visible (only for lazy mode)\n useEffect(() => {\n if (!siteKey) return;\n if (!lazy) return; // Only use manual loading for lazy mode\n if (!isVisible) return; // Wait until visible\n\n const handleLoad = () => {\n setScriptLoaded(true);\n executeRecaptcha();\n };\n\n const handleError = (error: Error) => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(error);\n }\n };\n\n loadRecaptchaScript(siteKey, handleLoad, handleError);\n }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Only set up refresh if script is loaded\n if (!scriptLoaded) return;\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [scriptLoaded, executeRecaptcha, refreshInterval]);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <div ref={containerRef} style={{ display: \"contents\" }}>\n {/* \n Note: display: contents makes this wrapper transparent to the DOM layout.\n The wrapper is needed for IntersectionObserver but shouldn't affect form layout.\n Browser support: https://caniuse.com/css-display-contents\n */}\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script using Next.js Script component for non-lazy mode */}\n {!lazy && (\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n // Mark script as loaded globally for singleton behavior\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n // Flush all queued callbacks from lazy instances\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n }\n setScriptLoaded(true);\n executeRecaptcha();\n }}\n onError={() => {\n // Mark loading as complete on error\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = false;\n // Notify all queued callbacks about the failure\n const error = new Error(\"Failed to load reCAPTCHA script\");\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n }\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n )}\n </div>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
|