@tramvai/module-i18n 6.79.10

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.
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var core = require('@tramvai/core');
6
+ var tokensCommon = require('@tramvai/tokens-common');
7
+ var tokensCookie = require('@tramvai/tokens-cookie');
8
+ var tokensRouter = require('@tramvai/tokens-router');
9
+ var moduleClientHints = require('@tramvai/module-client-hints');
10
+ var tokensRender = require('@tramvai/tokens-render');
11
+ var tokens = require('../tokens.js');
12
+ var store = require('./store.js');
13
+ var service = require('./service.js');
14
+ var config = require('./config.js');
15
+ var constants = require('./constants.js');
16
+
17
+ const sharedProviders = [
18
+ core.provide({
19
+ provide: tokens.I18N_CONFIGURATION_TOKEN,
20
+ useValue: config.defaultConfiguration,
21
+ }),
22
+ core.provide({
23
+ provide: tokensCommon.COMBINE_REDUCERS,
24
+ useValue: [store.I18nStore],
25
+ }),
26
+ core.provide({
27
+ provide: tokens.I18N_RESOLVE_AVAILABLE_LANGUAGES_TOKEN,
28
+ useFactory: ({ config }) => {
29
+ // TODO: sync with state from server if SSR?
30
+ return async () => config.availableLanguages ?? [config.defaultLanguage];
31
+ },
32
+ deps: {
33
+ config: tokens.I18N_CONFIGURATION_TOKEN,
34
+ },
35
+ }),
36
+ core.provide({
37
+ provide: tokens.I18N_TOKEN,
38
+ useClass: service.I18nServiceImpl,
39
+ deps: {
40
+ store: tokensCommon.STORE_TOKEN,
41
+ config: tokens.I18N_CONFIGURATION_TOKEN,
42
+ cookieManager: tokensCookie.COOKIE_MANAGER_TOKEN,
43
+ router: tokensRouter.ROUTER_TOKEN,
44
+ requestManager: tokensCommon.REQUEST_MANAGER_TOKEN,
45
+ logger: tokensCommon.LOGGER_TOKEN,
46
+ },
47
+ }),
48
+ // Language from cookie has higher priority
49
+ core.provide({
50
+ provide: tokens.I18N_RESOLVE_LANGUAGE_SOURCE_TOKEN,
51
+ useFactory: ({ cookieManager, config }) => {
52
+ return {
53
+ name: 'cookie',
54
+ priority: constants.LanguageSourcePriority.Cookie,
55
+ resolve: async () => {
56
+ const valueFromCookie = cookieManager.get(config.cookieName);
57
+ if (typeof valueFromCookie === 'string') {
58
+ return valueFromCookie;
59
+ }
60
+ },
61
+ };
62
+ },
63
+ deps: {
64
+ cookieManager: tokensCookie.COOKIE_MANAGER_TOKEN,
65
+ config: tokens.I18N_CONFIGURATION_TOKEN,
66
+ },
67
+ }),
68
+ // Then, check user preferences (Accept-Language or navigator.languages)
69
+ core.provide({
70
+ provide: tokens.I18N_RESOLVE_LANGUAGE_SOURCE_TOKEN,
71
+ useFactory: ({ i18n, userLanguage }) => {
72
+ return {
73
+ name: 'accept-language',
74
+ priority: constants.LanguageSourcePriority.AcceptLanguage,
75
+ resolve: async () => {
76
+ const availableLanguages = i18n.getAvailableLanguages();
77
+ for (let i = 0; i < userLanguage.length; i++) {
78
+ const lang = userLanguage[i];
79
+ // Try exact match first, because userLanguage already sorted by priority
80
+ if (availableLanguages.includes(lang)) {
81
+ return lang;
82
+ }
83
+ }
84
+ },
85
+ };
86
+ },
87
+ deps: {
88
+ i18n: tokens.I18N_TOKEN,
89
+ userLanguage: moduleClientHints.USER_LANGUAGE_TOKEN,
90
+ },
91
+ }),
92
+ // Then, check language from URL (if prefix strategy is used, defined under the hood)
93
+ core.provide({
94
+ provide: tokens.I18N_RESOLVE_LANGUAGE_SOURCE_TOKEN,
95
+ useFactory: ({ router, requestManager, i18n }) => {
96
+ return {
97
+ name: 'url',
98
+ priority: constants.LanguageSourcePriority.Url,
99
+ resolve: async () => {
100
+ const currentUrl = router.getCurrentUrl() ?? requestManager.getParsedUrl();
101
+ const valueFromUrl = i18n.getLanguageFromUrl(currentUrl);
102
+ const availableLanguages = i18n.getAvailableLanguages();
103
+ if (typeof valueFromUrl === 'string' && availableLanguages.includes(valueFromUrl)) {
104
+ return valueFromUrl;
105
+ }
106
+ },
107
+ };
108
+ },
109
+ deps: {
110
+ router: tokensRouter.ROUTER_TOKEN,
111
+ requestManager: tokensCommon.REQUEST_MANAGER_TOKEN,
112
+ i18n: tokens.I18N_TOKEN,
113
+ },
114
+ }),
115
+ // As a last resort, return default language
116
+ core.provide({
117
+ provide: tokens.I18N_RESOLVE_LANGUAGE_SOURCE_TOKEN,
118
+ useFactory: ({ config }) => {
119
+ return {
120
+ name: 'default',
121
+ priority: constants.LanguageSourcePriority.Default,
122
+ resolve: async () => {
123
+ return config.defaultLanguage;
124
+ },
125
+ };
126
+ },
127
+ deps: {
128
+ config: tokens.I18N_CONFIGURATION_TOKEN,
129
+ },
130
+ }),
131
+ core.provide({
132
+ provide: tokens.I18N_RESOLVE_LANGUAGE_TOKEN,
133
+ useFactory: ({ store: store$1, envManager, resolveSources }) => {
134
+ return async () => {
135
+ // At client-side, for SSR mode, we can resolve language from server state
136
+ if (typeof window !== 'undefined' &&
137
+ envManager.get('TRAMVAI_FORCE_CLIENT_SIDE_RENDERING') !== 'true') {
138
+ const { language, source } = store$1.getState(store.I18nStore);
139
+ return { language, source };
140
+ }
141
+ const sources = [...resolveSources].sort(({ priority: a }, { priority: b }) => b - a);
142
+ for (const source of sources) {
143
+ const language = await source.resolve();
144
+ if (typeof language === 'string') {
145
+ return {
146
+ language: language,
147
+ source: source.name,
148
+ };
149
+ }
150
+ }
151
+ // Impossible case, because we always have default source
152
+ throw Error('No language could be resolved from any sources');
153
+ };
154
+ },
155
+ deps: {
156
+ store: tokensCommon.STORE_TOKEN,
157
+ envManager: tokensCommon.ENV_MANAGER_TOKEN,
158
+ resolveSources: tokens.I18N_RESOLVE_LANGUAGE_SOURCE_TOKEN,
159
+ },
160
+ }),
161
+ core.provide({
162
+ provide: core.commandLineListTokens.init,
163
+ useFactory: ({ di, config, logger }) => {
164
+ const log = logger('i18n');
165
+ return function registerI18nResolveLanguagesCommands() {
166
+ di.register({
167
+ provide: config.resolveLanguagesCommandLineStage
168
+ ? core.commandLineListTokens[config.resolveLanguagesCommandLineStage]
169
+ : core.commandLineListTokens.customerStart,
170
+ useFactory: ({ store: store$1, resolveAvailableLanguages, resolveLanguage, cookieManager, router, requestManager, i18n, }) => {
171
+ return async function resolveLanguagesFn() {
172
+ // Resolve available languages first, so we can use them to validate current language
173
+ const availableLanguages = await resolveAvailableLanguages();
174
+ log.info(`Resolved available languages: ${availableLanguages}`);
175
+ store$1.dispatch(store.setAvailableLanguages(availableLanguages));
176
+ const language = await resolveLanguage();
177
+ log.info(`Resolved language: "${language.language}" from "${language.source}" source`);
178
+ store$1.dispatch(store.setLanguage(language));
179
+ if (!cookieManager.get(config.cookieName)) {
180
+ log.info(`${config.cookieName} cookie is not found, save resolved "${language.language}" language to cookie`);
181
+ cookieManager.set({
182
+ name: config.cookieName,
183
+ value: language.language,
184
+ // TODO valid params!
185
+ });
186
+ }
187
+ const currentUrl = router.getCurrentUrl() ?? requestManager.getParsedUrl();
188
+ const languageFromUrl = i18n.getLanguageFromUrl(currentUrl);
189
+ if (typeof languageFromUrl === 'string' && languageFromUrl !== language.language) {
190
+ log.info(`Language "${languageFromUrl}" from current url "${currentUrl}" is not matched to resolved language "${language.language}"`);
191
+ if (config.enableAutoRedirect) {
192
+ log.info(`Sync resolved language with url because 'config.enableAutoRedirect' is enabled`);
193
+ await i18n.switchLanguage(language.language);
194
+ }
195
+ }
196
+ };
197
+ },
198
+ deps: {
199
+ store: tokensCommon.STORE_TOKEN,
200
+ resolveAvailableLanguages: tokens.I18N_RESOLVE_AVAILABLE_LANGUAGES_TOKEN,
201
+ resolveLanguage: tokens.I18N_RESOLVE_LANGUAGE_TOKEN,
202
+ cookieManager: tokensCookie.COOKIE_MANAGER_TOKEN,
203
+ i18n: tokens.I18N_TOKEN,
204
+ router: tokensRouter.ROUTER_TOKEN,
205
+ requestManager: tokensCommon.REQUEST_MANAGER_TOKEN,
206
+ },
207
+ });
208
+ };
209
+ },
210
+ deps: {
211
+ di: core.DI_TOKEN,
212
+ config: tokens.I18N_CONFIGURATION_TOKEN,
213
+ logger: tokensCommon.LOGGER_TOKEN,
214
+ },
215
+ }),
216
+ // Router guard to sync language prefix in URL with current language
217
+ core.provide({
218
+ provide: tokensRouter.ROUTER_GUARD_TOKEN,
219
+ useFactory: ({ logger, i18n, config, cookieManager }) => {
220
+ const log = logger('i18n');
221
+ return async ({ url, to }) => {
222
+ if (typeof window !== 'undefined' && url && to) {
223
+ // i18n store will be updated after navigation, so we need to use cookie to get current language
224
+ const language = cookieManager.get(config.cookieName);
225
+ const languageFromUrl = i18n.getLanguageFromUrl(url.href);
226
+ if (typeof languageFromUrl === 'string' && languageFromUrl !== language) {
227
+ log.info(`Language "${languageFromUrl}" from target url "${url.pathname}" is not matched to current language "${language}"`);
228
+ if (config.enableAutoRedirect) {
229
+ log.info(`Force navigate to url with correct language prefix because 'config.enableAutoRedirect' is enabled`);
230
+ const hasLanguagePrefix = typeof languageFromUrl === 'string';
231
+ const targetUrl = hasLanguagePrefix
232
+ ? i18n.addLanguageToUrl(i18n.removeLanguageFromUrl(url), language)
233
+ : i18n.addLanguageToUrl(url, language);
234
+ return `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`;
235
+ }
236
+ }
237
+ }
238
+ };
239
+ },
240
+ deps: {
241
+ logger: tokensCommon.LOGGER_TOKEN,
242
+ i18n: tokens.I18N_TOKEN,
243
+ config: tokens.I18N_CONFIGURATION_TOKEN,
244
+ cookieManager: tokensCookie.COOKIE_MANAGER_TOKEN,
245
+ },
246
+ }),
247
+ core.provide({
248
+ provide: tokensRouter.ROUTER_PLUGIN,
249
+ useFactory: ({ config, di, store: store$1 }) => {
250
+ return {
251
+ apply(router) {
252
+ if (config.updateStrategy === 'update') {
253
+ router.syncHooks.get('change').tap('i18n', (_, { navigation }) => {
254
+ // Resolve from DI to avoid circular dependency
255
+ const i18n = di.get(tokens.I18N_TOKEN);
256
+ if (navigation.to?.params?.__lang) {
257
+ const currentLang = i18n.getLanguage();
258
+ const nextLang = navigation.to.params.__lang;
259
+ if (currentLang !== nextLang) {
260
+ store$1.dispatch(store.setLanguage({
261
+ language: navigation.to.params.__lang,
262
+ source: 'user-interaction',
263
+ }));
264
+ }
265
+ }
266
+ });
267
+ }
268
+ router.internalHooks['router:resolve-route'].wrap((_, args, next) => {
269
+ // Resolve from DI to avoid circular dependency
270
+ const i18n = di.get(tokens.I18N_TOKEN);
271
+ const [navigation, options] = args;
272
+ if (navigation.url) {
273
+ const languageFromUrl = i18n.getLanguageFromUrl(navigation.url);
274
+ const hasLanguagePrefix = typeof languageFromUrl === 'string';
275
+ if (hasLanguagePrefix) {
276
+ // If language is in URL, we need to remove it to resolve the same route for all languages
277
+ const urlWithoutPrefix = i18n.removeLanguageFromUrl(navigation.url);
278
+ const route = next([{ ...navigation, url: urlWithoutPrefix }, options]);
279
+ if (route) {
280
+ // If route is resolved, we need to save actual url and information about language for logs and debugging
281
+ route.actualPath = navigation.url.pathname;
282
+ route.params.__lang = languageFromUrl;
283
+ }
284
+ return route;
285
+ }
286
+ if (config.routingStrategy === 'prefix') {
287
+ // If routing strategy is prefix, we need to send 404 for all urls without language prefix
288
+ return;
289
+ }
290
+ }
291
+ return next(args);
292
+ });
293
+ },
294
+ };
295
+ },
296
+ deps: {
297
+ config: tokens.I18N_CONFIGURATION_TOKEN,
298
+ di: core.DI_TOKEN,
299
+ store: tokensCommon.STORE_TOKEN,
300
+ },
301
+ }),
302
+ core.provide({
303
+ provide: tokensRender.HTML_ATTRS,
304
+ useFactory: ({ i18n }) => {
305
+ return {
306
+ target: 'html',
307
+ attrs: {
308
+ lang: i18n.getLanguage(),
309
+ },
310
+ };
311
+ },
312
+ deps: {
313
+ i18n: tokens.I18N_TOKEN,
314
+ },
315
+ }),
316
+ core.provide({
317
+ provide: tokensRender.RENDER_SLOTS,
318
+ multi: true,
319
+ useFactory: ({ i18n, requestManager }) => {
320
+ const parsedUrl = requestManager.getParsedUrl();
321
+ const url = `${parsedUrl.origin}${parsedUrl.pathname}`;
322
+ // TODO: dynamic list of available languages for specific route to generate alternate links
323
+ // TODO: `x-default` link?
324
+ return [
325
+ {
326
+ type: tokensRender.ResourceType.asIs,
327
+ slot: tokensRender.ResourceSlot.HEAD_META,
328
+ payload: `<link rel="alternate" hreflang="${i18n.getLanguage()}" href="${url}" />`,
329
+ },
330
+ ];
331
+ },
332
+ deps: {
333
+ i18n: tokens.I18N_TOKEN,
334
+ requestManager: tokensCommon.REQUEST_MANAGER_TOKEN,
335
+ },
336
+ }),
337
+ ];
338
+
339
+ exports.sharedProviders = sharedProviders;
@@ -0,0 +1,164 @@
1
+ import { parse } from '@tinkoff/url';
2
+ import { RedirectFoundError, makeErrorSilent } from '@tinkoff/errors';
3
+ import { I18nStore } from './store.browser.js';
4
+
5
+ class I18nServiceImpl {
6
+ store;
7
+ config;
8
+ cookieManager;
9
+ router;
10
+ requestManager;
11
+ log;
12
+ constructor(deps) {
13
+ this.store = deps.store;
14
+ this.config = deps.config;
15
+ this.cookieManager = deps.cookieManager;
16
+ this.router = deps.router;
17
+ this.requestManager = deps.requestManager;
18
+ this.log = deps.logger('i18n');
19
+ }
20
+ getLanguage() {
21
+ const state = this.store.getState(I18nStore);
22
+ return state.language;
23
+ }
24
+ getAvailableLanguages() {
25
+ const state = this.store.getState(I18nStore);
26
+ return state.availableLanguages;
27
+ }
28
+ // eslint-disable-next-line max-statements
29
+ async switchLanguage(language) {
30
+ const availableLanguages = this.getAvailableLanguages();
31
+ if (!availableLanguages.includes(language)) {
32
+ // TODO: is throwing an error is a best solution?
33
+ throw new Error(`Language "${language}" is not available. Available languages: ${availableLanguages.join(', ')}`);
34
+ }
35
+ this.log.info(`Switch language to "${language}"`);
36
+ const { cookieName, routingStrategy, updateStrategy } = this.config;
37
+ this.cookieManager.set({
38
+ name: cookieName,
39
+ value: language,
40
+ // TODO valid params!
41
+ });
42
+ const currentUrl = this.router.getCurrentUrl() ?? this.requestManager.getUrl();
43
+ const parsedUrl = typeof currentUrl === 'string' ? parse(currentUrl) : currentUrl;
44
+ const currentLanguage = this.getLanguage();
45
+ const languageFromUrl = this.getLanguageFromUrl(parsedUrl);
46
+ const hasLanguagePrefix = typeof languageFromUrl === 'string';
47
+ const targetUrl = hasLanguagePrefix
48
+ ? this.addLanguageToUrl(this.removeLanguageFromUrl(parsedUrl), language)
49
+ : this.addLanguageToUrl(parsedUrl, language);
50
+ const redirectError = new RedirectFoundError({
51
+ nextUrl: `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`,
52
+ httpStatus: 301,
53
+ });
54
+ makeErrorSilent(redirectError);
55
+ /**
56
+ * 'none' - no action needed, language is already set and url is correct
57
+ * 'reload' - url is correct, reload the page to apply language change
58
+ * 'navigate' - need to update url, navigate to the new URL with/without language prefix
59
+ */
60
+ let action = 'none';
61
+ if (hasLanguagePrefix && languageFromUrl !== language) {
62
+ action = 'navigate';
63
+ }
64
+ else if (currentLanguage !== language) {
65
+ if (routingStrategy === 'no_prefix') {
66
+ action = 'reload';
67
+ }
68
+ else {
69
+ action = 'navigate';
70
+ }
71
+ }
72
+ switch (action) {
73
+ case 'none': {
74
+ return;
75
+ }
76
+ case 'reload': {
77
+ if (updateStrategy === 'reload') {
78
+ if (typeof window !== 'undefined') {
79
+ window.location.reload();
80
+ return new Promise(() => { });
81
+ }
82
+ throw redirectError;
83
+ }
84
+ else {
85
+ if (typeof window !== 'undefined') {
86
+ return this.router.navigate({
87
+ url: targetUrl.href,
88
+ replace: true,
89
+ });
90
+ }
91
+ throw redirectError;
92
+ }
93
+ }
94
+ case 'navigate': {
95
+ if (updateStrategy === 'reload') {
96
+ if (typeof window !== 'undefined') {
97
+ window.location.href = targetUrl.href;
98
+ return new Promise(() => { });
99
+ }
100
+ throw redirectError;
101
+ }
102
+ else {
103
+ if (typeof window !== 'undefined') {
104
+ return this.router.navigate({
105
+ url: targetUrl.href,
106
+ });
107
+ }
108
+ throw redirectError;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ getLanguageFromUrl(url) {
114
+ const parsedUrl = typeof url === 'string' ? parse(url) : url;
115
+ const { routingStrategy } = this.config;
116
+ const availableLanguages = this.getAvailableLanguages();
117
+ const [firstSegment] = parsedUrl.pathname.split('/').filter(Boolean);
118
+ const isLanguagePrefix = availableLanguages.includes(firstSegment);
119
+ switch (routingStrategy) {
120
+ case 'no_prefix':
121
+ // No prefix strategy, language is not in URL
122
+ return null;
123
+ case 'prefix':
124
+ // Prefix strategy, first segment should be always a language
125
+ return isLanguagePrefix ? firstSegment : null;
126
+ case 'prefix_and_default':
127
+ // Prefix and default strategy, first segment can be a language, or default language if it not matches
128
+ return isLanguagePrefix ? firstSegment : this.config.defaultLanguage;
129
+ case 'prefix_except_default':
130
+ // Prefix except default strategy, first segment can be a language, but can't match default language
131
+ if (isLanguagePrefix) {
132
+ return firstSegment === this.config.defaultLanguage ? null : firstSegment;
133
+ }
134
+ return this.config.defaultLanguage;
135
+ }
136
+ }
137
+ removeLanguageFromUrl(url) {
138
+ const parsedUrl = typeof url === 'string' ? parse(url) : url;
139
+ const languageFromUrl = this.getLanguageFromUrl(parsedUrl);
140
+ const hasLanguagePrefix = typeof languageFromUrl === 'string';
141
+ if (!hasLanguagePrefix) {
142
+ return parsedUrl;
143
+ }
144
+ return parse(`${parsedUrl.origin}${parsedUrl.pathname.replace(`/${languageFromUrl}`, '')}${parsedUrl.search}${parsedUrl.hash}`);
145
+ }
146
+ addLanguageToUrl(url, lang) {
147
+ const { routingStrategy } = this.config;
148
+ const parsedUrl = typeof url === 'string' ? parse(url) : url;
149
+ const languageFromUrl = this.getLanguageFromUrl(parsedUrl);
150
+ const hasLanguagePrefix = typeof languageFromUrl === 'string';
151
+ // If the URL already has a language prefix that matches the requested language, return it as is
152
+ // or if routing strategy is 'no_prefix', return it as is
153
+ // or 'prefix_except_default' or 'prefix_and_default' and the language is default, return it as is
154
+ if ((hasLanguagePrefix && languageFromUrl === lang) ||
155
+ routingStrategy === 'no_prefix' ||
156
+ (routingStrategy === 'prefix_except_default' && lang === this.config.defaultLanguage) ||
157
+ (routingStrategy === 'prefix_and_default' && lang === this.config.defaultLanguage)) {
158
+ return parsedUrl;
159
+ }
160
+ return parse(`${parsedUrl.origin}/${lang}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`);
161
+ }
162
+ }
163
+
164
+ export { I18nServiceImpl };
@@ -0,0 +1,32 @@
1
+ import type { COOKIE_MANAGER_TOKEN } from '@tramvai/tokens-cookie';
2
+ import { ExtractDependencyType } from '@tinkoff/dippy';
3
+ import { LOGGER_TOKEN, REQUEST_MANAGER_TOKEN, STORE_TOKEN } from '@tramvai/tokens-common';
4
+ import { ROUTER_TOKEN } from '@tramvai/tokens-router';
5
+ import { Url } from '@tinkoff/url';
6
+ import type { I18nService, Language } from '../types';
7
+ import { I18N_CONFIGURATION_TOKEN } from '../tokens';
8
+ type Deps = {
9
+ store: ExtractDependencyType<typeof STORE_TOKEN>;
10
+ config: ExtractDependencyType<typeof I18N_CONFIGURATION_TOKEN>;
11
+ cookieManager: ExtractDependencyType<typeof COOKIE_MANAGER_TOKEN>;
12
+ router: ExtractDependencyType<typeof ROUTER_TOKEN>;
13
+ requestManager: ExtractDependencyType<typeof REQUEST_MANAGER_TOKEN>;
14
+ logger: ExtractDependencyType<typeof LOGGER_TOKEN>;
15
+ };
16
+ export declare class I18nServiceImpl implements I18nService {
17
+ private store;
18
+ private config;
19
+ private cookieManager;
20
+ private router;
21
+ private requestManager;
22
+ private log;
23
+ constructor(deps: Deps);
24
+ getLanguage(): Language;
25
+ getAvailableLanguages(): Language[];
26
+ switchLanguage(language: Language): Promise<void>;
27
+ getLanguageFromUrl(url: string | Url): Language | null;
28
+ removeLanguageFromUrl(url: string | Url): Url;
29
+ addLanguageToUrl(url: string | Url, lang: Language): Url;
30
+ }
31
+ export {};
32
+ //# sourceMappingURL=service.d.ts.map