Skip to content

Commit

Permalink
feat: add language detection (#52)
Browse files Browse the repository at this point in the history
* feat: add language detection
  • Loading branch information
melikhov-dev authored Oct 21, 2024
1 parent 31ba5ee commit 8962847
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 5 deletions.
21 changes: 17 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@gravity-ui/eslint-config": "^3.2.0",
"@gravity-ui/prettier-config": "^1.1.0",
"@gravity-ui/tsconfig": "^1.0.0",
"@types/accept-language-parser": "^1.5.6",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.21",
"@types/jest": "^29.2.3",
Expand All @@ -45,6 +46,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"accept-language-parser": "^1.5.0",
"body-parser": "^1.20.1",
"cookie-parser": "^1.4.7",
"csp-header": "^5.2.1",
Expand All @@ -53,7 +55,7 @@
"uuid": "^9.0.0"
},
"peerDependencies": {
"@gravity-ui/nodekit": "^1.5.0"
"@gravity-ui/nodekit": "^1.6.0"
},
"nano-staged": {
"*.{js,ts}": [
Expand Down
2 changes: 2 additions & 0 deletions src/expresskit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {setupBaseMiddleware} from './base-middleware';
import {setupParsers} from './parsers';
import {setupRoutes} from './router';
import type {AppRoutes} from './types';
import {setupLangMiddleware} from './lang/lang-middleware';

const DEFAULT_PORT = 3030;

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

setupBaseMiddleware(this.nodekit.ctx, this.express);
setupLangMiddleware(this.nodekit.ctx, this.express);
setupParsers(this.nodekit.ctx, this.express);
setupRoutes(this.nodekit.ctx, this.express, routes);

Expand Down
53 changes: 53 additions & 0 deletions src/lang/lang-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {AppContext} from '@gravity-ui/nodekit';
import acceptLanguage from 'accept-language-parser';
import type {Express} from 'express';
import {setLang} from './set-lang';

export function setupLangMiddleware(appCtx: AppContext, expressApp: Express) {
const config = appCtx.config;

const {appDefaultLang, appAllowedLangs, appLangQueryParamName} = config;
if (!(appAllowedLangs && appAllowedLangs.length > 0 && appDefaultLang)) {
return;
}
expressApp.use((req, _res, next) => {
const langQuery = appLangQueryParamName && req.query[appLangQueryParamName];
if (langQuery && typeof langQuery === 'string' && appAllowedLangs.includes(langQuery)) {
setLang({lang: langQuery, ctx: req.ctx});
return next();
}

setLang({lang: appDefaultLang, ctx: req.ctx});

if (config.appGetLangByHostname) {
const langByHostname = config.appGetLangByHostname(req.hostname);

if (langByHostname) {
setLang({lang: langByHostname, ctx: req.ctx});
}
} else {
const tld = req.hostname.split('.').pop();
const langByTld = tld && config.appLangByTld ? config.appLangByTld[tld] : undefined;

if (langByTld) {
setLang({lang: langByTld, ctx: req.ctx});
}
}

if (req.headers['accept-language']) {
const langByHeader = acceptLanguage.pick(
appAllowedLangs,
req.headers['accept-language'],
{
loose: true,
},
);

if (langByHeader) {
setLang({lang: langByHeader, ctx: req.ctx});
}
}

return next();
});
}
12 changes: 12 additions & 0 deletions src/lang/set-lang.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {AppContext} from '@gravity-ui/nodekit';
import {USER_LANGUAGE_PARAM_NAME} from '@gravity-ui/nodekit';

export const setLang = ({lang, ctx}: {lang: string; ctx: AppContext}) => {
const config = ctx.config;
if (!config.appAllowedLangs || config.appAllowedLangs.includes(lang)) {
ctx.set(USER_LANGUAGE_PARAM_NAME, lang);
return true;
}

return false;
};
126 changes: 126 additions & 0 deletions src/tests/lang-detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {ExpressKit, Request, Response} from '..';
import {NodeKit, USER_LANGUAGE_PARAM_NAME} from '@gravity-ui/nodekit';
import request from 'supertest';

const setupApp = (langConfig: NodeKit['config'] = {}) => {
const nodekit = new NodeKit({
config: {
appDefaultLang: 'ru',
appAllowedLangs: ['ru', 'en'],
...langConfig,
},
});
const routes = {
'GET /test': {
handler: (req: Request, res: Response) => {
res.status(200);
const lang = req.ctx.get(USER_LANGUAGE_PARAM_NAME);
res.send({lang});
},
},
};

const app = new ExpressKit(nodekit, routes);

return app;
};

describe('langMiddleware with default options', () => {
it('should set default lang if no hostname or accept-language header', async () => {
const app = setupApp();
const res = await request.agent(app.express).get('/test');

expect(res.text).toBe('{"lang":"ru"}');
expect(res.status).toBe(200);
});

it('should set lang for en domains by tld', async () => {
const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}});
const res = await request.agent(app.express).host('www.foo.com').get('/test');

expect(res.text).toBe('{"lang":"en"}');
expect(res.status).toBe(200);
});

it('should set lang for ru domains by tld ', async () => {
const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}});
const res = await request.agent(app.express).host('www.foo.ru').get('/test');

expect(res.text).toBe('{"lang":"ru"}');
expect(res.status).toBe(200);
});

it('should set default lang for other domains by tld ', async () => {
const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}});
const res = await request.agent(app.express).host('www.foo.jp').get('/test');

expect(res.text).toBe('{"lang":"ru"}');
expect(res.status).toBe(200);
});
});

describe('langMiddleware with getLangByHostname is set', () => {
it('should set lang by known hostname if getLangByHostname is set', async () => {
const app = setupApp({
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
});
const res = await request.agent(app.express).host('www.foo.com').get('/test');

expect(res.text).toBe('{"lang":"en"}');
expect(res.status).toBe(200);
});
it("shouldn't set default lang for unknown hostname if getLangByHostname is set", async () => {
const app = setupApp({
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
});
const res = await request.agent(app.express).host('www.bar.com').get('/test');

expect(res.text).toBe('{"lang":"ru"}');
expect(res.status).toBe(200);
});
});

describe('langMiddleware with accept-language header', () => {
it('should set lang if known accept-language', async () => {
const app = setupApp({
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
});
const res = await request
.agent(app.express)
.host('www.foo.com')
.set('accept-language', 'ru-RU, ru;q=0.9, en-US;q=0.8, en;q=0.7, fr;q=0.6')
.get('/test');

expect(res.text).toBe('{"lang":"ru"}');
expect(res.status).toBe(200);
});
it('should set tld lang for unknown accept-language', async () => {
const app = setupApp({
appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined),
});
const res = await request
.agent(app.express)
.host('www.foo.com')
.set('accept-language', 'fr;q=0.6')
.get('/test');

expect(res.text).toBe('{"lang":"en"}');
expect(res.status).toBe(200);
});
});

describe('langMiddleware with lang query param', () => {
it('should set lang if known accept-language', async () => {
const app = setupApp({
appLangQueryParamName: '_lang',
});
const res = await request
.agent(app.express)
.host('www.foo.com')
.set('accept-language', 'ru-RU, ru;q=0.9, en-US;q=0.8, en;q=0.7, fr;q=0.6')
.get('/test?_lang=en');

expect(res.text).toBe('{"lang":"en"}');
expect(res.status).toBe(200);
});
});
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ declare module '@gravity-ui/nodekit' {
expressCspReportOnly?: boolean;
expressCspReportTo?: CSPMiddlewareParams['reportTo'];
expressCspReportUri?: CSPMiddlewareParams['reportUri'];

appAllowedLangs?: string[];
appDefaultLang?: string;
appLangQueryParamName?: string;
appLangByTld?: Record<string, string | undefined>;
appGetLangByHostname?: (hostname: string) => string | undefined;
}
}

Expand Down

0 comments on commit 8962847

Please sign in to comment.