Skip to content

Commit 8962847

Browse files
authored
feat: add language detection (#52)
* feat: add language detection
1 parent 31ba5ee commit 8962847

File tree

7 files changed

+219
-5
lines changed

7 files changed

+219
-5
lines changed

package-lock.json

Lines changed: 17 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@gravity-ui/eslint-config": "^3.2.0",
2929
"@gravity-ui/prettier-config": "^1.1.0",
3030
"@gravity-ui/tsconfig": "^1.0.0",
31+
"@types/accept-language-parser": "^1.5.6",
3132
"@types/cookie-parser": "^1.4.3",
3233
"@types/express": "^4.17.21",
3334
"@types/jest": "^29.2.3",
@@ -45,6 +46,7 @@
4546
"typescript": "^5.6.2"
4647
},
4748
"dependencies": {
49+
"accept-language-parser": "^1.5.0",
4850
"body-parser": "^1.20.1",
4951
"cookie-parser": "^1.4.7",
5052
"csp-header": "^5.2.1",
@@ -53,7 +55,7 @@
5355
"uuid": "^9.0.0"
5456
},
5557
"peerDependencies": {
56-
"@gravity-ui/nodekit": "^1.5.0"
58+
"@gravity-ui/nodekit": "^1.6.0"
5759
},
5860
"nano-staged": {
5961
"*.{js,ts}": [

src/expresskit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {setupBaseMiddleware} from './base-middleware';
1010
import {setupParsers} from './parsers';
1111
import {setupRoutes} from './router';
1212
import type {AppRoutes} from './types';
13+
import {setupLangMiddleware} from './lang/lang-middleware';
1314

1415
const DEFAULT_PORT = 3030;
1516

@@ -34,6 +35,7 @@ export class ExpressKit {
3435
this.express.get('/__version', (_, res) => res.send({version: this.config.appVersion}));
3536

3637
setupBaseMiddleware(this.nodekit.ctx, this.express);
38+
setupLangMiddleware(this.nodekit.ctx, this.express);
3739
setupParsers(this.nodekit.ctx, this.express);
3840
setupRoutes(this.nodekit.ctx, this.express, routes);
3941

src/lang/lang-middleware.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {AppContext} from '@gravity-ui/nodekit';
2+
import acceptLanguage from 'accept-language-parser';
3+
import type {Express} from 'express';
4+
import {setLang} from './set-lang';
5+
6+
export function setupLangMiddleware(appCtx: AppContext, expressApp: Express) {
7+
const config = appCtx.config;
8+
9+
const {appDefaultLang, appAllowedLangs, appLangQueryParamName} = config;
10+
if (!(appAllowedLangs && appAllowedLangs.length > 0 && appDefaultLang)) {
11+
return;
12+
}
13+
expressApp.use((req, _res, next) => {
14+
const langQuery = appLangQueryParamName && req.query[appLangQueryParamName];
15+
if (langQuery && typeof langQuery === 'string' && appAllowedLangs.includes(langQuery)) {
16+
setLang({lang: langQuery, ctx: req.ctx});
17+
return next();
18+
}
19+
20+
setLang({lang: appDefaultLang, ctx: req.ctx});
21+
22+
if (config.appGetLangByHostname) {
23+
const langByHostname = config.appGetLangByHostname(req.hostname);
24+
25+
if (langByHostname) {
26+
setLang({lang: langByHostname, ctx: req.ctx});
27+
}
28+
} else {
29+
const tld = req.hostname.split('.').pop();
30+
const langByTld = tld && config.appLangByTld ? config.appLangByTld[tld] : undefined;
31+
32+
if (langByTld) {
33+
setLang({lang: langByTld, ctx: req.ctx});
34+
}
35+
}
36+
37+
if (req.headers['accept-language']) {
38+
const langByHeader = acceptLanguage.pick(
39+
appAllowedLangs,
40+
req.headers['accept-language'],
41+
{
42+
loose: true,
43+
},
44+
);
45+
46+
if (langByHeader) {
47+
setLang({lang: langByHeader, ctx: req.ctx});
48+
}
49+
}
50+
51+
return next();
52+
});
53+
}

src/lang/set-lang.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type {AppContext} from '@gravity-ui/nodekit';
2+
import {USER_LANGUAGE_PARAM_NAME} from '@gravity-ui/nodekit';
3+
4+
export const setLang = ({lang, ctx}: {lang: string; ctx: AppContext}) => {
5+
const config = ctx.config;
6+
if (!config.appAllowedLangs || config.appAllowedLangs.includes(lang)) {
7+
ctx.set(USER_LANGUAGE_PARAM_NAME, lang);
8+
return true;
9+
}
10+
11+
return false;
12+
};

src/tests/lang-detect.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {ExpressKit, Request, Response} from '..';
2+
import {NodeKit, USER_LANGUAGE_PARAM_NAME} from '@gravity-ui/nodekit';
3+
import request from 'supertest';
4+
5+
const setupApp = (langConfig: NodeKit['config'] = {}) => {
6+
const nodekit = new NodeKit({
7+
config: {
8+
appDefaultLang: 'ru',
9+
appAllowedLangs: ['ru', 'en'],
10+
...langConfig,
11+
},
12+
});
13+
const routes = {
14+
'GET /test': {
15+
handler: (req: Request, res: Response) => {
16+
res.status(200);
17+
const lang = req.ctx.get(USER_LANGUAGE_PARAM_NAME);
18+
res.send({lang});
19+
},
20+
},
21+
};
22+
23+
const app = new ExpressKit(nodekit, routes);
24+
25+
return app;
26+
};
27+
28+
describe('langMiddleware with default options', () => {
29+
it('should set default lang if no hostname or accept-language header', async () => {
30+
const app = setupApp();
31+
const res = await request.agent(app.express).get('/test');
32+
33+
expect(res.text).toBe('{"lang":"ru"}');
34+
expect(res.status).toBe(200);
35+
});
36+
37+
it('should set lang for en domains by tld', async () => {
38+
const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}});
39+
const res = await request.agent(app.express).host('www.foo.com').get('/test');
40+
41+
expect(res.text).toBe('{"lang":"en"}');
42+
expect(res.status).toBe(200);
43+
});
44+
45+
it('should set lang for ru domains by tld ', async () => {
46+
const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}});
47+
const res = await request.agent(app.express).host('www.foo.ru').get('/test');
48+
49+
expect(res.text).toBe('{"lang":"ru"}');
50+
expect(res.status).toBe(200);
51+
});
52+
53+
it('should set default lang for other domains by tld ', async () => {
54+
const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}});
55+
const res = await request.agent(app.express).host('www.foo.jp').get('/test');
56+
57+
expect(res.text).toBe('{"lang":"ru"}');
58+
expect(res.status).toBe(200);
59+
});
60+
});
61+
62+
describe('langMiddleware with getLangByHostname is set', () => {
63+
it('should set lang by known hostname if getLangByHostname is set', async () => {
64+
const app = setupApp({
65+
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
66+
});
67+
const res = await request.agent(app.express).host('www.foo.com').get('/test');
68+
69+
expect(res.text).toBe('{"lang":"en"}');
70+
expect(res.status).toBe(200);
71+
});
72+
it("shouldn't set default lang for unknown hostname if getLangByHostname is set", async () => {
73+
const app = setupApp({
74+
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
75+
});
76+
const res = await request.agent(app.express).host('www.bar.com').get('/test');
77+
78+
expect(res.text).toBe('{"lang":"ru"}');
79+
expect(res.status).toBe(200);
80+
});
81+
});
82+
83+
describe('langMiddleware with accept-language header', () => {
84+
it('should set lang if known accept-language', async () => {
85+
const app = setupApp({
86+
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
87+
});
88+
const res = await request
89+
.agent(app.express)
90+
.host('www.foo.com')
91+
.set('accept-language', 'ru-RU, ru;q=0.9, en-US;q=0.8, en;q=0.7, fr;q=0.6')
92+
.get('/test');
93+
94+
expect(res.text).toBe('{"lang":"ru"}');
95+
expect(res.status).toBe(200);
96+
});
97+
it('should set tld lang for unknown accept-language', async () => {
98+
const app = setupApp({
99+
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
100+
});
101+
const res = await request
102+
.agent(app.express)
103+
.host('www.foo.com')
104+
.set('accept-language', 'fr;q=0.6')
105+
.get('/test');
106+
107+
expect(res.text).toBe('{"lang":"en"}');
108+
expect(res.status).toBe(200);
109+
});
110+
});
111+
112+
describe('langMiddleware with lang query param', () => {
113+
it('should set lang if known accept-language', async () => {
114+
const app = setupApp({
115+
appLangQueryParamName: '_lang',
116+
});
117+
const res = await request
118+
.agent(app.express)
119+
.host('www.foo.com')
120+
.set('accept-language', 'ru-RU, ru;q=0.9, en-US;q=0.8, en;q=0.7, fr;q=0.6')
121+
.get('/test?_lang=en');
122+
123+
expect(res.text).toBe('{"lang":"en"}');
124+
expect(res.status).toBe(200);
125+
});
126+
});

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ declare module '@gravity-ui/nodekit' {
6868
expressCspReportOnly?: boolean;
6969
expressCspReportTo?: CSPMiddlewareParams['reportTo'];
7070
expressCspReportUri?: CSPMiddlewareParams['reportUri'];
71+
72+
appAllowedLangs?: string[];
73+
appDefaultLang?: string;
74+
appLangQueryParamName?: string;
75+
appLangByTld?: Record<string, string | undefined>;
76+
appGetLangByHostname?: (hostname: string) => string | undefined;
7177
}
7278
}
7379

0 commit comments

Comments
 (0)