From 92fe2cc2a6337a1b97711a8e3487e45472d2d753 Mon Sep 17 00:00:00 2001 From: galiprandi <20272796+galiprandi@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:12:33 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Quality:=20Improve=20useTranslator?= =?UTF-8?q?=20robustness=20and=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/hooks/useTranslator.coverage.test.ts | 446 +++++++++++++ lib/hooks/useTranslator.ts | 791 +++++++++++++++-------- 2 files changed, 976 insertions(+), 261 deletions(-) create mode 100644 lib/hooks/useTranslator.coverage.test.ts diff --git a/lib/hooks/useTranslator.coverage.test.ts b/lib/hooks/useTranslator.coverage.test.ts new file mode 100644 index 0000000..b77bcf2 --- /dev/null +++ b/lib/hooks/useTranslator.coverage.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { useTranslator } from './useTranslator' + +describe('useTranslator Coverage Gaps', () => { + beforeEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + + // Default mock navigator.userActivation to active + vi.stubGlobal('navigator', { + userActivation: { isActive: true }, + language: 'en-US', + }) + + if (typeof window !== 'undefined') { + const globals = ['Translator', 'LanguageDetector'] + globals.forEach((g) => { + delete (window as unknown as Record)[g] + }) + } + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('should handle LanguageDetector being a base constructor', async () => { + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = Object + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', text: 'test' }), + ) + + await waitFor(() => expect(result.current.status).toBe('idle')) + // It should fallback to 'en' + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle Translator being a base constructor', async () => { + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Object + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'en', targetLanguage: 'es' }), + ) + + await act(async () => { + await result.current.translate('test').catch(() => {}) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'Translator is not available', + ) + }) + + it('should handle LanguageDetector.detect returning empty results', async () => { + const mockLanguageDetector = { + detect: vi.fn().mockResolvedValue([]), + destroy: vi.fn(), + } + const LanguageDetector = function () {} + LanguageDetector.availability = vi.fn().mockResolvedValue('available') + LanguageDetector.create = vi + .fn() + .mockResolvedValue(mockLanguageDetector) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + + await act(async () => { + await result.current.translate('test') + }) + + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle navigator.userActivation.isActive being false', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: false }, + }) + + const Translator = function () {} + Translator.availability = vi.fn().mockResolvedValue('available') + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + const { result } = renderHook(() => + useTranslator({ + sourceLanguage: 'en', + targetLanguage: 'es', + warmup: false, + }), + ) + + await act(async () => { + await result.current.translate('test').catch(() => {}) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'User activation required', + ) + }) + + it('should handle warmup failure', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const Translator = function () {} + Translator.availability = vi + .fn() + .mockRejectedValue(new Error('Warmup failed')) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + renderHook(() => useTranslator({ warmup: true })) + + // Warmup is async, we wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)) + // Should not crash + consoleSpy.mockRestore() + }) + + it('should handle "downloading" availability state', async () => { + const Translator = function () {} + Translator.availability = vi.fn().mockResolvedValue('downloading') + Translator.create = vi.fn().mockResolvedValue({ + translate: vi.fn().mockResolvedValue('translated'), + destroy: vi.fn(), + }) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + const { result } = renderHook(() => + useTranslator({ + sourceLanguage: 'en', + targetLanguage: 'es', + streaming: false, + warmup: false, + }), + ) + + await act(async () => { + await result.current.translate('test') + }) + + expect(result.current.status).toBe('success') + }) + + it('should handle unsupported user language', async () => { + vi.stubGlobal('navigator', { + language: 'xx-XX', // Unsupported language + }) + + const { result } = renderHook(() => + useTranslator({ targetLanguage: 'user' }), + ) + + // It should fallback to 'en' + expect(result.current.resolvedTargetLanguage).toBe('en') + }) + + it('should handle non-Error rejection in translate', async () => { + const Translator = function () {} + Translator.availability = vi.fn().mockResolvedValue('available') + Translator.create = vi.fn().mockRejectedValue(new Error('String Error')) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + const { result } = renderHook(() => + useTranslator({ + sourceLanguage: 'en', + targetLanguage: 'es', + warmup: false, + }), + ) + + await act(async () => { + await result.current.translate('test').catch(() => {}) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toBe('String Error') + }) + + it('should handle LanguageDetector not being a function', async () => { + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = 'not a function' + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto' }), + ) + await act(async () => { + await result.current.translate('test') + }) + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle detected language not in SUPPORTED_LANGUAGES', async () => { + const mockLanguageDetector = { + detect: vi + .fn() + .mockResolvedValue([ + { detectedLanguage: 'xx', confidence: 0.9 }, + ]), + destroy: vi.fn(), + } + const LanguageDetector = function () {} + LanguageDetector.availability = vi.fn().mockResolvedValue('available') + LanguageDetector.create = vi + .fn() + .mockResolvedValue(mockLanguageDetector) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + + await act(async () => { + await result.current.translate('test') + }) + + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle Translator.availability being unavailable', async () => { + const Translator = function () {} + Translator.availability = vi.fn().mockResolvedValue('unavailable') + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + const { result } = renderHook(() => + useTranslator({ + sourceLanguage: 'en', + targetLanguage: 'es', + warmup: false, + }), + ) + + await act(async () => { + await result.current.translate('test').catch(() => {}) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'Translator is not available for en → es', + ) + }) + + it('should handle LanguageDetector.availability being unavailable', async () => { + const LanguageDetector = function () {} + LanguageDetector.availability = vi.fn().mockResolvedValue('unavailable') + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + + await act(async () => { + await result.current.translate('test') + }) + + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle cleanup on unmount when refs are set', async () => { + const mockDestroy = vi.fn() + const Translator = function () {} + Translator.availability = vi.fn().mockResolvedValue('available') + Translator.create = vi.fn().mockResolvedValue({ + translate: vi.fn().mockResolvedValue('done'), + destroy: mockDestroy, + }) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + const { result, unmount } = renderHook(() => + useTranslator({ + sourceLanguage: 'en', + targetLanguage: 'es', + warmup: false, + }), + ) + + await act(async () => { + await result.current.translate('test') + }) + + unmount() + expect(mockDestroy).toHaveBeenCalled() + }) + + it('should handle LanguageDetector.availability throwing error', async () => { + const LanguageDetector = function () {} + LanguageDetector.availability = vi + .fn() + .mockRejectedValue(new Error('fail')) + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + + await act(async () => { + await result.current.translate('test') + }) + + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle Translator.create not being a function', async () => { + const Translator = function () {} + Translator.availability = vi.fn().mockResolvedValue('available') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(Translator as any).create = undefined + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Translator = Translator + } + + const { result } = renderHook(() => + useTranslator({ + sourceLanguage: 'en', + targetLanguage: 'es', + warmup: false, + }), + ) + + await act(async () => { + await result.current.translate('test').catch(() => {}) + }) + + expect(result.current.status).toBe('error') + expect(result.current.error?.message).toContain( + 'Translator.create is not available', + ) + }) + + it('should handle LanguageDetector userActivation isActive false', async () => { + vi.stubGlobal('navigator', { + userActivation: { isActive: false }, + }) + const LanguageDetector = function () {} + LanguageDetector.availability = vi.fn().mockResolvedValue('available') + LanguageDetector.create = vi.fn() + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + await act(async () => { + await result.current.translate('test') + }) + expect(LanguageDetector.create).not.toHaveBeenCalled() + }) + + it('should handle LanguageDetector.create not being a function', async () => { + const LanguageDetector = function () {} + LanguageDetector.availability = vi.fn().mockResolvedValue('available') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(LanguageDetector as any).create = undefined + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + await act(async () => { + await result.current.translate('test') + }) + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) + + it('should handle LanguageDetector not having availability function', async () => { + const LanguageDetector = function () {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(LanguageDetector as any).availability = undefined + + if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).LanguageDetector = LanguageDetector + } + + const { result } = renderHook(() => + useTranslator({ sourceLanguage: 'auto', warmup: false }), + ) + await act(async () => { + await result.current.translate('test') + }) + expect(result.current.detectedSourceLanguage).toBeUndefined() + }) +}) diff --git a/lib/hooks/useTranslator.ts b/lib/hooks/useTranslator.ts index 75308d9..e0333d7 100644 --- a/lib/hooks/useTranslator.ts +++ b/lib/hooks/useTranslator.ts @@ -1,70 +1,179 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; -import type { Availability } from './useAI'; -import { getUserLanguage } from '../utilities/userLanguage'; +import { useState, useCallback, useRef, useEffect } from 'react' +import type { Availability } from './useAI' +import { getUserLanguage } from '../utilities/userLanguage' /** * Supported languages for translation (BCP 47 language codes). * Based on Chrome's Translator API implementation. */ +export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [ + 'ar', + 'bg', + 'bn', + 'cs', + 'da', + 'de', + 'el', + 'en', + 'es', + 'fi', + 'fr', + 'hi', + 'hr', + 'hu', + 'id', + 'it', + 'iw', + 'ja', + 'kn', + 'ko', + 'lt', + 'mr', + 'nl', + 'no', + 'pl', + 'pt', + 'ro', + 'ru', + 'sk', + 'sl', + 'sv', + 'ta', + 'te', + 'th', + 'tr', + 'uk', + 'vi', + 'zh', + 'zh-Hant', +] + +/** + * Supported languages for translation (BCP 47 language codes). + */ export type SupportedLanguage = - | 'ar' | 'bg' | 'bn' | 'cs' | 'da' | 'de' | 'el' | 'en' | 'es' | 'fi' | 'fr' - | 'hi' | 'hr' | 'hu' | 'id' | 'it' | 'iw' | 'ja' | 'kn' | 'ko' | 'lt' | 'mr' | 'nl' - | 'no' | 'pl' | 'pt' | 'ro' | 'ru' | 'sk' | 'sl' | 'sv' | 'ta' | 'te' | 'th' | 'tr' - | 'uk' | 'vi' | 'zh' | 'zh-Hant'; + | 'ar' + | 'bg' + | 'bn' + | 'cs' + | 'da' + | 'de' + | 'el' + | 'en' + | 'es' + | 'fi' + | 'fr' + | 'hi' + | 'hr' + | 'hu' + | 'id' + | 'it' + | 'iw' + | 'ja' + | 'kn' + | 'ko' + | 'lt' + | 'mr' + | 'nl' + | 'no' + | 'pl' + | 'pt' + | 'ro' + | 'ru' + | 'sk' + | 'sl' + | 'sv' + | 'ta' + | 'te' + | 'th' + | 'tr' + | 'uk' + | 'vi' + | 'zh' + | 'zh-Hant' /** * Configuration options for the Translator hook. */ export interface UseTranslatorOptions { - /** Text to translate. Auto-translates when changed */ - text?: string; - /** Source language code (BCP 47 format, e.g., 'en', 'es', 'fr'). Use 'auto' to detect from text (default) */ - sourceLanguage?: 'auto' | SupportedLanguage; - /** Target language code (BCP 47 format, e.g., 'en', 'es', 'fr'). Use 'user' for browser language (default) */ - targetLanguage?: 'user' | SupportedLanguage; - /** Enable streaming output for real-time results */ - streaming?: boolean; - /** Preload the model on component mount for faster first translation */ - warmup?: boolean; - /** Enable/disable auto-translation (default: true) */ - enable?: boolean; + /** Text to translate. Auto-translates when changed */ + text?: string + /** Source language code (BCP 47 format, e.g., 'en', 'es', 'fr'). Use 'auto' to detect from text (default) */ + sourceLanguage?: 'auto' | SupportedLanguage + /** Target language code (BCP 47 format, e.g., 'en', 'es', 'fr'). Use 'user' for browser language (default) */ + targetLanguage?: 'user' | SupportedLanguage + /** Enable streaming output for real-time results */ + streaming?: boolean + /** Preload the model on component mount for faster first translation */ + warmup?: boolean + /** Enable/disable auto-translation (default: true) */ + enable?: boolean } /** * Status of the translation process. */ -export type TranslationStatus = 'idle' | 'initializing' | 'downloading' | 'translating' | 'success' | 'error'; +export type TranslationStatus = + | 'idle' + | 'initializing' + | 'downloading' + | 'translating' + | 'success' + | 'error' /** * Progress information for model download. */ export interface DownloadProgress { - /** Number of bytes downloaded */ - loaded: number; - /** Total number of bytes to download */ - total: number; + /** Number of bytes downloaded */ + loaded: number + /** Total number of bytes to download */ + total: number } /** * Result object returned by the useTranslator hook. */ export interface UseTranslatorResult { - /** The translated text */ - data: string; - /** Detected source language (when sourceLanguage is 'auto') */ - detectedSourceLanguage?: string; - /** Resolved target language (when targetLanguage is 'user') */ - resolvedTargetLanguage?: string; - /** Current status of the translation process */ - status: TranslationStatus; - /** Download progress if model is being downloaded */ - progress: DownloadProgress | null; - /** Error object if translation failed */ - error: Error | null; - /** Function to translate text manually */ - translate: (text: string) => Promise; - /** Function to reset the hook state */ - reset: () => void; + /** + * The translated text. + * @example "Hola mundo" + */ + data: string + /** + * Detected source language (when sourceLanguage is 'auto'). + * @example "en" + */ + detectedSourceLanguage?: string + /** + * Resolved target language (when targetLanguage is 'user'). + * @example "es" + */ + resolvedTargetLanguage?: string + /** + * Current status of the translation process. + * @example "success" + */ + status: TranslationStatus + /** + * Download progress if model is being downloaded. + * @example { loaded: 50, total: 100 } + */ + progress: DownloadProgress | null + /** + * Error object if translation failed. + * @example null + */ + error: Error | null + /** + * Function to translate text manually. + * @param text - The text to translate. + */ + translate: (text: string) => Promise + /** + * Function to reset the hook state. + */ + reset: () => void } // Type definitions for Chrome's Translator API @@ -74,33 +183,39 @@ export interface UseTranslatorResult { * Monitor for tracking Translator creation progress. */ interface CreateMonitor { - addEventListener(event: 'downloadprogress', callback: (e: ProgressEvent) => void): void; + addEventListener( + event: 'downloadprogress', + callback: (e: ProgressEvent) => void, + ): void } /** * Options for creating a Translator instance. */ interface TranslatorCreateOptions { - sourceLanguage: SupportedLanguage; - targetLanguage: SupportedLanguage; - monitor?(m: CreateMonitor): void; + sourceLanguage: SupportedLanguage + targetLanguage: SupportedLanguage + monitor?(m: CreateMonitor): void } /** * Options for availability check. */ interface TranslatorAvailabilityOptions { - sourceLanguage: SupportedLanguage; - targetLanguage: SupportedLanguage; + sourceLanguage: SupportedLanguage + targetLanguage: SupportedLanguage } /** * Chrome's Translator interface. */ interface Translator { - translate(text: string, options?: { signal?: AbortSignal }): Promise; - translateStreaming(text: string, options?: { signal?: AbortSignal }): ReadableStream; - destroy(): void; + translate(text: string, options?: { signal?: AbortSignal }): Promise + translateStreaming( + text: string, + options?: { signal?: AbortSignal }, + ): ReadableStream + destroy(): void } /** @@ -146,234 +261,388 @@ interface Translator { * @param options - Configuration for the translator * @returns An object with data, detected languages, status, progress, error, and functions to translate or reset */ -export function useTranslator(options: UseTranslatorOptions = {}): UseTranslatorResult { - const { text, sourceLanguage = 'auto', targetLanguage = 'user', streaming = true, warmup = true, enable = true } = options; - const [data, setData] = useState(''); - const [status, setStatus] = useState('idle'); - const [progress, setProgress] = useState(null); - const [error, setError] = useState(null); - const [detectedSourceLanguage, setDetectedSourceLanguage] = useState(); - const [resolvedTargetLanguage, setResolvedTargetLanguage] = useState(); - - const translatorRef = useRef(null); - const abortControllerRef = useRef(null); - - // Helper function to resolve languages - const resolveLanguages = useCallback(async (textToDetect?: string): Promise<{ source: SupportedLanguage; target: SupportedLanguage }> => { - let resolvedSource: SupportedLanguage; - let resolvedTarget: SupportedLanguage; - - // Resolve source language - if (sourceLanguage === 'auto') { - if (!textToDetect) { - // Default to 'en' if no text to detect - resolvedSource = 'en'; - } else { - // Try to detect language from text using Chrome's LanguageDetector - try { - if (typeof window !== 'undefined' && typeof (window as unknown as { LanguageDetector?: unknown }).LanguageDetector === 'function') { - const LanguageDetector = (window as unknown as { LanguageDetector: { availability?: () => Promise; create?: (options?: { monitor?: (m: { addEventListener: (event: string, callback: (e: ProgressEvent) => void) => void }) => void }) => Promise<{ detect: (text: string) => Promise<{ detectedLanguage: string; confidence: number }[]>; destroy: () => void }> } }).LanguageDetector; - - if (typeof LanguageDetector.availability === 'function') { - const avail = await LanguageDetector.availability(); - - // Check user activation (required by Chrome) - const isActive = typeof navigator !== 'undefined' && 'userActivation' in navigator && (navigator as unknown as { userActivation?: { isActive: boolean } }).userActivation?.isActive; - - if (isActive && avail !== 'unavailable' && typeof LanguageDetector.create === 'function') { - const detector = await LanguageDetector.create(); - const results = await detector.detect(textToDetect); - detector.destroy(); - - if (results.length > 0) { - const detected = results[0].detectedLanguage.split('-')[0] as SupportedLanguage; - // Check if detected language is supported - const supportedLanguages: SupportedLanguage[] = ['ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'hr', 'hu', 'id', 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'mr', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh', 'zh-Hant']; - if (supportedLanguages.includes(detected)) { - resolvedSource = detected; - setDetectedSourceLanguage(detected); - } else { - resolvedSource = 'en'; - } +export function useTranslator( + options: UseTranslatorOptions = {}, +): UseTranslatorResult { + const { + text, + sourceLanguage = 'auto', + targetLanguage = 'user', + streaming = true, + warmup = true, + enable = true, + } = options + const [data, setData] = useState('') + const [status, setStatus] = useState('idle') + const [progress, setProgress] = useState(null) + const [error, setError] = useState(null) + const [detectedSourceLanguage, setDetectedSourceLanguage] = useState< + string | undefined + >() + const [resolvedTargetLanguage, setResolvedTargetLanguage] = useState< + string | undefined + >() + + const translatorRef = useRef(null) + const abortControllerRef = useRef(null) + + // Helper function to resolve languages + const resolveLanguages = useCallback( + async ( + textToDetect?: string, + ): Promise<{ + source: SupportedLanguage + target: SupportedLanguage + }> => { + let resolvedSource: SupportedLanguage = 'en' + let resolvedTarget: SupportedLanguage = 'en' + + // Resolve source language + if (sourceLanguage === 'auto') { + if (!textToDetect) { + resolvedSource = 'en' + } else { + // Try to detect language from text using Chrome's LanguageDetector + try { + if ( + typeof window !== 'undefined' && + typeof ( + window as unknown as { + LanguageDetector?: unknown + } + ).LanguageDetector === 'function' + ) { + const LanguageDetector = ( + window as unknown as { + LanguageDetector: { + availability?: () => Promise + create?: (options?: { + monitor?: (m: { + addEventListener: ( + event: string, + callback: ( + e: ProgressEvent, + ) => void, + ) => void + }) => void + }) => Promise<{ + detect: ( + text: string, + ) => Promise< + { + detectedLanguage: string + confidence: number + }[] + > + destroy: () => void + }> + } + } + ).LanguageDetector + + // Security: check if constructor is valid + if ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + LanguageDetector === (Object as any) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + LanguageDetector === (Array as any) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + LanguageDetector === (Function as any) + ) { + resolvedSource = 'en' + } else if ( + typeof LanguageDetector.availability === + 'function' + ) { + const avail = + await LanguageDetector.availability() + + // Check user activation (required by Chrome) + const isActive = + typeof navigator !== 'undefined' && + 'userActivation' in navigator && + ( + navigator as unknown as { + userActivation?: { + isActive: boolean + } + } + ).userActivation?.isActive + + if ( + isActive && + avail !== 'unavailable' && + typeof LanguageDetector.create === + 'function' + ) { + const detector = + await LanguageDetector.create() + const results = + await detector.detect(textToDetect) + detector.destroy() + + if (results.length > 0) { + const detected = + results[0].detectedLanguage.split( + '-', + )[0] as SupportedLanguage + // Check if detected language is supported + if ( + SUPPORTED_LANGUAGES.includes( + detected, + ) + ) { + resolvedSource = detected + setDetectedSourceLanguage(detected) + } else { + resolvedSource = 'en' + } + } else { + resolvedSource = 'en' + } + } else { + resolvedSource = 'en' + } + } else { + resolvedSource = 'en' + } + } else { + resolvedSource = 'en' + } + } catch { + resolvedSource = 'en' + } + } + } else { + resolvedSource = sourceLanguage + } + + // Resolve target language + if (targetLanguage === 'user') { + const userLang = getUserLanguage() as SupportedLanguage + if (SUPPORTED_LANGUAGES.includes(userLang)) { + resolvedTarget = userLang } else { - resolvedSource = 'en'; + resolvedTarget = 'en' } - } else { - resolvedSource = 'en'; - } + setResolvedTargetLanguage(resolvedTarget) } else { - resolvedSource = 'en'; + resolvedTarget = targetLanguage + setResolvedTargetLanguage(targetLanguage) } - } else { - resolvedSource = 'en'; - } - } catch { - resolvedSource = 'en'; + + return { source: resolvedSource, target: resolvedTarget } + }, + [sourceLanguage, targetLanguage], + ) + + const reset = useCallback(() => { + setData('') + setStatus('idle') + setProgress(null) + setError(null) + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null } - } - } else { - resolvedSource = sourceLanguage; - } + }, []) + + const createTranslator = useCallback( + async (source: SupportedLanguage, target: SupportedLanguage) => { + if (translatorRef.current) return translatorRef.current + + // Chrome native API: Translator is a global constructor + if ( + typeof window === 'undefined' || + typeof (window as unknown as { Translator?: unknown }) + .Translator !== 'function' + ) { + throw new Error('Translator API not supported in this browser') + } - // Resolve target language - if (targetLanguage === 'user') { - const userLang = getUserLanguage() as SupportedLanguage; - const supportedLanguages: SupportedLanguage[] = ['ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'hr', 'hu', 'id', 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'mr', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh', 'zh-Hant']; - if (supportedLanguages.includes(userLang)) { - resolvedTarget = userLang; - } else { - resolvedTarget = 'en'; - } - setResolvedTargetLanguage(resolvedTarget); - } else { - resolvedTarget = targetLanguage; - setResolvedTargetLanguage(targetLanguage); - } + const Translator = ( + window as unknown as { + Translator: { + availability?: ( + options: TranslatorAvailabilityOptions, + ) => Promise + create?: ( + options: TranslatorCreateOptions, + ) => Promise + } + } + ).Translator + + // Security: check if constructor is valid + if ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Translator === (Object as any) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Translator === (Array as any) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Translator === (Function as any) + ) { + throw new Error('Translator is not available') + } - return { source: resolvedSource, target: resolvedTarget }; - }, [sourceLanguage, targetLanguage]); - - const reset = useCallback(() => { - setData(''); - setStatus('idle'); - setProgress(null); - setError(null); - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); + // Check availability for the specific language pair + if (typeof Translator.availability === 'function') { + const avail = await Translator.availability({ + sourceLanguage: source, + targetLanguage: target, + }) + if (avail === 'unavailable') { + throw new Error( + `Translator is not available for ${source} → ${target}`, + ) + } + if (avail === 'downloading' || avail === 'downloadable') { + setStatus('downloading') + } else { + setStatus('initializing') + } + } - const createTranslator = useCallback(async (source: SupportedLanguage, target: SupportedLanguage) => { - if (translatorRef.current) return translatorRef.current; + // Check user activation (required by Chrome) + if ( + typeof navigator !== 'undefined' && + 'userActivation' in navigator && + !( + navigator as unknown as { + userActivation?: { isActive: boolean } + } + ).userActivation?.isActive + ) { + throw new Error( + 'User activation required. Please interact with the page first.', + ) + } - // Chrome native API: Translator is a global constructor - if (typeof window === 'undefined' || typeof (window as unknown as { Translator?: unknown }).Translator !== 'function') { - throw new Error('Translator API not supported in this browser'); - } + if (typeof Translator.create !== 'function') { + throw new Error('Translator.create is not available') + } - const Translator = (window as unknown as { Translator: { availability?: (options: TranslatorAvailabilityOptions) => Promise; create?: (options: TranslatorCreateOptions) => Promise } }).Translator; - - // Check availability for the specific language pair - if (typeof Translator.availability === 'function') { - const avail = await Translator.availability({ sourceLanguage: source, targetLanguage: target }); - if (avail === 'unavailable') { - throw new Error(`Translator is not available for ${source} → ${target}`); - } - if (avail === 'downloading' || avail === 'downloadable') { - setStatus('downloading'); - } else { - setStatus('initializing'); - } - } + const instance = await Translator.create({ + sourceLanguage: source, + targetLanguage: target, + monitor(m: CreateMonitor) { + m.addEventListener( + 'downloadprogress', + (e: ProgressEvent) => { + setProgress({ loaded: e.loaded, total: e.total }) + }, + ) + }, + }) + + translatorRef.current = instance + return instance + }, + [], + ) + + const translate = useCallback( + async (textToTranslate: string) => { + const currentStatus = translatorRef.current ? 'translating' : status + if ( + currentStatus === 'translating' || + currentStatus === 'initializing' || + currentStatus === 'downloading' + ) { + return + } - // Check user activation (required by Chrome) - if (typeof navigator !== 'undefined' && 'userActivation' in navigator && !(navigator as unknown as { userActivation?: { isActive: boolean } }).userActivation?.isActive) { - throw new Error('User activation required. Please interact with the page first.'); - } + setError(null) + setData('') - if (typeof Translator.create !== 'function') { - throw new Error('Translator.create is not available'); - } + try { + const { source, target } = + await resolveLanguages(textToTranslate) - const instance = await Translator.create({ - sourceLanguage: source, - targetLanguage: target, - monitor(m: CreateMonitor) { - m.addEventListener('downloadprogress', (e: ProgressEvent) => { - setProgress({ loaded: e.loaded, total: e.total }); - }); - }, - }); - - translatorRef.current = instance; - return instance; - }, []); - - const translate = useCallback( - async (textToTranslate: string) => { - const currentStatus = translatorRef.current ? 'translating' : status; - if (currentStatus === 'translating' || currentStatus === 'initializing' || currentStatus === 'downloading') { - return; - } - - setError(null); - setData(''); - - try { - const { source, target } = await resolveLanguages(textToTranslate); - - // Optimization: if source and target are the same, skip translation - if (source === target) { - setData(textToTranslate); - setStatus('success'); - return; - } + // Optimization: if source and target are the same, skip translation + if (source === target) { + setData(textToTranslate) + setStatus('success') + return + } - const translator = await createTranslator(source, target); - setStatus('translating'); - - abortControllerRef.current = new AbortController(); - - if (streaming) { - const stream = translator.translateStreaming(textToTranslate, { signal: abortControllerRef.current.signal }); - // @ts-expect-error - ReadableStream is async iterable in many environments - for await (const chunk of stream) { - // The Translator API returns cumulative chunks, replace state to avoid DoS - setData(chunk); - } - setStatus('success'); - } else { - const result = await translator.translate(textToTranslate, { signal: abortControllerRef.current.signal }); - setData(result); - setStatus('success'); + const translator = await createTranslator(source, target) + setStatus('translating') + + abortControllerRef.current = new AbortController() + + if (streaming) { + const stream = translator.translateStreaming( + textToTranslate, + { signal: abortControllerRef.current.signal }, + ) + // @ts-expect-error - ReadableStream is async iterable in many environments + for await (const chunk of stream) { + // The Translator API returns cumulative chunks, replace state to avoid DoS + setData(chunk) + } + setStatus('success') + } else { + const result = await translator.translate(textToTranslate, { + signal: abortControllerRef.current.signal, + }) + setData(result) + setStatus('success') + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle') + return + } + setError( + err instanceof Error + ? err + : new Error('Unknown error during translation'), + ) + setStatus('error') + } + }, + [streaming, resolveLanguages, createTranslator, status], + ) + + // Auto-translate when text changes and enable is true + useEffect(() => { + if (enable && text && text.trim().length > 0) { + translate(text) } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - setStatus('idle'); - return; + }, [text, enable, translate]) + + useEffect(() => { + if (warmup) { + resolveLanguages() + .then(({ source, target }) => { + createTranslator(source, target) + .then(() => setStatus('idle')) + .catch(() => {}) + }) + .catch(() => {}) } - setError(err instanceof Error ? err : new Error('Unknown error during translation')); - setStatus('error'); - } - }, - [streaming, resolveLanguages] - ); - - // Auto-translate when text changes and enable is true - useEffect(() => { - if (enable && text && text.trim().length > 0) { - translate(text); - } - }, [text, enable, translate]); - useEffect(() => { - if (warmup) { - resolveLanguages().then(({ source, target }) => { - createTranslator(source, target).then(() => setStatus('idle')).catch(() => {}); - }).catch(() => {}); + return () => { + if (translatorRef.current) { + translatorRef.current.destroy() + translatorRef.current = null + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + } + }, [warmup, resolveLanguages, createTranslator]) + + return { + data, + detectedSourceLanguage, + resolvedTargetLanguage, + status, + progress, + error, + translate, + reset, } - - return () => { - if (translatorRef.current) { - translatorRef.current.destroy(); - translatorRef.current = null; - } - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }; - }, [warmup, resolveLanguages, createTranslator]); - - return { - data, - detectedSourceLanguage, - resolvedTargetLanguage, - status, - progress, - error, - translate, - reset, - }; }