diff --git a/src/server/helpers/i18n.ts b/src/server/helpers/i18n.ts index 526915ca39..bdc22988e9 100644 --- a/src/server/helpers/i18n.ts +++ b/src/server/helpers/i18n.ts @@ -18,7 +18,6 @@ import type {Request} from 'express'; - type AcceptedLanguage = { code: string, subtags: string[], @@ -33,15 +32,35 @@ type AcceptedLanguage = { export function parseAcceptLanguage(acceptLanguage: string): AcceptedLanguage[] { return acceptLanguage .split(',') + .map((rawValue) => rawValue.trim()) + .filter(Boolean) .map((value) => { - const match = value.match(/(?[a-zA-Z]{2,3})(?:-(?[\w-]+))?(?:;q=(?[01](?:\.[0-9]+)?))?/); - return match ? { - code: match.groups.tag, - subtags: match.groups.subtag?.split('-') ?? [], - weight: parseFloat(match.groups.weight ?? '1') - } : null; + // Matches: + // - primary tag: 2-3 letters (e.g. en, fr, pt) + // - optional subtags: -Latn, -US, etc. + // - optional quality weight: ;q=0.8 + const match = value.match( + /^(?[a-zA-Z]{2,3})(?:-(?[a-zA-Z0-9-]+))?(?:\s*;\s*q=(?[01](?:\.[0-9]+)?))?$/ + ); + + if (!match?.groups?.tag) { + return null; + } + + const weight = parseFloat(match.groups.weight ?? '1'); + if (Number.isNaN(weight) || weight < 0 || weight > 1) { + return null; + } + + return { + code: match.groups.tag.toLowerCase(), + subtags: match.groups.subtag + ? match.groups.subtag.split('-').map((subtag) => subtag.toLowerCase()) + : [], + weight + }; }) - .filter((value) => value !== null) + .filter((value): value is AcceptedLanguage => value !== null) .sort((a, b) => b.weight - a.weight); } diff --git a/src/server/helpers/test-i18n.js b/src/server/helpers/test-i18n.js new file mode 100644 index 0000000000..e200c9ab80 --- /dev/null +++ b/src/server/helpers/test-i18n.js @@ -0,0 +1,54 @@ +import {getAcceptedLanguageCodes, parseAcceptLanguage} from '../../../../src/server/helpers/i18n'; +import chai from 'chai'; + + +const {expect} = chai; + +describe('i18n helpers', () => { + describe('parseAcceptLanguage', () => { + it('should parse and sort language tags by q-value', () => { + const result = parseAcceptLanguage('fr-CA,fr;q=0.8,en;q=0.6'); + + expect(result).to.deep.equal([ + {code: 'fr', subtags: ['ca'], weight: 1}, + {code: 'fr', subtags: [], weight: 0.8}, + {code: 'en', subtags: [], weight: 0.6} + ]); + }); + + it('should handle whitespace and normalize casing', () => { + const result = parseAcceptLanguage(' EN-us ;q=0.9 , FR ; q=0.8 '); + + expect(result).to.deep.equal([ + {code: 'en', subtags: ['us'], weight: 0.9}, + {code: 'fr', subtags: [], weight: 0.8} + ]); + }); + + it('should skip malformed language values', () => { + const result = parseAcceptLanguage('@@,en;q=0.8,*;q=0.5'); + + expect(result).to.deep.equal([ + {code: 'en', subtags: [], weight: 0.8} + ]); + }); + + it('should skip invalid q-values', () => { + const result = parseAcceptLanguage('de;q=abc,en;q=0.7,fr;q=1.5'); + + expect(result).to.deep.equal([ + {code: 'en', subtags: [], weight: 0.7} + ]); + }); + }); + + describe('getAcceptedLanguageCodes', () => { + it('should return normalized language codes ordered by preference', () => { + const request = { + headers: {'accept-language': 'fr-CA;q=0.7,EN;q=0.9'} + }; + + expect(getAcceptedLanguageCodes(request)).to.deep.equal(['en', 'fr']); + }); + }); +});