Skip to content
This repository was archived by the owner on Feb 15, 2026. It is now read-only.

Commit 9cc528d

Browse files
committed
Add i18n support
1 parent acdf291 commit 9cc528d

6 files changed

Lines changed: 1164 additions & 845 deletions

File tree

src/captcha-loader.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
const isDevelopment = window.__SWETRIX_CAPTCHA_DEV || false
33

44
const CAPTCHA_SELECTOR = '.swecaptcha'
5+
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'pl', 'uk'] as const
6+
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
7+
const DEFAULT_LOCALE: SupportedLocale = 'en'
58
const LIGHT_CAPTCHA_IFRAME_URL = isDevelopment ? './light.html' : 'https://cdn.swetrixcaptcha.com/pages/light'
69
const DARK_CAPTCHA_IFRAME_URL = isDevelopment ? './dark.html' : 'https://cdn.swetrixcaptcha.com/pages/dark'
710
const DEFAULT_RESPONSE_INPUT_NAME = 'swetrix-captcha-response'
@@ -42,6 +45,70 @@ const detectPreferredTheme = (): 'light' | 'dark' => {
4245
return 'light'
4346
}
4447

48+
const isSupportedLocale = (locale: string): locale is SupportedLocale => {
49+
return SUPPORTED_LOCALES.includes(locale as SupportedLocale)
50+
}
51+
52+
const normalizeLocale = (locale: string): SupportedLocale => {
53+
const lowered = locale.toLowerCase()
54+
const primary = lowered.split('-')[0].split('_')[0]
55+
56+
if (isSupportedLocale(primary)) {
57+
return primary
58+
}
59+
60+
return DEFAULT_LOCALE
61+
}
62+
63+
const detectBrowserLocale = (): SupportedLocale => {
64+
if (typeof navigator === 'undefined') {
65+
return DEFAULT_LOCALE
66+
}
67+
68+
const languages = navigator.languages || [navigator.language]
69+
70+
for (const lang of languages) {
71+
const normalized = normalizeLocale(lang)
72+
if (normalized !== DEFAULT_LOCALE || lang.toLowerCase().startsWith('en')) {
73+
return normalized
74+
}
75+
}
76+
77+
return DEFAULT_LOCALE
78+
}
79+
80+
const findHtmlLangAttribute = (element: Element): string | null => {
81+
let current: Element | null = element
82+
83+
while (current) {
84+
const lang = current.getAttribute('lang')
85+
if (lang) {
86+
return lang
87+
}
88+
current = current.parentElement
89+
}
90+
91+
return null
92+
}
93+
94+
const detectLanguage = (container: Element): SupportedLocale => {
95+
// First priority: data-lang attribute on the widget element
96+
const forcedLang = container.getAttribute('data-lang')
97+
console.log('forcedLang', forcedLang)
98+
if (forcedLang) {
99+
return normalizeLocale(forcedLang)
100+
}
101+
102+
// Second priority: lang attribute on ancestor elements (e.g., <html lang="de">)
103+
const ancestorLang = findHtmlLangAttribute(container.parentElement as Element)
104+
if (ancestorLang) {
105+
return normalizeLocale(ancestorLang)
106+
}
107+
108+
// Fallback: browser language
109+
return detectBrowserLocale()
110+
}
111+
45112
const resolveTheme = (theme: string | null): 'light' | 'dark' => {
46113
if (!theme || theme === 'auto') {
47114
return detectPreferredTheme()
@@ -233,6 +300,7 @@ const parseParams = (container: Element): object => {
233300
pid: container.getAttribute('data-project-id'),
234301
respName: container.getAttribute('data-response-input-name') || DEFAULT_RESPONSE_INPUT_NAME,
235302
theme: container.getAttribute('data-theme') || DEFAULT_THEME,
303+
lang: detectLanguage(container),
236304
}
237305

238306
// Optional custom API URL

src/captcha.ts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { getTranslations, normalizeLocale, detectBrowserLocale, type SupportedLocale } from './i18n'
2+
import { logger } from './logger'
3+
14
export {}
25

36
// @ts-ignore
@@ -51,7 +54,24 @@ interface PowProgress {
5154

5255
let activeAction: ACTION = ACTION.checkbox
5356
let powWorker: Worker | null = null
54-
let progressStartTime: number = 0
57+
58+
const getLocaleFromUrl = (): SupportedLocale => {
59+
const urlParams = new URLSearchParams(window.location.search)
60+
const lang = urlParams.get('lang')
61+
62+
logger.log('lang', lang)
63+
64+
if (lang) {
65+
return normalizeLocale(lang)
66+
}
67+
68+
return detectBrowserLocale()
69+
}
70+
71+
const currentLocale = getLocaleFromUrl()
72+
const t = getTranslations(currentLocale)
73+
74+
logger.log('t:', t)
5575

5676
const sendMessageToLoader = (event: IFRAME_MESSAGE_TYPES, data = {}) => {
5777
window.parent.postMessage(
@@ -123,28 +143,25 @@ const updateAriaState = (action: ACTION) => {
123143
captchaComponent.setAttribute('role', 'checkbox')
124144
captchaComponent.setAttribute('aria-checked', 'false')
125145
captchaComponent.setAttribute('aria-busy', 'false')
126-
captchaComponent.setAttribute(
127-
'aria-label',
128-
'Human verification checkbox. Press Enter or Space to verify you are human.',
129-
)
146+
captchaComponent.setAttribute('aria-label', t.ariaCheckbox)
130147
break
131148
case ACTION.loading:
132149
captchaComponent.setAttribute('role', 'status')
133150
captchaComponent.setAttribute('aria-checked', 'mixed')
134151
captchaComponent.setAttribute('aria-busy', 'true')
135-
captchaComponent.setAttribute('aria-label', 'Verifying that you are human. Please wait.')
152+
captchaComponent.setAttribute('aria-label', t.ariaVerifying)
136153
break
137154
case ACTION.completed:
138155
captchaComponent.setAttribute('role', 'checkbox')
139156
captchaComponent.setAttribute('aria-checked', 'true')
140157
captchaComponent.setAttribute('aria-busy', 'false')
141-
captchaComponent.setAttribute('aria-label', 'Verification successful. You have been verified as human.')
158+
captchaComponent.setAttribute('aria-label', t.ariaSuccess)
142159
break
143160
case ACTION.failure:
144161
captchaComponent.setAttribute('role', 'checkbox')
145162
captchaComponent.setAttribute('aria-checked', 'false')
146163
captchaComponent.setAttribute('aria-busy', 'false')
147-
captchaComponent.setAttribute('aria-label', 'Verification failed. Press Enter or Space to try again.')
164+
captchaComponent.setAttribute('aria-label', t.ariaFailed)
148165
break
149166
}
150167

@@ -153,13 +170,13 @@ const updateAriaState = (action: ACTION) => {
153170
if (srStatus) {
154171
switch (action) {
155172
case ACTION.loading:
156-
srStatus.textContent = 'Verification in progress. Please wait.'
173+
srStatus.textContent = t.srLoading
157174
break
158175
case ACTION.completed:
159-
srStatus.textContent = 'Verification successful!'
176+
srStatus.textContent = t.srSuccess
160177
break
161178
case ACTION.failure:
162-
srStatus.textContent = 'Verification failed. Please try again.'
179+
srStatus.textContent = t.srFailed
163180
break
164181
default:
165182
srStatus.textContent = ''
@@ -208,7 +225,6 @@ const activateAction = (action: ACTION) => {
208225
updateProgressBar(0) // Hide progress bar on failure
209226
} else if (action === 'loading') {
210227
statusComputing?.classList.remove('hidden')
211-
progressStartTime = Date.now()
212228
updateProgressBar(-1) // Start with indeterminate progress
213229
} else if (action === 'completed') {
214230
statusDefault?.classList.remove('hidden')
@@ -255,7 +271,8 @@ const generateChallenge = async (): Promise<PowChallenge | null> => {
255271
}
256272

257273
return await response.json()
258-
} catch (e) {
274+
} catch (reason) {
275+
logger.error('Failed to generate challenge:', reason)
259276
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
260277
activateAction(ACTION.failure)
261278
return null
@@ -289,7 +306,8 @@ const verifySolution = async (challenge: string, nonce: number, solution: string
289306
}
290307

291308
return data.token
292-
} catch (e) {
309+
} catch (reason) {
310+
logger.error('Failed to verify solution:', reason)
293311
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
294312
activateAction(ACTION.failure)
295313
return null
@@ -325,7 +343,8 @@ const solveChallenge = async (challenge: PowChallenge): Promise<void> => {
325343

326344
try {
327345
powWorker = new Worker(WORKER_URL)
328-
} catch (e) {
346+
} catch (reason) {
347+
logger.warn('Failed to create worker:', reason)
329348
// Fallback: solve in main thread if worker fails
330349
solveInMainThread(challenge).then(resolve).catch(reject)
331350
return
@@ -348,7 +367,7 @@ const solveChallenge = async (challenge: PowChallenge): Promise<void> => {
348367

349368
if (data.type === 'timeout') {
350369
// Worker timed out or hit max iterations
351-
console.error('PoW worker timeout:', (data as { type: 'timeout'; reason: string }).reason)
370+
logger.error('PoW worker timeout:', (data as { type: 'timeout'; reason: string }).reason)
352371
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
353372
activateAction(ACTION.failure)
354373
powWorker?.terminate()
@@ -377,7 +396,7 @@ const solveChallenge = async (challenge: PowChallenge): Promise<void> => {
377396
// Handle error message from worker
378397
if (data.type === 'error') {
379398
const errorData = data as { type: 'error'; message?: string }
380-
console.error('PoW worker error message:', errorData.message || 'Unknown error')
399+
logger.error('PoW worker error message:', errorData.message || 'Unknown error')
381400
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
382401
activateAction(ACTION.failure)
383402
powWorker?.terminate()
@@ -387,7 +406,7 @@ const solveChallenge = async (challenge: PowChallenge): Promise<void> => {
387406
}
388407

389408
// Fallback for unexpected message types
390-
console.warn('PoW worker received unexpected message type:', (data as { type?: unknown }).type, 'Raw data:', data)
409+
logger.warn('PoW worker received unexpected message type:', (data as { type?: unknown }).type, 'Raw data:', data)
391410
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
392411
activateAction(ACTION.failure)
393412
powWorker?.terminate()
@@ -396,7 +415,7 @@ const solveChallenge = async (challenge: PowChallenge): Promise<void> => {
396415
}
397416

398417
powWorker.onerror = (error) => {
399-
console.error('PoW worker error:', error)
418+
logger.error('PoW worker error:', error)
400419
powWorker?.terminate()
401420
powWorker = null
402421

@@ -438,7 +457,7 @@ const solveInMainThread = async (challenge: PowChallenge): Promise<void> => {
438457
// Check overall timeout
439458
const elapsedMs = Date.now() - startTime
440459
if (elapsedMs >= TIMEOUT_MS) {
441-
console.error(`PoW main-thread timeout: ${TIMEOUT_MS}ms elapsed after ${nonce} attempts`)
460+
logger.error(`PoW main-thread timeout: ${TIMEOUT_MS}ms elapsed after ${nonce} attempts`)
442461
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
443462
activateAction(ACTION.failure)
444463
return
@@ -469,7 +488,7 @@ const solveInMainThread = async (challenge: PowChallenge): Promise<void> => {
469488
}
470489

471490
// Max iterations reached without finding solution
472-
console.error(`PoW main-thread max iterations reached: ${MAX_ITERATIONS} attempts`)
491+
logger.error(`PoW main-thread max iterations reached: ${MAX_ITERATIONS} attempts`)
473492
sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
474493
activateAction(ACTION.failure)
475494
}
@@ -498,10 +517,26 @@ const handleCaptchaActivation = async () => {
498517
await solveChallenge(challenge)
499518
}
500519

520+
const applyTranslations = () => {
521+
const statusDefault = document.querySelector('#status-default')
522+
const statusFailure = document.querySelector('#status-failure')
523+
const statusComputing = document.querySelector('#status-computing span')
524+
const progressBarContainer = document.querySelector('#progress-bar-container')
525+
526+
if (statusDefault) statusDefault.textContent = t.iAmHuman
527+
if (statusFailure) statusFailure.textContent = t.verificationFailed
528+
if (statusComputing) statusComputing.textContent = t.verifying
529+
if (progressBarContainer) progressBarContainer.setAttribute('aria-label', t.ariaProgress)
530+
531+
document.documentElement.lang = currentLocale
532+
}
533+
501534
document.addEventListener('DOMContentLoaded', () => {
502535
const captchaComponent = document.querySelector('#swetrix-captcha')
503536
const branding = document.querySelector('#branding')
504537

538+
applyTranslations()
539+
505540
branding?.addEventListener('click', (e: Event) => {
506541
e.stopPropagation()
507542
})

0 commit comments

Comments
 (0)