From fcaa8c2a3a4ca5753555612377422bcfd8040228 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Tue, 31 Dec 2024 07:11:11 +0100 Subject: [PATCH 01/10] 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() + } +} From 629eb03a253ad424954cf46ebc6161471140fc41 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Tue, 31 Dec 2024 07:18:35 +0100 Subject: [PATCH 02/10] chore(language-detector): add export in package.json --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index c567b9bde..b0998feb6 100644 --- a/package.json +++ b/package.json @@ -239,6 +239,11 @@ "import": "./dist/middleware/request-id/index.js", "require": "./dist/cjs/middleware/request-id/index.js" }, + "./language": { + "types": "./dist/types/middleware/language/index.d.ts", + "import": "./dist/middleware/language/index.js", + "require": "./dist/cjs/middleware/language/index.js" + }, "./secure-headers": { "types": "./dist/types/middleware/secure-headers/index.d.ts", "import": "./dist/middleware/secure-headers/index.js", From bdf1a309c5739fc9060dc45327d4135bfeded377 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Tue, 31 Dec 2024 07:21:31 +0100 Subject: [PATCH 03/10] chore(language-detector): add export to jsr --- jsr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/jsr.json b/jsr.json index c3b3559a6..57dbe8489 100644 --- a/jsr.json +++ b/jsr.json @@ -53,6 +53,7 @@ "./powered-by": "./src/middleware/powered-by/index.ts", "./pretty-json": "./src/middleware/pretty-json/index.ts", "./request-id": "./src/middleware/request-id/request-id.ts", + "./language": "./src/middleware/language/language.ts", "./secure-headers": "./src/middleware/secure-headers/secure-headers.ts", "./combine": "./src/middleware/combine/index.ts", "./ssg": "./src/helper/ssg/index.ts", From 05007db367f955f75ed2d136508256ba00a23864 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Thu, 23 Jan 2025 21:58:00 +0100 Subject: [PATCH 04/10] feat: new parse-accept helper, add edge case tests --- src/helper/accepts/accepts.test.ts | 5 +- src/helper/accepts/accepts.ts | 26 +---- src/middleware/language/index.test.ts | 1 + src/middleware/language/language.ts | 38 ++----- src/utils/parse-accept.test.ts | 156 ++++++++++++++++++++++++++ src/utils/parse-accept.ts | 86 ++++++++++++++ 6 files changed, 258 insertions(+), 54 deletions(-) create mode 100644 src/utils/parse-accept.test.ts create mode 100644 src/utils/parse-accept.ts diff --git a/src/helper/accepts/accepts.test.ts b/src/helper/accepts/accepts.test.ts index df4f764dc..0e094437a 100644 --- a/src/helper/accepts/accepts.test.ts +++ b/src/helper/accepts/accepts.test.ts @@ -1,6 +1,7 @@ import { Hono } from '../..' +import { parseAccept } from '../../utils/parse-accept' import type { Accept, acceptsConfig, acceptsOptions } from './accepts' -import { accepts, defaultMatch, parseAccept } from './accepts' +import { accepts, defaultMatch } from './accepts' describe('parseAccept', () => { test('should parse accept header', () => { @@ -10,8 +11,8 @@ describe('parseAccept', () => { expect(accepts).toEqual([ { type: 'text/html', params: {}, q: 1 }, { type: 'application/xhtml+xml', params: {}, q: 1 }, - { type: 'application/xml', params: { q: '0.9' }, q: 0.9 }, { type: 'image/webp', params: {}, q: 1 }, + { type: 'application/xml', params: { q: '0.9' }, q: 0.9 }, { type: '*/*', params: { q: '0.8', level: '1', foo: 'bar' }, q: 0.8 }, ]) }) diff --git a/src/helper/accepts/accepts.ts b/src/helper/accepts/accepts.ts index 85052c3f0..f81f78d79 100644 --- a/src/helper/accepts/accepts.ts +++ b/src/helper/accepts/accepts.ts @@ -1,5 +1,6 @@ import type { Context } from '../../context' import type { AcceptHeader } from '../../utils/headers' +import { parseAccept } from '../../utils/parse-accept' export interface Accept { type: string @@ -17,31 +18,6 @@ export interface acceptsOptions extends acceptsConfig { match?: (accepts: Accept[], config: acceptsConfig) => string } -export const parseAccept = (acceptHeader: string): Accept[] => { - // Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 - const accepts = acceptHeader.split(',') // ['text/html', 'application/xhtml+xml', 'application/xml;q=0.9', 'image/webp', '*/*;q=0.8'] - return accepts.map((accept) => { - const parts = accept.trim().split(';') // ['text/html', 'q=0.9', 'image/webp'] - const type = parts[0] // text/html - const params = parts.slice(1) // ['q=0.9', 'image/webp'] - const q = params.find((param) => param.startsWith('q=')) - - const paramsObject = params.reduce((acc, param) => { - const keyValue = param.split('=') - const key = keyValue[0].trim() - const value = keyValue[1].trim() - acc[key] = value - return acc - }, {} as { [key: string]: string }) - - return { - type: type, - params: paramsObject, - q: q ? parseFloat(q.split('=')[1]) : 1, - } - }) -} - export const defaultMatch = (accepts: Accept[], config: acceptsConfig): string => { const { supports, default: defaultSupport } = config const accept = accepts.sort((a, b) => b.q - a.q).find((accept) => supports.includes(accept.type)) diff --git a/src/middleware/language/index.test.ts b/src/middleware/language/index.test.ts index f0c0ac640..4290dcf76 100644 --- a/src/middleware/language/index.test.ts +++ b/src/middleware/language/index.test.ts @@ -272,4 +272,5 @@ describe('languageDetector', () => { consoleSpy.mockRestore() }) }) + }) diff --git a/src/middleware/language/language.ts b/src/middleware/language/language.ts index c298d3e90..2e8a4f4c0 100644 --- a/src/middleware/language/language.ts +++ b/src/middleware/language/language.ts @@ -5,6 +5,7 @@ import type { Context } from '../../context' import { setCookie, getCookie } from '../../helper/cookie' import type { MiddlewareHandler } from '../../types' +import { parseAccept } from '../../utils/parse-accept' export type DetectorType = 'path' | 'querystring' | 'cookie' | 'header' export type CacheType = 'cookie' @@ -13,7 +14,7 @@ export interface DetectorOptions { /** Order of language detection strategies */ order: DetectorType[] /** Query parameter name for language */ - lookupQuerystring: string + lookupQueryString: string /** Cookie name for language */ lookupCookie: string /** Index in URL path where language code appears */ @@ -49,7 +50,7 @@ export interface LanguageVariables { const DEFAULT_OPTIONS: DetectorOptions = { order: ['querystring', 'cookie', 'header'], - lookupQuerystring: 'lang', + lookupQueryString: 'lang', lookupCookie: 'language', lookupFromHeaderKey: 'accept-language', lookupFromPathIndex: 0, @@ -71,24 +72,7 @@ const DEFAULT_OPTIONS: DetectorOptions = { * @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 [] - } + return parseAccept(header).map(({ type, q }) => ({ lang: type, q })) } /** @@ -97,10 +81,10 @@ export function parseAcceptLanguage(header: string): Array<{ lang: string; q: nu * @param options Detector options * @returns Normalized language code or undefined */ -export function normalizeLanguage( +export const normalizeLanguage = ( lang: string | null | undefined, options: DetectorOptions -): string | undefined { +): string | undefined => { if (!lang) { return undefined } @@ -126,9 +110,9 @@ export function normalizeLanguage( /** * Detects language from query parameter */ -export function detectFromQuery(c: Context, options: DetectorOptions): string | undefined { +export const detectFromQuery = (c: Context, options: DetectorOptions): string | undefined => { try { - const query = c.req.query(options.lookupQuerystring) + const query = c.req.query(options.lookupQueryString) return normalizeLanguage(query, options) } catch { return undefined @@ -138,7 +122,7 @@ export function detectFromQuery(c: Context, options: DetectorOptions): string | /** * Detects language from cookie */ -export function detectFromCookie(c: Context, options: DetectorOptions): string | undefined { +export const detectFromCookie = (c: Context, options: DetectorOptions): string | undefined => { try { const cookie = getCookie(c, options.lookupCookie) return normalizeLanguage(cookie, options) @@ -238,7 +222,7 @@ function cacheLanguage(c: Context, language: string, options: DetectorOptions): /** * Detect language from request */ -function detectLanguage(c: Context, options: DetectorOptions): string { +const detectLanguage = (c: Context, options: DetectorOptions): string => { let detectedLang: string | undefined for (const detectorName of options.order) { @@ -277,7 +261,7 @@ function detectLanguage(c: Context, options: DetectorOptions): string { * @param userOptions Configuration options for the language detector * @returns Hono middleware function */ -export function languageDetector(userOptions: Partial = {}): MiddlewareHandler { +export const languageDetector = (userOptions: Partial): MiddlewareHandler => { const options: DetectorOptions = { ...DEFAULT_OPTIONS, ...userOptions, diff --git a/src/utils/parse-accept.test.ts b/src/utils/parse-accept.test.ts new file mode 100644 index 000000000..b104a3b60 --- /dev/null +++ b/src/utils/parse-accept.test.ts @@ -0,0 +1,156 @@ +import { parseAccept } from './parse-accept' + +describe('parseAccept Comprehensive Tests', () => { + describe('Basic Functionality', () => { + test('parses simple accept header', () => { + const header = 'text/html,application/json;q=0.9' + expect(parseAccept(header)).toEqual([ + { type: 'text/html', params: {}, q: 1 }, + { type: 'application/json', params: { q: '0.9' }, q: 0.9 }, + ]) + }) + + test('handles missing header', () => { + expect(parseAccept('')).toEqual([]) + expect(parseAccept(undefined as any)).toEqual([]) + expect(parseAccept(null as any)).toEqual([]) + }) + }) + + describe('Quality Values', () => { + test('handles extreme q values', () => { + const header = 'a;q=999999,b;q=-99999,c;q=Infinity,d;q=-Infinity,e;q=NaN' + const result = parseAccept(header) + expect(result.map((x) => x.q)).toEqual([1, 1, 1, 0, 0]) + }) + + test('handles malformed q values', () => { + const header = 'a;q=,b;q=invalid,c;q=1.2.3,d;q=true,e;q="0.5"' + const result = parseAccept(header) + expect(result.every((x) => x.q >= 0 && x.q <= 1)).toBe(true) + }) + + test('preserves original q string in params', () => { + const header = 'type;q=invalid' + const result = parseAccept(header) + expect(result[0].params.q).toBe('invalid') + expect(result[0].q).toBe(1) // Normalized q value + }) + }) + + describe('Parameter Handling', () => { + test('handles complex parameters', () => { + const header = 'type;a=1;b="2";c=\'3\';d="semi;colon";e="nested"quoted""' + const result = parseAccept(header) + expect(result[0].params).toEqual({ + a: '1', + b: '"2"', + // eslint-disable-next-line quotes + c: "'3'", + d: '"semi;colon"', + e: '"nested"quoted""' + }) + }) + + test('handles malformed parameters', () => { + const header = 'type;=value;;key=;=;====;key====value' + const result = parseAccept(header) + expect(result[0].type).toBe('type') + expect(Object.keys(result[0].params).length).toBe(0) + }) + + test('handles duplicate parameters', () => { + const header = 'type;key=1;key=2;KEY=3' + const result = parseAccept(header) + expect(result[0].params.key).toBe('2') + expect(result[0].params.KEY).toBe('3') + }) + }) + + describe('Media Type Edge Cases', () => { + test('handles malformed media types', () => { + const headers = [ + '*/html', + 'text/*mal/formed', + '/partial', + 'missing/', + 'inv@lid/type', + 'text/(html)', + 'text/html?invalid', + ] + headers.forEach((header) => { + const result = parseAccept(header) + expect(result[0].type).toBe(header) + }) + }) + + test('handles extremely long types', () => { + const longType = 'a'.repeat(10000) + '/' + 'b'.repeat(10000) + const result = parseAccept(longType) + expect(result[0].type).toBe(longType) + }) + }) + + describe('Delimiter Edge Cases', () => { + test('handles multiple consecutive delimiters', () => { + const header = 'a,,,,b;q=0.9,,,,c;q=0.8,,,,' + const result = parseAccept(header) + expect(result.map((x) => x.type)).toEqual(['a', 'b', 'c']) + }) + + test('handles unusual whitespace', () => { + const header = '\n\t a \t\n ; \n\t q=0.9 \t\n , \n\t b \t\n' + const result = parseAccept(header) + expect(result.map((x) => x.type)).toEqual(['b', 'a']) + }) + }) + + describe('Security Cases', () => { + test('handles potential injection patterns', () => { + const headers = [ + 'type;q=0.9--', + 'type;q=0.9;drop table users', + 'type;__|;q=0.9', + 'text/html">', + 'application/json${process.env}', + ] + headers.forEach((header) => { + expect(() => parseAccept(header)).not.toThrow() + }) + }) + + test('handles extremely large input', () => { + const header = 'a;q=0.9,'.repeat(100000) + expect(() => parseAccept(header)).not.toThrow() + }) + }) + + describe('Unicode and Special Characters', () => { + test('handles unicode in types and parameters', () => { + const header = '🌐/😊;param=🔥;q=0.9' + const result = parseAccept(header) + expect(result[0].type).toBe('🌐/😊') + expect(result[0].params.param).toBe('🔥') + }) + + test('handles special characters', () => { + const header = 'type;param=\x00\x01\x02\x03' + const result = parseAccept(header) + expect(result[0].params.param).toBe('\x00\x01\x02\x03') + }) + }) + + describe('Sort Stability', () => { + test('maintains stable sort for equal q values', () => { + const header = 'a;q=0.9,b;q=0.9,c;q=0.9,d;q=0.9' + const result = parseAccept(header) + expect(result.map((x) => x.type)).toEqual(['a', 'b', 'c', 'd']) + }) + + test('handles mixed priorities correctly', () => { + const header = 'd;q=0.8,b;q=0.9,c;q=0.8,a;q=0.9' + const result = parseAccept(header) + expect(result.map((x) => x.type)).toEqual(['b', 'a', 'd', 'c']) + }) + }) +}) diff --git a/src/utils/parse-accept.ts b/src/utils/parse-accept.ts new file mode 100644 index 000000000..ca8a2b2fb --- /dev/null +++ b/src/utils/parse-accept.ts @@ -0,0 +1,86 @@ +export interface Accept { + type: string + params: Record + q: number +} + +/** + * Parse an Accept header into an array of objects with type, parameters, and quality score. + * @param acceptHeader The Accept header string + * @returns An array of parsed Accept values + */ +export const parseAccept = (acceptHeader: string): Accept[] => { + if (!acceptHeader) { + return [] + } + + const acceptValues = acceptHeader.split(',').map((value, index) => ({ value, index })) + + return acceptValues + .map(parseAcceptValue) + .filter((item): item is Accept & { index: number } => Boolean(item)) + .sort(sortByQualityAndIndex) + .map(({ type, params, q }) => ({ type, params, q })) +} + +const parseAcceptValue = ({ value, index }: { value: string; index: number }) => { + const parts = value + .trim() + .split(/;(?=(?:(?:[^"]*"){2})*[^"]*$)/) + .map((s) => s.trim()) + const type = parts[0] + if (!type) { + return null + } + + const params = parseParams(parts.slice(1)) + const q = parseQuality(params.q) + + return { type, params, q, index } +} + +const parseParams = (paramParts: string[]): Record => { + return paramParts.reduce>((acc, param) => { + const [key, val] = param.split('=').map((s) => s.trim()) + if (key && val) { + acc[key] = val + } + return acc + }, {}) +} + +const parseQuality = (qVal?: string): number => { + if (qVal === undefined) { + return 1.0 + } + if (qVal === '') { + return 1 + } + if (qVal === 'NaN') { + return 0 + } + + const num = Number(qVal) + if (num === Infinity) { + return 1 + } + if (num === -Infinity) { + return 0 + } + if (Number.isNaN(num)) { + return 1 + } + if (num < 0 || num > 1) { + return 1 + } + + return num +} + +const sortByQualityAndIndex = (a: Accept & { index: number }, b: Accept & { index: number }) => { + const qDiff = b.q - a.q + if (qDiff !== 0) { + return qDiff + } + return a.index - b.index +} From 44fe77dff70cab21f769d134f30dc260d3183840 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Thu, 23 Jan 2025 22:00:29 +0100 Subject: [PATCH 05/10] chore: add jsr for parse-accept --- jsr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/jsr.json b/jsr.json index 57dbe8489..c2c0460bd 100644 --- a/jsr.json +++ b/jsr.json @@ -94,6 +94,7 @@ "./utils/headers": "./src/utils/headers.ts", "./utils/html": "./src/utils/html.ts", "./utils/http-status": "./src/utils/http-status.ts", + "./utils/parse-accept": "./src/utils/parse-accept.ts", "./utils/jwt": "./src/utils/jwt/index.ts", "./utils/jwt/jwa": "./src/utils/jwt/jwa.ts", "./utils/jwt/jws": "./src/utils/jwt/jws.ts", From 6b4e053ea0b73731cb23bf823f7810ab5688fa41 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Sat, 25 Jan 2025 22:19:01 +0100 Subject: [PATCH 06/10] fix: export default options, remove empty type --- src/middleware/language/index.ts | 3 +-- src/middleware/language/language.ts | 2 +- src/utils/parse-accept.ts | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/middleware/language/index.ts b/src/middleware/language/index.ts index 4d466504d..d225a54af 100644 --- a/src/middleware/language/index.ts +++ b/src/middleware/language/index.ts @@ -7,9 +7,8 @@ export { detectFromHeader, detectFromPath, detectFromQuery, + DEFAULT_OPTIONS, } 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 index 2e8a4f4c0..ecab0658e 100644 --- a/src/middleware/language/language.ts +++ b/src/middleware/language/language.ts @@ -48,7 +48,7 @@ export interface LanguageVariables { language: string } -const DEFAULT_OPTIONS: DetectorOptions = { +export const DEFAULT_OPTIONS: DetectorOptions = { order: ['querystring', 'cookie', 'header'], lookupQueryString: 'lang', lookupCookie: 'language', diff --git a/src/utils/parse-accept.ts b/src/utils/parse-accept.ts index ca8a2b2fb..73f3a8dfe 100644 --- a/src/utils/parse-accept.ts +++ b/src/utils/parse-accept.ts @@ -22,11 +22,11 @@ export const parseAccept = (acceptHeader: string): Accept[] => { .sort(sortByQualityAndIndex) .map(({ type, params, q }) => ({ type, params, q })) } - +const parseAcceptValueRegex = /;(?=(?:(?:[^"]*"){2})*[^"]*$)/ const parseAcceptValue = ({ value, index }: { value: string; index: number }) => { const parts = value .trim() - .split(/;(?=(?:(?:[^"]*"){2})*[^"]*$)/) + .split(parseAcceptValueRegex) .map((s) => s.trim()) const type = parts[0] if (!type) { @@ -51,7 +51,7 @@ const parseParams = (paramParts: string[]): Record => { const parseQuality = (qVal?: string): number => { if (qVal === undefined) { - return 1.0 + return 1 } if (qVal === '') { return 1 From e84b8405cbfd8fadcd77b3ac48b9521523b480ae Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Mon, 27 Jan 2025 20:53:18 +0100 Subject: [PATCH 07/10] refac: rename languageDetectorMiddleware to languageDetector --- src/middleware/language/language.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/middleware/language/language.ts b/src/middleware/language/language.ts index ecab0658e..7f47b4c87 100644 --- a/src/middleware/language/language.ts +++ b/src/middleware/language/language.ts @@ -273,15 +273,15 @@ export const languageDetector = (userOptions: Partial): Middlew validateOptions(options) - return async function languageDetectorMiddleware(c, next) { + return async function languageDetector(ctx, next) { try { - const lang = detectLanguage(c, options) - c.set('language', lang) + const lang = detectLanguage(ctx, options) + ctx.set('language', lang) } catch (error) { if (options.debug) { console.error('Language detection failed:', error) } - c.set('language', options.fallbackLanguage) + ctx.set('language', options.fallbackLanguage) } await next() From 2127d80e1409fe5cef5a98e5f995161f499ca6ab Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Thu, 30 Jan 2025 06:12:16 +0100 Subject: [PATCH 08/10] chore format code --- src/middleware/language/index.test.ts | 1 - src/utils/parse-accept.test.ts | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/middleware/language/index.test.ts b/src/middleware/language/index.test.ts index 4290dcf76..f0c0ac640 100644 --- a/src/middleware/language/index.test.ts +++ b/src/middleware/language/index.test.ts @@ -272,5 +272,4 @@ describe('languageDetector', () => { consoleSpy.mockRestore() }) }) - }) diff --git a/src/utils/parse-accept.test.ts b/src/utils/parse-accept.test.ts index b104a3b60..a1019c92a 100644 --- a/src/utils/parse-accept.test.ts +++ b/src/utils/parse-accept.test.ts @@ -40,17 +40,17 @@ describe('parseAccept Comprehensive Tests', () => { describe('Parameter Handling', () => { test('handles complex parameters', () => { - const header = 'type;a=1;b="2";c=\'3\';d="semi;colon";e="nested"quoted""' - const result = parseAccept(header) - expect(result[0].params).toEqual({ - a: '1', - b: '"2"', - // eslint-disable-next-line quotes - c: "'3'", - d: '"semi;colon"', - e: '"nested"quoted""' - }) + const header = 'type;a=1;b="2";c=\'3\';d="semi;colon";e="nested"quoted""' + const result = parseAccept(header) + expect(result[0].params).toEqual({ + a: '1', + b: '"2"', + // eslint-disable-next-line quotes + c: "'3'", + d: '"semi;colon"', + e: '"nested"quoted""', }) + }) test('handles malformed parameters', () => { const header = 'type;=value;;key=;=;====;key====value' From 007ea3de46d3e5c1cbbd354e9a429e820b8f3610 Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Tue, 4 Feb 2025 00:58:50 +0100 Subject: [PATCH 09/10] refac: apply patches --- jsr.json | 2 +- src/helper/accepts/accepts.test.ts | 2 +- src/helper/accepts/accepts.ts | 2 +- src/middleware/language/index.ts | 5 ++--- src/middleware/language/language.ts | 2 +- src/utils/{parse-accept.test.ts => accept.test.ts} | 4 +++- src/utils/{parse-accept.ts => accept.ts} | 0 7 files changed, 9 insertions(+), 8 deletions(-) rename src/utils/{parse-accept.test.ts => accept.test.ts} (96%) rename src/utils/{parse-accept.ts => accept.ts} (100%) diff --git a/jsr.json b/jsr.json index c2c0460bd..c811c2883 100644 --- a/jsr.json +++ b/jsr.json @@ -94,7 +94,7 @@ "./utils/headers": "./src/utils/headers.ts", "./utils/html": "./src/utils/html.ts", "./utils/http-status": "./src/utils/http-status.ts", - "./utils/parse-accept": "./src/utils/parse-accept.ts", + "./utils/accept": "./src/utils/accept.ts", "./utils/jwt": "./src/utils/jwt/index.ts", "./utils/jwt/jwa": "./src/utils/jwt/jwa.ts", "./utils/jwt/jws": "./src/utils/jwt/jws.ts", diff --git a/src/helper/accepts/accepts.test.ts b/src/helper/accepts/accepts.test.ts index 0e094437a..38f7ba7c8 100644 --- a/src/helper/accepts/accepts.test.ts +++ b/src/helper/accepts/accepts.test.ts @@ -1,5 +1,5 @@ import { Hono } from '../..' -import { parseAccept } from '../../utils/parse-accept' +import { parseAccept } from '../../utils/accept' import type { Accept, acceptsConfig, acceptsOptions } from './accepts' import { accepts, defaultMatch } from './accepts' diff --git a/src/helper/accepts/accepts.ts b/src/helper/accepts/accepts.ts index f81f78d79..5f7d294f8 100644 --- a/src/helper/accepts/accepts.ts +++ b/src/helper/accepts/accepts.ts @@ -1,6 +1,6 @@ import type { Context } from '../../context' import type { AcceptHeader } from '../../utils/headers' -import { parseAccept } from '../../utils/parse-accept' +import { parseAccept } from '../../utils/accept' export interface Accept { type: string diff --git a/src/middleware/language/index.ts b/src/middleware/language/index.ts index d225a54af..061c75380 100644 --- a/src/middleware/language/index.ts +++ b/src/middleware/language/index.ts @@ -1,8 +1,7 @@ -import type { LanguageVariables, DetectorType, CacheType } from './language' -export type { LanguageVariables, DetectorType, CacheType } +import type { LanguageVariables, DetectorOptions, DetectorType, CacheType } from './language' +export type { LanguageVariables, DetectorOptions, DetectorType, CacheType } export { languageDetector, - DetectorOptions, detectFromCookie, detectFromHeader, detectFromPath, diff --git a/src/middleware/language/language.ts b/src/middleware/language/language.ts index 7f47b4c87..c1213f4ea 100644 --- a/src/middleware/language/language.ts +++ b/src/middleware/language/language.ts @@ -5,7 +5,7 @@ import type { Context } from '../../context' import { setCookie, getCookie } from '../../helper/cookie' import type { MiddlewareHandler } from '../../types' -import { parseAccept } from '../../utils/parse-accept' +import { parseAccept } from '../../utils/accept' export type DetectorType = 'path' | 'querystring' | 'cookie' | 'header' export type CacheType = 'cookie' diff --git a/src/utils/parse-accept.test.ts b/src/utils/accept.test.ts similarity index 96% rename from src/utils/parse-accept.test.ts rename to src/utils/accept.test.ts index a1019c92a..4d5655182 100644 --- a/src/utils/parse-accept.test.ts +++ b/src/utils/accept.test.ts @@ -1,4 +1,4 @@ -import { parseAccept } from './parse-accept' +import { parseAccept } from './accept' describe('parseAccept Comprehensive Tests', () => { describe('Basic Functionality', () => { @@ -12,7 +12,9 @@ describe('parseAccept Comprehensive Tests', () => { test('handles missing header', () => { expect(parseAccept('')).toEqual([]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(parseAccept(undefined as any)).toEqual([]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(parseAccept(null as any)).toEqual([]) }) }) diff --git a/src/utils/parse-accept.ts b/src/utils/accept.ts similarity index 100% rename from src/utils/parse-accept.ts rename to src/utils/accept.ts From ecdbd5ebe1f9d06a396c721d0fc49ba8b69c052c Mon Sep 17 00:00:00 2001 From: Raed Bahri Date: Tue, 4 Feb 2025 20:33:33 +0100 Subject: [PATCH 10/10] refactor: change export type in language --- src/helper/accepts/accepts.ts | 2 +- src/middleware/language/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/helper/accepts/accepts.ts b/src/helper/accepts/accepts.ts index 5f7d294f8..92c789e03 100644 --- a/src/helper/accepts/accepts.ts +++ b/src/helper/accepts/accepts.ts @@ -1,6 +1,6 @@ import type { Context } from '../../context' -import type { AcceptHeader } from '../../utils/headers' import { parseAccept } from '../../utils/accept' +import type { AcceptHeader } from '../../utils/headers' export interface Accept { type: string diff --git a/src/middleware/language/index.ts b/src/middleware/language/index.ts index 061c75380..602536549 100644 --- a/src/middleware/language/index.ts +++ b/src/middleware/language/index.ts @@ -6,7 +6,6 @@ export { detectFromHeader, detectFromPath, detectFromQuery, - DEFAULT_OPTIONS, } from './language' declare module '../..' { interface ContextVariableMap extends LanguageVariables {}