@hazeljs/i18n 0.2.0-beta.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +192 -0
- package/README.md +292 -0
- package/dist/decorators/lang.decorator.d.ts +23 -0
- package/dist/decorators/lang.decorator.d.ts.map +1 -0
- package/dist/decorators/lang.decorator.js +40 -0
- package/dist/decorators/lang.decorator.test.d.ts +2 -0
- package/dist/decorators/lang.decorator.test.d.ts.map +1 -0
- package/dist/decorators/lang.decorator.test.js +61 -0
- package/dist/i18n.interceptor.d.ts +29 -0
- package/dist/i18n.interceptor.d.ts.map +1 -0
- package/dist/i18n.interceptor.js +52 -0
- package/dist/i18n.interceptor.test.d.ts +2 -0
- package/dist/i18n.interceptor.test.d.ts.map +1 -0
- package/dist/i18n.interceptor.test.js +126 -0
- package/dist/i18n.middleware.d.ts +55 -0
- package/dist/i18n.middleware.d.ts.map +1 -0
- package/dist/i18n.middleware.js +120 -0
- package/dist/i18n.middleware.test.d.ts +2 -0
- package/dist/i18n.middleware.test.d.ts.map +1 -0
- package/dist/i18n.middleware.test.js +239 -0
- package/dist/i18n.module.d.ts +73 -0
- package/dist/i18n.module.d.ts.map +1 -0
- package/dist/i18n.module.js +154 -0
- package/dist/i18n.module.test.d.ts +2 -0
- package/dist/i18n.module.test.d.ts.map +1 -0
- package/dist/i18n.module.test.js +162 -0
- package/dist/i18n.service.d.ts +112 -0
- package/dist/i18n.service.d.ts.map +1 -0
- package/dist/i18n.service.js +228 -0
- package/dist/i18n.service.test.d.ts +2 -0
- package/dist/i18n.service.test.d.ts.map +1 -0
- package/dist/i18n.service.test.js +297 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/translation.loader.d.ts +16 -0
- package/dist/translation.loader.d.ts.map +1 -0
- package/dist/translation.loader.js +47 -0
- package/dist/translation.loader.test.d.ts +2 -0
- package/dist/translation.loader.test.d.ts.map +1 -0
- package/dist/translation.loader.test.js +85 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +51 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.I18nInterceptor = void 0;
|
|
4
|
+
const i18n_middleware_1 = require("./i18n.middleware");
|
|
5
|
+
/**
|
|
6
|
+
* Optional interceptor that automatically translates a `message` field found
|
|
7
|
+
* in the response object.
|
|
8
|
+
*
|
|
9
|
+
* The `message` value is treated as an i18n key. If the key exists in the
|
|
10
|
+
* translation store for the request locale, it is replaced in-place; otherwise
|
|
11
|
+
* the original value is preserved.
|
|
12
|
+
*
|
|
13
|
+
* Register it per-controller with @UseInterceptors(I18nInterceptor) or
|
|
14
|
+
* globally via the HazelApp interceptor API.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // Returning from a controller:
|
|
18
|
+
* return { message: 'user.created', data: user };
|
|
19
|
+
* // → { message: 'User created successfully.', data: user } (if key exists)
|
|
20
|
+
*/
|
|
21
|
+
class I18nInterceptor {
|
|
22
|
+
constructor(i18n) {
|
|
23
|
+
this.i18n = i18n;
|
|
24
|
+
}
|
|
25
|
+
async intercept(context, next) {
|
|
26
|
+
const result = await next();
|
|
27
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
const body = result;
|
|
31
|
+
if (typeof body.message !== 'string') {
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
const locale = this.extractLocale(context);
|
|
35
|
+
const translated = this.i18n.t(body.message, { locale });
|
|
36
|
+
// Only substitute when a real translation was found (key !== translated).
|
|
37
|
+
if (translated !== body.message) {
|
|
38
|
+
return { ...body, message: translated };
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reads the locale attached by LocaleMiddleware from the raw request object.
|
|
44
|
+
*/
|
|
45
|
+
extractLocale(context) {
|
|
46
|
+
const req = context.req ?? context['request'];
|
|
47
|
+
if (!req || typeof req !== 'object')
|
|
48
|
+
return undefined;
|
|
49
|
+
return req[i18n_middleware_1.LOCALE_KEY];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.I18nInterceptor = I18nInterceptor;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.interceptor.test.d.ts","sourceRoot":"","sources":["../src/i18n.interceptor.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
jest.mock('@hazeljs/core', () => ({
|
|
4
|
+
__esModule: true,
|
|
5
|
+
Service: () => () => undefined,
|
|
6
|
+
}));
|
|
7
|
+
const i18n_interceptor_1 = require("./i18n.interceptor");
|
|
8
|
+
const i18n_service_1 = require("./i18n.service");
|
|
9
|
+
const i18n_middleware_1 = require("./i18n.middleware");
|
|
10
|
+
const DEFAULT_OPTIONS = {
|
|
11
|
+
defaultLocale: 'en',
|
|
12
|
+
fallbackLocale: 'en',
|
|
13
|
+
translationsPath: './translations',
|
|
14
|
+
detection: ['query', 'cookie', 'header'],
|
|
15
|
+
queryParam: 'lang',
|
|
16
|
+
cookieName: 'locale',
|
|
17
|
+
isGlobal: true,
|
|
18
|
+
};
|
|
19
|
+
function makeService(store = new Map(), opts = DEFAULT_OPTIONS) {
|
|
20
|
+
const svc = new i18n_service_1.I18nService();
|
|
21
|
+
svc.initialize(store, opts);
|
|
22
|
+
return svc;
|
|
23
|
+
}
|
|
24
|
+
function makeStore(entries) {
|
|
25
|
+
const store = new Map();
|
|
26
|
+
for (const [locale, map] of Object.entries(entries)) {
|
|
27
|
+
store.set(locale, map);
|
|
28
|
+
}
|
|
29
|
+
return store;
|
|
30
|
+
}
|
|
31
|
+
function makeContext(locale) {
|
|
32
|
+
const req = {};
|
|
33
|
+
if (locale)
|
|
34
|
+
req[i18n_middleware_1.LOCALE_KEY] = locale;
|
|
35
|
+
return { req };
|
|
36
|
+
}
|
|
37
|
+
describe('I18nInterceptor', () => {
|
|
38
|
+
describe('intercept()', () => {
|
|
39
|
+
it('passes through null result', async () => {
|
|
40
|
+
const svc = makeService();
|
|
41
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
42
|
+
const next = jest.fn().mockResolvedValue(null);
|
|
43
|
+
const result = await interceptor.intercept(makeContext(), next);
|
|
44
|
+
expect(result).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
it('passes through non-object result (string)', async () => {
|
|
47
|
+
const svc = makeService();
|
|
48
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
49
|
+
const next = jest.fn().mockResolvedValue('plain string');
|
|
50
|
+
const result = await interceptor.intercept(makeContext(), next);
|
|
51
|
+
expect(result).toBe('plain string');
|
|
52
|
+
});
|
|
53
|
+
it('passes through array result', async () => {
|
|
54
|
+
const svc = makeService();
|
|
55
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
56
|
+
const next = jest.fn().mockResolvedValue([1, 2, 3]);
|
|
57
|
+
const result = await interceptor.intercept(makeContext(), next);
|
|
58
|
+
expect(result).toEqual([1, 2, 3]);
|
|
59
|
+
});
|
|
60
|
+
it('passes through object without message field', async () => {
|
|
61
|
+
const svc = makeService();
|
|
62
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
63
|
+
const body = { data: { id: 1 } };
|
|
64
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
65
|
+
const result = await interceptor.intercept(makeContext(), next);
|
|
66
|
+
expect(result).toBe(body);
|
|
67
|
+
});
|
|
68
|
+
it('passes through when message is not a string', async () => {
|
|
69
|
+
const svc = makeService();
|
|
70
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
71
|
+
const body = { message: 42 };
|
|
72
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
73
|
+
const result = await interceptor.intercept(makeContext(), next);
|
|
74
|
+
expect(result).toBe(body);
|
|
75
|
+
});
|
|
76
|
+
it('translates the message field when a key is found', async () => {
|
|
77
|
+
const store = makeStore({ en: { user: { created: 'User created successfully.' } } });
|
|
78
|
+
const svc = makeService(store);
|
|
79
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
80
|
+
const body = { message: 'user.created', data: { id: 1 } };
|
|
81
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
82
|
+
const result = await interceptor.intercept(makeContext('en'), next);
|
|
83
|
+
expect(result).toEqual({ message: 'User created successfully.', data: { id: 1 } });
|
|
84
|
+
});
|
|
85
|
+
it('preserves the original message when the key is not found', async () => {
|
|
86
|
+
const svc = makeService(new Map());
|
|
87
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
88
|
+
const body = { message: 'unknown.key' };
|
|
89
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
90
|
+
const result = await interceptor.intercept(makeContext('en'), next);
|
|
91
|
+
expect(result).toBe(body);
|
|
92
|
+
});
|
|
93
|
+
it('uses locale from request context', async () => {
|
|
94
|
+
const store = makeStore({
|
|
95
|
+
en: { greeting: 'Hello' },
|
|
96
|
+
fr: { greeting: 'Bonjour' },
|
|
97
|
+
});
|
|
98
|
+
const svc = makeService(store, { ...DEFAULT_OPTIONS, defaultLocale: 'en' });
|
|
99
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
100
|
+
const body = { message: 'greeting' };
|
|
101
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
102
|
+
const result = await interceptor.intercept(makeContext('fr'), next);
|
|
103
|
+
expect(result.message).toBe('Bonjour');
|
|
104
|
+
});
|
|
105
|
+
it('handles context without req (no locale)', async () => {
|
|
106
|
+
const store = makeStore({ en: { hi: 'Hi there' } });
|
|
107
|
+
const svc = makeService(store);
|
|
108
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
109
|
+
const body = { message: 'hi' };
|
|
110
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
111
|
+
// context with no req
|
|
112
|
+
const result = await interceptor.intercept({}, next);
|
|
113
|
+
expect(result.message).toBe('Hi there');
|
|
114
|
+
});
|
|
115
|
+
it('handles context with "request" key instead of "req"', async () => {
|
|
116
|
+
const store = makeStore({ en: { bye: 'Goodbye' } });
|
|
117
|
+
const svc = makeService(store);
|
|
118
|
+
const interceptor = new i18n_interceptor_1.I18nInterceptor(svc);
|
|
119
|
+
const body = { message: 'bye' };
|
|
120
|
+
const next = jest.fn().mockResolvedValue(body);
|
|
121
|
+
const ctx = { request: { [i18n_middleware_1.LOCALE_KEY]: 'en' } };
|
|
122
|
+
const result = await interceptor.intercept(ctx, next);
|
|
123
|
+
expect(result.message).toBe('Goodbye');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Request, Response } from '@hazeljs/core';
|
|
2
|
+
import { ResolvedI18nOptions } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Symbol used to attach the detected locale to the request object so that
|
|
5
|
+
* downstream handlers and the @Lang() decorator can read it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LOCALE_KEY = "__hazel_locale__";
|
|
8
|
+
/**
|
|
9
|
+
* Middleware that detects the request locale and stores it on the request
|
|
10
|
+
* object. Detection is attempted in the order specified by
|
|
11
|
+
* I18nOptions.detection (default: query → cookie → header).
|
|
12
|
+
*
|
|
13
|
+
* Usage — register on the HazelApp before route handling:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const localeMiddleware = new LocaleMiddleware(options);
|
|
17
|
+
* app.use((req, res, next) => localeMiddleware.handle(req, res, next));
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare class LocaleMiddleware {
|
|
21
|
+
private readonly options;
|
|
22
|
+
constructor(options: ResolvedI18nOptions);
|
|
23
|
+
handle(req: Request, res: Response, next: () => void): void;
|
|
24
|
+
/**
|
|
25
|
+
* Run through each configured strategy in priority order and return the
|
|
26
|
+
* first valid locale found, or the default locale as a fallback.
|
|
27
|
+
*/
|
|
28
|
+
private detect;
|
|
29
|
+
/**
|
|
30
|
+
* Parse the Cookie header and extract the value for the given name.
|
|
31
|
+
*/
|
|
32
|
+
private parseCookie;
|
|
33
|
+
/**
|
|
34
|
+
* Parse the Accept-Language header and return the highest-priority locale
|
|
35
|
+
* that looks like a valid BCP-47 tag.
|
|
36
|
+
*
|
|
37
|
+
* Example: "fr-FR,fr;q=0.9,en;q=0.8" → "fr-FR"
|
|
38
|
+
*/
|
|
39
|
+
private parseAcceptLanguage;
|
|
40
|
+
/**
|
|
41
|
+
* A locale is "valid" if it looks like a non-empty BCP-47 tag
|
|
42
|
+
* (letters, digits, hyphens, underscores).
|
|
43
|
+
*/
|
|
44
|
+
private isValid;
|
|
45
|
+
/**
|
|
46
|
+
* Factory method for use with HazelApp.use() or route-level middleware.
|
|
47
|
+
*/
|
|
48
|
+
static create(options: ResolvedI18nOptions): (req: Request, res: Response, next: () => void) => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads the locale that was attached to the request by LocaleMiddleware.
|
|
52
|
+
* Returns undefined if the middleware was not applied.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getLocaleFromRequest(req: Request): string | undefined;
|
|
55
|
+
//# sourceMappingURL=i18n.middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.middleware.d.ts","sourceRoot":"","sources":["../src/i18n.middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,UAAU,qBAAqB,CAAC;AAE7C;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,mBAAmB;IAEzD,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,IAAI,GAAG,IAAI;IAQ3D;;;OAGG;IACH,OAAO,CAAC,MAAM;IAwBd;;OAEG;IACH,OAAO,CAAC,WAAW;IAcnB;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAiB3B;;;OAGG;IACH,OAAO,CAAC,OAAO;IAIf;;OAEG;IACH,MAAM,CAAC,MAAM,CACX,OAAO,EAAE,mBAAmB,GAC3B,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI;CAI3D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAErE"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocaleMiddleware = exports.LOCALE_KEY = void 0;
|
|
4
|
+
exports.getLocaleFromRequest = getLocaleFromRequest;
|
|
5
|
+
/**
|
|
6
|
+
* Symbol used to attach the detected locale to the request object so that
|
|
7
|
+
* downstream handlers and the @Lang() decorator can read it.
|
|
8
|
+
*/
|
|
9
|
+
exports.LOCALE_KEY = '__hazel_locale__';
|
|
10
|
+
/**
|
|
11
|
+
* Middleware that detects the request locale and stores it on the request
|
|
12
|
+
* object. Detection is attempted in the order specified by
|
|
13
|
+
* I18nOptions.detection (default: query → cookie → header).
|
|
14
|
+
*
|
|
15
|
+
* Usage — register on the HazelApp before route handling:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* const localeMiddleware = new LocaleMiddleware(options);
|
|
19
|
+
* app.use((req, res, next) => localeMiddleware.handle(req, res, next));
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
class LocaleMiddleware {
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
handle(req, res, next) {
|
|
27
|
+
const locale = this.detect(req);
|
|
28
|
+
req[exports.LOCALE_KEY] = locale;
|
|
29
|
+
// Expose the detected locale as a response header so clients can confirm.
|
|
30
|
+
res.setHeader('Content-Language', locale);
|
|
31
|
+
next();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run through each configured strategy in priority order and return the
|
|
35
|
+
* first valid locale found, or the default locale as a fallback.
|
|
36
|
+
*/
|
|
37
|
+
detect(req) {
|
|
38
|
+
for (const strategy of this.options.detection) {
|
|
39
|
+
switch (strategy) {
|
|
40
|
+
case 'query': {
|
|
41
|
+
const lang = req.query?.[this.options.queryParam];
|
|
42
|
+
if (lang && this.isValid(lang))
|
|
43
|
+
return lang;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case 'cookie': {
|
|
47
|
+
const cookieLocale = this.parseCookie(req, this.options.cookieName);
|
|
48
|
+
if (cookieLocale && this.isValid(cookieLocale))
|
|
49
|
+
return cookieLocale;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case 'header': {
|
|
53
|
+
const headerLocale = this.parseAcceptLanguage(req);
|
|
54
|
+
if (headerLocale && this.isValid(headerLocale))
|
|
55
|
+
return headerLocale;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return this.options.defaultLocale;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse the Cookie header and extract the value for the given name.
|
|
64
|
+
*/
|
|
65
|
+
parseCookie(req, name) {
|
|
66
|
+
const cookieHeader = req.headers?.['cookie'] ?? req.headers?.['Cookie'];
|
|
67
|
+
if (!cookieHeader)
|
|
68
|
+
return undefined;
|
|
69
|
+
for (const pair of cookieHeader.split(';')) {
|
|
70
|
+
const [key, ...rest] = pair.trim().split('=');
|
|
71
|
+
if (key?.trim() === name) {
|
|
72
|
+
return decodeURIComponent(rest.join('=').trim());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse the Accept-Language header and return the highest-priority locale
|
|
79
|
+
* that looks like a valid BCP-47 tag.
|
|
80
|
+
*
|
|
81
|
+
* Example: "fr-FR,fr;q=0.9,en;q=0.8" → "fr-FR"
|
|
82
|
+
*/
|
|
83
|
+
parseAcceptLanguage(req) {
|
|
84
|
+
const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language'];
|
|
85
|
+
if (!header)
|
|
86
|
+
return undefined;
|
|
87
|
+
// Parse and sort by q-value, then return the first locale code.
|
|
88
|
+
const locales = header
|
|
89
|
+
.split(',')
|
|
90
|
+
.map((entry) => {
|
|
91
|
+
const [locale, q] = entry.trim().split(';q=');
|
|
92
|
+
return { locale: locale.trim(), q: q ? parseFloat(q) : 1.0 };
|
|
93
|
+
})
|
|
94
|
+
.sort((a, b) => b.q - a.q)
|
|
95
|
+
.map((entry) => entry.locale);
|
|
96
|
+
return locales[0];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A locale is "valid" if it looks like a non-empty BCP-47 tag
|
|
100
|
+
* (letters, digits, hyphens, underscores).
|
|
101
|
+
*/
|
|
102
|
+
isValid(locale) {
|
|
103
|
+
return /^[a-zA-Z]{2,8}([-_][a-zA-Z0-9]{2,8})*$/.test(locale);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Factory method for use with HazelApp.use() or route-level middleware.
|
|
107
|
+
*/
|
|
108
|
+
static create(options) {
|
|
109
|
+
const mw = new LocaleMiddleware(options);
|
|
110
|
+
return (req, res, next) => mw.handle(req, res, next);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
exports.LocaleMiddleware = LocaleMiddleware;
|
|
114
|
+
/**
|
|
115
|
+
* Reads the locale that was attached to the request by LocaleMiddleware.
|
|
116
|
+
* Returns undefined if the middleware was not applied.
|
|
117
|
+
*/
|
|
118
|
+
function getLocaleFromRequest(req) {
|
|
119
|
+
return req[exports.LOCALE_KEY];
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.middleware.test.d.ts","sourceRoot":"","sources":["../src/i18n.middleware.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
jest.mock('@hazeljs/core', () => ({
|
|
4
|
+
__esModule: true,
|
|
5
|
+
}));
|
|
6
|
+
const i18n_middleware_1 = require("./i18n.middleware");
|
|
7
|
+
const DEFAULT_OPTIONS = {
|
|
8
|
+
defaultLocale: 'en',
|
|
9
|
+
fallbackLocale: 'en',
|
|
10
|
+
translationsPath: './translations',
|
|
11
|
+
detection: ['query', 'cookie', 'header'],
|
|
12
|
+
queryParam: 'lang',
|
|
13
|
+
cookieName: 'locale',
|
|
14
|
+
isGlobal: true,
|
|
15
|
+
};
|
|
16
|
+
function makeReq(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
query: {},
|
|
19
|
+
headers: {},
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function makeRes() {
|
|
24
|
+
return { setHeader: jest.fn() };
|
|
25
|
+
}
|
|
26
|
+
describe('LocaleMiddleware', () => {
|
|
27
|
+
let middleware;
|
|
28
|
+
let next;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
middleware = new i18n_middleware_1.LocaleMiddleware(DEFAULT_OPTIONS);
|
|
31
|
+
next = jest.fn();
|
|
32
|
+
});
|
|
33
|
+
describe('handle()', () => {
|
|
34
|
+
it('calls next()', () => {
|
|
35
|
+
const req = makeReq();
|
|
36
|
+
const res = makeRes();
|
|
37
|
+
middleware.handle(req, res, next);
|
|
38
|
+
expect(next).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
it('sets the locale on the request object', () => {
|
|
41
|
+
const req = makeReq({ query: { lang: 'fr' } });
|
|
42
|
+
const res = makeRes();
|
|
43
|
+
middleware.handle(req, res, next);
|
|
44
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('fr');
|
|
45
|
+
});
|
|
46
|
+
it('sets Content-Language response header', () => {
|
|
47
|
+
const req = makeReq({ query: { lang: 'de' } });
|
|
48
|
+
const res = makeRes();
|
|
49
|
+
middleware.handle(req, res, next);
|
|
50
|
+
expect(res.setHeader).toHaveBeenCalledWith('Content-Language', 'de');
|
|
51
|
+
});
|
|
52
|
+
it('falls back to defaultLocale when no strategy matches', () => {
|
|
53
|
+
const req = makeReq();
|
|
54
|
+
const res = makeRes();
|
|
55
|
+
middleware.handle(req, res, next);
|
|
56
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('en');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('query strategy', () => {
|
|
60
|
+
it('picks locale from query parameter', () => {
|
|
61
|
+
const req = makeReq({ query: { lang: 'fr' } });
|
|
62
|
+
const res = makeRes();
|
|
63
|
+
middleware.handle(req, res, next);
|
|
64
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('fr');
|
|
65
|
+
});
|
|
66
|
+
it('ignores invalid locale in query', () => {
|
|
67
|
+
const req = makeReq({ query: { lang: '!invalid!' } });
|
|
68
|
+
const res = makeRes();
|
|
69
|
+
middleware.handle(req, res, next);
|
|
70
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('en');
|
|
71
|
+
});
|
|
72
|
+
it('uses custom queryParam name', () => {
|
|
73
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, queryParam: 'locale' });
|
|
74
|
+
const req = makeReq({ query: { locale: 'ja' } });
|
|
75
|
+
const res = makeRes();
|
|
76
|
+
mw.handle(req, res, next);
|
|
77
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('ja');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('cookie strategy', () => {
|
|
81
|
+
it('picks locale from cookie header', () => {
|
|
82
|
+
const req = makeReq({ headers: { cookie: 'locale=fr; theme=dark' } });
|
|
83
|
+
const res = makeRes();
|
|
84
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['cookie'] });
|
|
85
|
+
mw.handle(req, res, next);
|
|
86
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('fr');
|
|
87
|
+
});
|
|
88
|
+
it('picks locale from Cookie (capitalized) header', () => {
|
|
89
|
+
const req = makeReq({ headers: { Cookie: 'locale=de' } });
|
|
90
|
+
const res = makeRes();
|
|
91
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['cookie'] });
|
|
92
|
+
mw.handle(req, res, next);
|
|
93
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('de');
|
|
94
|
+
});
|
|
95
|
+
it('falls back to default when cookie is absent', () => {
|
|
96
|
+
const req = makeReq({ headers: {} });
|
|
97
|
+
const res = makeRes();
|
|
98
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['cookie'] });
|
|
99
|
+
mw.handle(req, res, next);
|
|
100
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('en');
|
|
101
|
+
});
|
|
102
|
+
it('falls back when cookie name does not match', () => {
|
|
103
|
+
const req = makeReq({ headers: { cookie: 'othercookie=fr' } });
|
|
104
|
+
const res = makeRes();
|
|
105
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['cookie'] });
|
|
106
|
+
mw.handle(req, res, next);
|
|
107
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('en');
|
|
108
|
+
});
|
|
109
|
+
it('handles cookie values with = in the value', () => {
|
|
110
|
+
const req = makeReq({ headers: { cookie: 'locale=zh-TW; token=abc=def' } });
|
|
111
|
+
const res = makeRes();
|
|
112
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['cookie'] });
|
|
113
|
+
mw.handle(req, res, next);
|
|
114
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('zh-TW');
|
|
115
|
+
});
|
|
116
|
+
it('handles URL-encoded cookie values', () => {
|
|
117
|
+
const req = makeReq({ headers: { cookie: 'locale=zh-TW' } });
|
|
118
|
+
const res = makeRes();
|
|
119
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['cookie'] });
|
|
120
|
+
mw.handle(req, res, next);
|
|
121
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('zh-TW');
|
|
122
|
+
});
|
|
123
|
+
it('uses custom cookieName', () => {
|
|
124
|
+
const req = makeReq({ headers: { cookie: 'lang_pref=ja' } });
|
|
125
|
+
const res = makeRes();
|
|
126
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({
|
|
127
|
+
...DEFAULT_OPTIONS,
|
|
128
|
+
cookieName: 'lang_pref',
|
|
129
|
+
detection: ['cookie'],
|
|
130
|
+
});
|
|
131
|
+
mw.handle(req, res, next);
|
|
132
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('ja');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('header strategy', () => {
|
|
136
|
+
it('picks locale from Accept-Language header', () => {
|
|
137
|
+
const req = makeReq({ headers: { 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8' } });
|
|
138
|
+
const res = makeRes();
|
|
139
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['header'] });
|
|
140
|
+
mw.handle(req, res, next);
|
|
141
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('fr-FR');
|
|
142
|
+
});
|
|
143
|
+
it('picks locale from Accept-Language (capitalized) header', () => {
|
|
144
|
+
const req = makeReq({ headers: { 'Accept-Language': 'de' } });
|
|
145
|
+
const res = makeRes();
|
|
146
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['header'] });
|
|
147
|
+
mw.handle(req, res, next);
|
|
148
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('de');
|
|
149
|
+
});
|
|
150
|
+
it('sorts by q-value and picks highest priority locale', () => {
|
|
151
|
+
const req = makeReq({
|
|
152
|
+
headers: { 'accept-language': 'en;q=0.5,ja;q=0.9,fr;q=0.7' },
|
|
153
|
+
});
|
|
154
|
+
const res = makeRes();
|
|
155
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['header'] });
|
|
156
|
+
mw.handle(req, res, next);
|
|
157
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('ja');
|
|
158
|
+
});
|
|
159
|
+
it('falls back to default when no Accept-Language header', () => {
|
|
160
|
+
const req = makeReq({ headers: {} });
|
|
161
|
+
const res = makeRes();
|
|
162
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({ ...DEFAULT_OPTIONS, detection: ['header'] });
|
|
163
|
+
mw.handle(req, res, next);
|
|
164
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('en');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('detection priority', () => {
|
|
168
|
+
it('prefers query over cookie and header', () => {
|
|
169
|
+
const req = makeReq({
|
|
170
|
+
query: { lang: 'fr' },
|
|
171
|
+
headers: {
|
|
172
|
+
cookie: 'locale=de',
|
|
173
|
+
'accept-language': 'ja',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const res = makeRes();
|
|
177
|
+
middleware.handle(req, res, next);
|
|
178
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('fr');
|
|
179
|
+
});
|
|
180
|
+
it('falls through to cookie when query has no valid locale', () => {
|
|
181
|
+
const req = makeReq({
|
|
182
|
+
query: {},
|
|
183
|
+
headers: {
|
|
184
|
+
cookie: 'locale=de',
|
|
185
|
+
'accept-language': 'ja',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const res = makeRes();
|
|
189
|
+
middleware.handle(req, res, next);
|
|
190
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('de');
|
|
191
|
+
});
|
|
192
|
+
it('falls through to header when query and cookie have no valid locale', () => {
|
|
193
|
+
const req = makeReq({
|
|
194
|
+
query: {},
|
|
195
|
+
headers: {
|
|
196
|
+
'accept-language': 'ja',
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
const res = makeRes();
|
|
200
|
+
middleware.handle(req, res, next);
|
|
201
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('ja');
|
|
202
|
+
});
|
|
203
|
+
it('respects custom detection order [header, query]', () => {
|
|
204
|
+
const mw = new i18n_middleware_1.LocaleMiddleware({
|
|
205
|
+
...DEFAULT_OPTIONS,
|
|
206
|
+
detection: ['header', 'query'],
|
|
207
|
+
});
|
|
208
|
+
const req = makeReq({
|
|
209
|
+
query: { lang: 'fr' },
|
|
210
|
+
headers: { 'accept-language': 'de' },
|
|
211
|
+
});
|
|
212
|
+
const res = makeRes();
|
|
213
|
+
mw.handle(req, res, next);
|
|
214
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('de');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
describe('LocaleMiddleware.create()', () => {
|
|
218
|
+
it('returns a middleware function', () => {
|
|
219
|
+
const fn = i18n_middleware_1.LocaleMiddleware.create(DEFAULT_OPTIONS);
|
|
220
|
+
expect(typeof fn).toBe('function');
|
|
221
|
+
});
|
|
222
|
+
it('created function applies locale detection', () => {
|
|
223
|
+
const fn = i18n_middleware_1.LocaleMiddleware.create(DEFAULT_OPTIONS);
|
|
224
|
+
const req = makeReq({ query: { lang: 'fr' } });
|
|
225
|
+
const res = makeRes();
|
|
226
|
+
fn(req, res, next);
|
|
227
|
+
expect(req[i18n_middleware_1.LOCALE_KEY]).toBe('fr');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('getLocaleFromRequest()', () => {
|
|
232
|
+
it('returns the locale set on the request', () => {
|
|
233
|
+
const req = { [i18n_middleware_1.LOCALE_KEY]: 'fr' };
|
|
234
|
+
expect((0, i18n_middleware_1.getLocaleFromRequest)(req)).toBe('fr');
|
|
235
|
+
});
|
|
236
|
+
it('returns undefined when locale is not set', () => {
|
|
237
|
+
expect((0, i18n_middleware_1.getLocaleFromRequest)({})).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
});
|