From fcaa8c2a3a4ca5753555612377422bcfd8040228 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Tue, 31 Dec 2024 07:11:11 +0100 Subject: [PATCH] feat(language-detector): add language detector middleware and helper function --- package.json | 3 + src/middleware/language/index.test.ts | 275 +++++++++++++++++++++++ src/middleware/language/index.ts | 15 ++ src/middleware/language/language.ts | 305 ++++++++++++++++++++++++++ 4 files changed, 598 insertions(+) create mode 100644 src/middleware/language/index.test.ts create mode 100644 src/middleware/language/index.ts create mode 100644 src/middleware/language/language.ts diff --git a/package.json b/package.json index 258f1548a..c567b9bde 100644 --- a/package.json +++ b/package.json @@ -506,6 +506,9 @@ "request-id": [ "./dist/types/middleware/request-id" ], + "language": [ + "./dist/types/middleware/language" + ], "streaming": [ "./dist/types/helper/streaming" ], diff --git a/src/middleware/language/index.test.ts b/src/middleware/language/index.test.ts new file mode 100644 index 000000000..f0c0ac640 --- /dev/null +++ b/src/middleware/language/index.test.ts @@ -0,0 +1,275 @@ +import { Hono } from '../../hono' +import { detectors } from './language' +import { languageDetector } from '.' + +describe('languageDetector', () => { + const createTestApp = (options = {}) => { + const app = new Hono() + + app.use('/*', languageDetector(options)) + + app.get('/*', (c) => c.text(c.get('language'))) + + return app + } + + describe('Query Parameter Detection', () => { + it('should detect language from query parameter', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr', 'es'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/?lang=fr') + expect(await res.text()).toBe('fr') + }) + + it('should ignore unsupported languages in query', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/?lang=de') + expect(await res.text()).toBe('en') + }) + }) + + describe('Cookie Detection', () => { + it('should detect language from cookie', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/', { + headers: { + cookie: 'language=fr', + }, + }) + expect(await res.text()).toBe('fr') + }) + + it('should cache detected language in cookie when enabled', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + caches: ['cookie'], + }) + + const res = await app.request('/?lang=fr') + expect(res.headers.get('set-cookie')).toContain('language=fr') + }) + }) + + describe('Header Detection', () => { + it('should detect language from Accept-Language header', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr', 'es'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/', { + headers: { + 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8', + }, + }) + expect(await res.text()).toBe('fr') + }) + + it('should handle malformed Accept-Language headers', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/', { + headers: { + 'accept-language': 'invalid;header;;format', + }, + }) + expect(await res.text()).toBe('en') + }) + }) + + describe('Path Detection', () => { + it('should detect language from path', async () => { + const app = createTestApp({ + order: ['path'], + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + lookupFromPathIndex: 0, + }) + + const res = await app.request('/fr/page') + expect(await res.text()).toBe('fr') + }) + + it('should handle invalid path index gracefully', async () => { + const app = createTestApp({ + order: ['path'], + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + lookupFromPathIndex: 99, + }) + + const res = await app.request('/fr/page') + expect(await res.text()).toBe('en') + }) + }) + + describe('Detection Order', () => { + it('should respect detection order', async () => { + const app = createTestApp({ + order: ['cookie', 'querystring'], + supportedLanguages: ['en', 'fr', 'es'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/?lang=fr', { + headers: { + cookie: 'language=es', + }, + }) + + // Since cookie is first in order, it should use 'es' + expect(await res.text()).toBe('es') + }) + + it('should fall back to next detector if first fails', async () => { + const app = createTestApp({ + order: ['cookie', 'querystring'], + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/?lang=fr') // No cookie + expect(await res.text()).toBe('fr') // Should use querystring + }) + }) + + describe('Language Conversion', () => { + it('should apply language conversion function', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + convertDetectedLanguage: (lang: string) => lang.split('-')[0], + }) + + const res = await app.request('/?lang=fr-FR') + expect(await res.text()).toBe('fr') + }) + + it('should handle case sensitivity according to options', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + ignoreCase: false, + }) + + const res = await app.request('/?lang=FR') + expect(await res.text()).toBe('en') // Falls back because case doesn't match + }) + }) + + describe('Error Handling', () => { + it('should fall back to default language on error', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + }) + + const detector = vi.spyOn(detectors, 'querystring').mockImplementation(() => { + throw new Error('Simulated error') + }) + + const res = await app.request('/?lang=fr') + expect(await res.text()).toBe('en') + + detector.mockRestore() + }) + + it('should handle missing cookie values gracefully', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + order: ['cookie'], + }) + + const res = await app.request('/') + expect(await res.text()).toBe('en') + }) + }) + + describe('Configuration Validation', () => { + it('should throw if fallback language is not in supported languages', () => { + expect(() => { + createTestApp({ + supportedLanguages: ['fr', 'es'], + fallbackLanguage: 'en', + }) + }).toThrow() + }) + + it('should throw if path index is negative', () => { + expect(() => { + createTestApp({ + lookupFromPathIndex: -1, + }) + }).toThrow() + }) + + it('should handle empty supported languages list', () => { + expect(() => { + createTestApp({ + supportedLanguages: [], + }) + }).toThrow() + }) + }) + + describe('Debug Mode', () => { + it('should log errors in debug mode', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error') + + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + debug: true, + }) + + const detector = vi.spyOn(detectors, 'querystring').mockImplementation(() => { + throw new Error('Simulated error') + }) + + await app.request('/?lang=fr') + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error in querystring detector:', + expect.any(Error) + ) + + detector.mockRestore() + consoleErrorSpy.mockRestore() + }) + + // The log test remains unchanged + it('should log debug information when enabled', async () => { + const consoleSpy = vi.spyOn(console, 'log') + + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + debug: true, + }) + + await app.request('/?lang=fr') + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Language detected from querystring') + ) + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/middleware/language/index.ts b/src/middleware/language/index.ts new file mode 100644 index 000000000..4d466504d --- /dev/null +++ b/src/middleware/language/index.ts @@ -0,0 +1,15 @@ +import type { LanguageVariables, DetectorType, CacheType } from './language' +export type { LanguageVariables, DetectorType, CacheType } +export { + languageDetector, + DetectorOptions, + detectFromCookie, + detectFromHeader, + detectFromPath, + detectFromQuery, +} from './language' +import type {} from '../..' + +declare module '../..' { + interface ContextVariableMap extends LanguageVariables {} +} diff --git a/src/middleware/language/language.ts b/src/middleware/language/language.ts new file mode 100644 index 000000000..c298d3e90 --- /dev/null +++ b/src/middleware/language/language.ts @@ -0,0 +1,305 @@ +/** + * @module + * Language module for Hono. + */ +import type { Context } from '../../context' +import { setCookie, getCookie } from '../../helper/cookie' +import type { MiddlewareHandler } from '../../types' + +export type DetectorType = 'path' | 'querystring' | 'cookie' | 'header' +export type CacheType = 'cookie' + +export interface DetectorOptions { + /** Order of language detection strategies */ + order: DetectorType[] + /** Query parameter name for language */ + lookupQuerystring: string + /** Cookie name for language */ + lookupCookie: string + /** Index in URL path where language code appears */ + lookupFromPathIndex: number + /** Header key for language detection */ + lookupFromHeaderKey: string + /** Caching strategies */ + caches: CacheType[] | false + /** Cookie configuration options */ + cookieOptions?: { + domain?: string + path?: string + sameSite?: 'Strict' | 'Lax' | 'None' + secure?: boolean + maxAge?: number + httpOnly?: boolean + } + /** Whether to ignore case in language codes */ + ignoreCase: boolean + /** Default language if none detected */ + fallbackLanguage: string + /** List of supported language codes */ + supportedLanguages: string[] + /** Optional function to transform detected language codes */ + convertDetectedLanguage?: (lang: string) => string + /** Enable debug logging */ + debug?: boolean +} + +export interface LanguageVariables { + language: string +} + +const DEFAULT_OPTIONS: DetectorOptions = { + order: ['querystring', 'cookie', 'header'], + lookupQuerystring: 'lang', + lookupCookie: 'language', + lookupFromHeaderKey: 'accept-language', + lookupFromPathIndex: 0, + caches: ['cookie'], + ignoreCase: true, + fallbackLanguage: 'en', + supportedLanguages: ['en'], + cookieOptions: { + sameSite: 'Strict', + secure: true, + maxAge: 365 * 24 * 60 * 60, + httpOnly: true, + }, + debug: false, +} +/** + * Parse Accept-Language header values with quality scores + * @param header Accept-Language header string + * @returns Array of parsed languages with quality scores + */ +export function parseAcceptLanguage(header: string): Array<{ lang: string; q: number }> { + try { + return header + .split(',') + .map((lang) => { + const [language, quality = 'q=1.0'] = lang + .trim() + .split(';') + .map((s) => s.trim()) + const q = parseFloat(quality.replace('q=', '')) + return { + lang: language, + q: isNaN(q) ? 1.0 : Math.max(0, Math.min(1, q)), + } + }) + .sort((a, b) => b.q - a.q) + } catch { + return [] + } +} + +/** + * Validate and normalize language codes + * @param lang Language code to normalize + * @param options Detector options + * @returns Normalized language code or undefined + */ +export function normalizeLanguage( + lang: string | null | undefined, + options: DetectorOptions +): string | undefined { + if (!lang) { + return undefined + } + + try { + let normalizedLang = lang.trim() + if (options.convertDetectedLanguage) { + normalizedLang = options.convertDetectedLanguage(normalizedLang) + } + + const compLang = options.ignoreCase ? normalizedLang.toLowerCase() : normalizedLang + const compSupported = options.supportedLanguages.map((l) => + options.ignoreCase ? l.toLowerCase() : l + ) + + const matchedLang = compSupported.find((l) => l === compLang) + return matchedLang ? options.supportedLanguages[compSupported.indexOf(matchedLang)] : undefined + } catch { + return undefined + } +} + +/** + * Detects language from query parameter + */ +export function detectFromQuery(c: Context, options: DetectorOptions): string | undefined { + try { + const query = c.req.query(options.lookupQuerystring) + return normalizeLanguage(query, options) + } catch { + return undefined + } +} + +/** + * Detects language from cookie + */ +export function detectFromCookie(c: Context, options: DetectorOptions): string | undefined { + try { + const cookie = getCookie(c, options.lookupCookie) + return normalizeLanguage(cookie, options) + } catch { + return undefined + } +} + +/** + * Detects language from Accept-Language header + */ +export function detectFromHeader(c: Context, options: DetectorOptions): string | undefined { + try { + const acceptLanguage = c.req.header(options.lookupFromHeaderKey) + if (!acceptLanguage) { + return undefined + } + + const languages = parseAcceptLanguage(acceptLanguage) + for (const { lang } of languages) { + const normalizedLang = normalizeLanguage(lang, options) + if (normalizedLang) { + return normalizedLang + } + } + return undefined + } catch { + return undefined + } +} + +/** + * Detects language from URL path + */ +export function detectFromPath(c: Context, options: DetectorOptions): string | undefined { + try { + const pathSegments = c.req.path.split('/').filter(Boolean) + const langSegment = pathSegments[options.lookupFromPathIndex] + return normalizeLanguage(langSegment, options) + } catch { + return undefined + } +} + +/** + * Collection of all language detection strategies + */ +export const detectors = { + querystring: detectFromQuery, + cookie: detectFromCookie, + header: detectFromHeader, + path: detectFromPath, +} as const + +/** Type for detector functions */ +export type DetectorFunction = (c: Context, options: DetectorOptions) => string | undefined + +/** Type-safe detector map */ +export type Detectors = Record + +/** + * Validate detector options + * @param options Detector options to validate + * @throws Error if options are invalid + */ +export function validateOptions(options: DetectorOptions): void { + if (!options.supportedLanguages.includes(options.fallbackLanguage)) { + throw new Error('Fallback language must be included in supported languages') + } + + if (options.lookupFromPathIndex < 0) { + throw new Error('Path index must be non-negative') + } + + if (!options.order.every((detector) => Object.keys(detectors).includes(detector))) { + throw new Error('Invalid detector type in order array') + } +} + +/** + * Cache detected language + */ +function cacheLanguage(c: Context, language: string, options: DetectorOptions): void { + if (!Array.isArray(options.caches) || !options.caches.includes('cookie')) { + return + } + + try { + setCookie(c, options.lookupCookie, language, options.cookieOptions) + } catch (error) { + if (options.debug) { + console.error('Failed to cache language:', error) + } + } +} + +/** + * Detect language from request + */ +function detectLanguage(c: Context, options: DetectorOptions): string { + let detectedLang: string | undefined + + for (const detectorName of options.order) { + const detector = detectors[detectorName] + if (!detector) { + continue + } + + try { + detectedLang = detector(c, options) + if (detectedLang) { + if (options.debug) { + console.log(`Language detected from ${detectorName}: ${detectedLang}`) + } + break + } + } catch (error) { + if (options.debug) { + console.error(`Error in ${detectorName} detector:`, error) + } + continue + } + } + + const finalLang = detectedLang || options.fallbackLanguage + + if (detectedLang && options.caches) { + cacheLanguage(c, finalLang, options) + } + + return finalLang +} + +/** + * Language detector middleware factory + * @param userOptions Configuration options for the language detector + * @returns Hono middleware function + */ +export function languageDetector(userOptions: Partial = {}): MiddlewareHandler { + const options: DetectorOptions = { + ...DEFAULT_OPTIONS, + ...userOptions, + cookieOptions: { + ...DEFAULT_OPTIONS.cookieOptions, + ...userOptions.cookieOptions, + }, + } + + validateOptions(options) + + return async function languageDetectorMiddleware(c, next) { + try { + const lang = detectLanguage(c, options) + c.set('language', lang) + } catch (error) { + if (options.debug) { + console.error('Language detection failed:', error) + } + c.set('language', options.fallbackLanguage) + } + + await next() + } +}