Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions src/server/helpers/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

import type {Request} from 'express';


type AcceptedLanguage = {
code: string,
subtags: string[],
Expand All @@ -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(/(?<tag>[a-zA-Z]{2,3})(?:-(?<subtag>[\w-]+))?(?:;q=(?<weight>[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(
/^(?<tag>[a-zA-Z]{2,3})(?:-(?<subtag>[a-zA-Z0-9-]+))?(?:\s*;\s*q=(?<weight>[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);
}

Expand Down
54 changes: 54 additions & 0 deletions src/server/helpers/test-i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {getAcceptedLanguageCodes, parseAcceptLanguage} from '../../../../src/server/helpers/i18n';

Check failure on line 1 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L1

Unable to resolve path to module '../../../../src/server/helpers/i18n' (import/no-unresolved)

Check failure on line 1 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L1

"../../../../src/server/helpers/i18n" is not found (node/no-missing-import)
import chai from 'chai';


const {expect} = chai;

describe('i18n helpers', () => {

Check failure on line 7 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L7

'describe' is not defined (no-undef)
describe('parseAcceptLanguage', () => {

Check failure on line 8 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L8

'describe' is not defined (no-undef)
it('should parse and sort language tags by q-value', () => {

Check failure on line 9 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L9

'it' is not defined (no-undef)
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', () => {

Check failure on line 19 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L19

'it' is not defined (no-undef)
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', () => {

Check failure on line 28 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L28

'it' is not defined (no-undef)
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', () => {

Check failure on line 36 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L36

'it' is not defined (no-undef)
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', () => {

Check failure on line 45 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L45

'describe' is not defined (no-undef)
it('should return normalized language codes ordered by preference', () => {

Check failure on line 46 in src/server/helpers/test-i18n.js

View workflow job for this annotation

GitHub Actions / ESLint

src/server/helpers/test-i18n.js#L46

'it' is not defined (no-undef)
const request = {
headers: {'accept-language': 'fr-CA;q=0.7,EN;q=0.9'}
};

expect(getAcceptedLanguageCodes(request)).to.deep.equal(['en', 'fr']);
});
});
});
Loading