@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.2 → 3.2.0-ultramodern.23
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/dist/cjs/cli/index.js +22 -0
- package/dist/cjs/runtime/I18nLink.js +4 -12
- package/dist/cjs/runtime/context.js +32 -5
- package/dist/cjs/runtime/hooks.js +8 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +1 -1
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +4 -4
- package/dist/cjs/runtime/i18n/instance.js +4 -2
- package/dist/cjs/runtime/index.js +7 -6
- package/dist/cjs/runtime/routerAdapter.js +163 -0
- package/dist/cjs/runtime/utils.js +63 -94
- package/dist/cjs/server/index.js +64 -8
- package/dist/cjs/shared/localisedUrls.js +237 -0
- package/dist/esm/cli/index.mjs +22 -0
- package/dist/esm/runtime/I18nLink.mjs +4 -12
- package/dist/esm/runtime/context.mjs +34 -7
- package/dist/esm/runtime/hooks.mjs +9 -6
- package/dist/esm/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm/runtime/i18n/instance.mjs +4 -2
- package/dist/esm/runtime/index.mjs +7 -6
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +11 -30
- package/dist/esm/server/index.mjs +57 -7
- package/dist/esm/shared/localisedUrls.mjs +191 -0
- package/dist/esm-node/cli/index.mjs +22 -0
- package/dist/esm-node/runtime/I18nLink.mjs +4 -12
- package/dist/esm-node/runtime/context.mjs +34 -7
- package/dist/esm-node/runtime/hooks.mjs +9 -6
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +1 -1
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm-node/runtime/i18n/instance.mjs +4 -2
- package/dist/esm-node/runtime/index.mjs +7 -6
- package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
- package/dist/esm-node/runtime/utils.mjs +11 -30
- package/dist/esm-node/server/index.mjs +57 -7
- package/dist/esm-node/shared/localisedUrls.mjs +192 -0
- package/dist/types/cli/index.d.ts +21 -0
- package/dist/types/runtime/I18nLink.d.ts +23 -0
- package/dist/types/runtime/context.d.ts +41 -0
- package/dist/types/runtime/hooks.d.ts +30 -0
- package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
- package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
- package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
- package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
- package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
- package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +53 -0
- package/dist/types/runtime/i18n/backend/sdk-event.d.ts +9 -0
- package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
- package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
- package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
- package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
- package/dist/types/runtime/i18n/index.d.ts +3 -0
- package/dist/types/runtime/i18n/instance.d.ts +96 -0
- package/dist/types/runtime/i18n/utils.d.ts +29 -0
- package/dist/types/runtime/index.d.ts +21 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/types.d.ts +15 -0
- package/dist/types/runtime/utils.d.ts +28 -0
- package/dist/types/server/index.d.ts +14 -0
- package/dist/types/shared/deepMerge.d.ts +1 -0
- package/dist/types/shared/detection.d.ts +11 -0
- package/dist/types/shared/localisedUrls.d.ts +13 -0
- package/dist/types/shared/type.d.ts +168 -0
- package/dist/types/shared/utils.d.ts +5 -0
- package/package.json +15 -15
- package/rstest.config.mts +39 -0
- package/src/cli/index.ts +43 -1
- package/src/runtime/I18nLink.tsx +10 -16
- package/src/runtime/context.tsx +45 -7
- package/src/runtime/hooks.ts +13 -4
- package/src/runtime/i18n/backend/defaults.ts +3 -1
- package/src/runtime/i18n/backend/middleware.node.ts +1 -1
- package/src/runtime/i18n/instance.ts +14 -5
- package/src/runtime/index.tsx +10 -2
- package/src/runtime/routerAdapter.tsx +333 -0
- package/src/runtime/utils.ts +22 -34
- package/src/server/index.ts +135 -10
- package/src/shared/localisedUrls.ts +393 -0
- package/src/shared/type.ts +12 -0
- package/tests/localisedUrls.test.ts +278 -0
- package/tests/routerAdapter.test.tsx +278 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- package/dist/esm-node/rslib-runtime.mjs +0 -19
package/src/runtime/utils.ts
CHANGED
|
@@ -3,6 +3,11 @@ import {
|
|
|
3
3
|
getGlobalBasename,
|
|
4
4
|
type TInternalRuntimeContext,
|
|
5
5
|
} from '@modern-js/runtime/context';
|
|
6
|
+
import type { LocalisedUrlsMap } from '../shared/localisedUrls';
|
|
7
|
+
import {
|
|
8
|
+
resolveLocalisedPath,
|
|
9
|
+
resolveLocalisedUrlsConfig,
|
|
10
|
+
} from '../shared/localisedUrls';
|
|
6
11
|
|
|
7
12
|
export const getPathname = (context: TInternalRuntimeContext): string => {
|
|
8
13
|
if (isBrowser()) {
|
|
@@ -51,18 +56,25 @@ export const buildLocalizedUrl = (
|
|
|
51
56
|
pathname: string,
|
|
52
57
|
language: string,
|
|
53
58
|
languages: string[],
|
|
59
|
+
localisedUrls?: boolean | LocalisedUrlsMap,
|
|
54
60
|
): string => {
|
|
55
61
|
const segments = pathname.split('/').filter(Boolean);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
63
|
+
const pathWithoutLanguage =
|
|
64
|
+
segments.length > 0 && languages.includes(segments[0])
|
|
65
|
+
? `/${segments.slice(1).join('/')}`
|
|
66
|
+
: pathname;
|
|
67
|
+
const resolvedPath = localisedUrlsConfig.enabled
|
|
68
|
+
? resolveLocalisedPath(
|
|
69
|
+
pathWithoutLanguage,
|
|
70
|
+
language,
|
|
71
|
+
languages,
|
|
72
|
+
localisedUrlsConfig.map,
|
|
73
|
+
)
|
|
74
|
+
: pathWithoutLanguage;
|
|
75
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
76
|
+
|
|
77
|
+
return `/${[language, ...resolvedSegments].join('/')}`;
|
|
66
78
|
};
|
|
67
79
|
|
|
68
80
|
export const detectLanguageFromPath = (
|
|
@@ -137,27 +149,3 @@ export const shouldIgnoreRedirect = (
|
|
|
137
149
|
);
|
|
138
150
|
});
|
|
139
151
|
};
|
|
140
|
-
|
|
141
|
-
// Safe hook wrapper to handle cases where router context is not available
|
|
142
|
-
export const useRouterHooks = () => {
|
|
143
|
-
try {
|
|
144
|
-
const {
|
|
145
|
-
useLocation,
|
|
146
|
-
useNavigate,
|
|
147
|
-
useParams,
|
|
148
|
-
} = require('@modern-js/runtime/router');
|
|
149
|
-
return {
|
|
150
|
-
navigate: useNavigate(),
|
|
151
|
-
location: useLocation(),
|
|
152
|
-
params: useParams(),
|
|
153
|
-
hasRouter: true,
|
|
154
|
-
};
|
|
155
|
-
} catch (error) {
|
|
156
|
-
return {
|
|
157
|
-
navigate: null,
|
|
158
|
-
location: null,
|
|
159
|
-
params: {},
|
|
160
|
-
hasRouter: false,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
};
|
package/src/server/index.ts
CHANGED
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
mergeDetectionOptions,
|
|
9
9
|
} from '../runtime/i18n/detection/config.js';
|
|
10
10
|
import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
|
|
11
|
+
import {
|
|
12
|
+
resolveLocalisedPath,
|
|
13
|
+
resolveLocalisedUrlsConfig,
|
|
14
|
+
} from '../shared/localisedUrls.js';
|
|
11
15
|
import type { LocaleDetectionOptions } from '../shared/type';
|
|
12
16
|
import { getLocaleDetectionOptions } from '../shared/utils.js';
|
|
13
17
|
|
|
@@ -16,6 +20,74 @@ export interface I18nPluginOptions {
|
|
|
16
20
|
staticRoutePrefixes: string[];
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
type ApiPrefixInput = string | string[] | undefined;
|
|
24
|
+
|
|
25
|
+
const normalizeApiPrefix = (prefix: string): string | null => {
|
|
26
|
+
const trimmedPrefix = prefix.trim();
|
|
27
|
+
if (!trimmedPrefix) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const prefixedPath = trimmedPrefix.startsWith('/')
|
|
32
|
+
? trimmedPrefix
|
|
33
|
+
: `/${trimmedPrefix}`;
|
|
34
|
+
const withoutWildcard = prefixedPath.replace(/\/\*$/, '');
|
|
35
|
+
const normalizedPrefix =
|
|
36
|
+
withoutWildcard.length > 1
|
|
37
|
+
? withoutWildcard.replace(/\/+$/, '')
|
|
38
|
+
: withoutWildcard;
|
|
39
|
+
|
|
40
|
+
return normalizedPrefix === '/' ? null : normalizedPrefix;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const collectApiPrefixes = (
|
|
44
|
+
routes: Array<{ isApi?: boolean; urlPath?: string }>,
|
|
45
|
+
bffPrefix?: ApiPrefixInput,
|
|
46
|
+
): string[] => {
|
|
47
|
+
const prefixes = new Set<string>();
|
|
48
|
+
|
|
49
|
+
for (const route of routes) {
|
|
50
|
+
if (!route.isApi || !route.urlPath) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalizedPrefix = normalizeApiPrefix(route.urlPath);
|
|
55
|
+
if (normalizedPrefix) {
|
|
56
|
+
prefixes.add(normalizedPrefix);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const bffPrefixes = Array.isArray(bffPrefix)
|
|
61
|
+
? bffPrefix
|
|
62
|
+
: bffPrefix
|
|
63
|
+
? [bffPrefix]
|
|
64
|
+
: [];
|
|
65
|
+
|
|
66
|
+
for (const prefix of bffPrefixes) {
|
|
67
|
+
const normalizedPrefix = normalizeApiPrefix(prefix);
|
|
68
|
+
if (normalizedPrefix) {
|
|
69
|
+
prefixes.add(normalizedPrefix);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [...prefixes];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const matchesApiPrefix = (
|
|
77
|
+
pathname: string,
|
|
78
|
+
apiPrefixes: string[],
|
|
79
|
+
): boolean => {
|
|
80
|
+
const normalizedPathname = pathname.startsWith('/')
|
|
81
|
+
? pathname
|
|
82
|
+
: `/${pathname}`;
|
|
83
|
+
|
|
84
|
+
return apiPrefixes.some(
|
|
85
|
+
prefix =>
|
|
86
|
+
normalizedPathname === prefix ||
|
|
87
|
+
normalizedPathname.startsWith(`${prefix}/`),
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
19
91
|
/**
|
|
20
92
|
* Convert i18next detection options to hono languageDetector options
|
|
21
93
|
*/
|
|
@@ -231,6 +303,7 @@ const buildLocalizedUrl = (
|
|
|
231
303
|
urlPath: string,
|
|
232
304
|
language: string,
|
|
233
305
|
languages: string[],
|
|
306
|
+
localisedUrls?: LocaleDetectionOptions['localisedUrls'],
|
|
234
307
|
): string => {
|
|
235
308
|
const url = new URL(req.url);
|
|
236
309
|
const pathname = url.pathname;
|
|
@@ -242,16 +315,21 @@ const buildLocalizedUrl = (
|
|
|
242
315
|
: pathname;
|
|
243
316
|
|
|
244
317
|
const segments = remainingPath.split('/').filter(Boolean);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
318
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
319
|
+
const pathWithoutLanguage =
|
|
320
|
+
segments.length > 0 && languages.includes(segments[0])
|
|
321
|
+
? `/${segments.slice(1).join('/')}`
|
|
322
|
+
: remainingPath;
|
|
323
|
+
const resolvedPath = localisedUrlsConfig.enabled
|
|
324
|
+
? resolveLocalisedPath(
|
|
325
|
+
pathWithoutLanguage,
|
|
326
|
+
language,
|
|
327
|
+
languages,
|
|
328
|
+
localisedUrlsConfig.map,
|
|
329
|
+
)
|
|
330
|
+
: pathWithoutLanguage;
|
|
331
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
332
|
+
const newPathname = `/${[language, ...resolvedSegments].join('/')}`;
|
|
255
333
|
// Handle root path case to avoid double slashes like //en
|
|
256
334
|
const suffix = `${url.search}${url.hash}`;
|
|
257
335
|
const localizedUrl =
|
|
@@ -265,6 +343,11 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
|
|
|
265
343
|
setup: api => {
|
|
266
344
|
api.onPrepare(() => {
|
|
267
345
|
const { middlewares, routes } = api.getServerContext();
|
|
346
|
+
const serverConfig = api.getServerConfig();
|
|
347
|
+
const bffPrefix = serverConfig?.bff
|
|
348
|
+
? (serverConfig.bff.prefix ?? '/api')
|
|
349
|
+
: undefined;
|
|
350
|
+
const apiPrefixes = collectApiPrefixes(routes, bffPrefix);
|
|
268
351
|
|
|
269
352
|
// Collect all non-root entry paths for cross-entry path detection
|
|
270
353
|
const entryPaths = new Set<string>();
|
|
@@ -292,6 +375,7 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
|
|
|
292
375
|
fallbackLanguage = 'en',
|
|
293
376
|
detection,
|
|
294
377
|
ignoreRedirectRoutes,
|
|
378
|
+
localisedUrls,
|
|
295
379
|
} = getLocaleDetectionOptions(entryName, options.localeDetection);
|
|
296
380
|
const staticRoutePrefixes = options.staticRoutePrefixes;
|
|
297
381
|
const originUrlPath = route.urlPath;
|
|
@@ -314,6 +398,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
|
|
|
314
398
|
const url = new URL(c.req.url);
|
|
315
399
|
const pathname = url.pathname;
|
|
316
400
|
|
|
401
|
+
if (matchesApiPrefix(pathname, apiPrefixes)) {
|
|
402
|
+
return await next();
|
|
403
|
+
}
|
|
404
|
+
|
|
317
405
|
// For static resource requests, skip language detection
|
|
318
406
|
if (
|
|
319
407
|
isStaticResourceRequest(
|
|
@@ -348,6 +436,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
|
|
|
348
436
|
const url = new URL(c.req.url);
|
|
349
437
|
const pathname = url.pathname;
|
|
350
438
|
|
|
439
|
+
if (matchesApiPrefix(pathname, apiPrefixes)) {
|
|
440
|
+
return await next();
|
|
441
|
+
}
|
|
442
|
+
|
|
351
443
|
// For static resource requests, skip i18n processing
|
|
352
444
|
if (
|
|
353
445
|
isStaticResourceRequest(
|
|
@@ -391,9 +483,42 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
|
|
|
391
483
|
originUrlPath,
|
|
392
484
|
targetLanguage,
|
|
393
485
|
languages,
|
|
486
|
+
localisedUrls,
|
|
394
487
|
);
|
|
395
488
|
return c.redirect(localizedUrl);
|
|
396
489
|
}
|
|
490
|
+
const localisedUrlsConfig =
|
|
491
|
+
resolveLocalisedUrlsConfig(localisedUrls);
|
|
492
|
+
if (localisedUrlsConfig.enabled) {
|
|
493
|
+
const basePath = originUrlPath.replace('/*', '');
|
|
494
|
+
const remainingPath = pathname.startsWith(basePath)
|
|
495
|
+
? pathname.slice(basePath.length)
|
|
496
|
+
: pathname;
|
|
497
|
+
const pathWithoutLanguage = remainingPath
|
|
498
|
+
.split('/')
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.slice(1)
|
|
501
|
+
.join('/');
|
|
502
|
+
const canonicalLocalizedPath = resolveLocalisedPath(
|
|
503
|
+
`/${pathWithoutLanguage}`,
|
|
504
|
+
language,
|
|
505
|
+
languages,
|
|
506
|
+
localisedUrlsConfig.map,
|
|
507
|
+
);
|
|
508
|
+
const expectedPathname =
|
|
509
|
+
basePath === '/'
|
|
510
|
+
? `/${language}${canonicalLocalizedPath === '/' ? '' : canonicalLocalizedPath}`
|
|
511
|
+
: `${basePath}/${language}${
|
|
512
|
+
canonicalLocalizedPath === '/'
|
|
513
|
+
? ''
|
|
514
|
+
: canonicalLocalizedPath
|
|
515
|
+
}`;
|
|
516
|
+
if (expectedPathname !== pathname) {
|
|
517
|
+
return c.redirect(
|
|
518
|
+
`${expectedPathname}${url.search}${url.hash}`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
397
522
|
await next();
|
|
398
523
|
},
|
|
399
524
|
});
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
|
|
2
|
+
|
|
3
|
+
export type LocalisedUrlPathMap = Record<string, string>;
|
|
4
|
+
export type LocalisedUrlsMap = Record<string, LocalisedUrlPathMap>;
|
|
5
|
+
export type LocalisedUrlsOption = boolean | LocalisedUrlsMap;
|
|
6
|
+
|
|
7
|
+
export interface ResolvedLocalisedUrlsConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
map: LocalisedUrlsMap;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
|
|
13
|
+
|
|
14
|
+
export const normalisePathPattern = (path: string): string => {
|
|
15
|
+
const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
|
|
16
|
+
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/')
|
|
17
|
+
? withoutDuplicateSlashes
|
|
18
|
+
: `/${withoutDuplicateSlashes}`;
|
|
19
|
+
const withoutTrailingSlash =
|
|
20
|
+
withLeadingSlash.length > 1
|
|
21
|
+
? withLeadingSlash.replace(/\/+$/, '')
|
|
22
|
+
: withLeadingSlash;
|
|
23
|
+
|
|
24
|
+
return withoutTrailingSlash.replace(/\[(.+?)\]/g, ':$1');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const normaliseRoutePath = (path: string): string => {
|
|
28
|
+
const normalized = normalisePathPattern(path);
|
|
29
|
+
return normalized === '/' ? '' : normalized.slice(1);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getLocaleParamSegment = (segment: string): string | null => {
|
|
33
|
+
if (!segment.startsWith(':')) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const paramName = segment.slice(1).replace(/\?$/, '');
|
|
38
|
+
return LOCALE_PARAM_NAMES.has(paramName) ? segment : null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const splitPathSegments = (path?: string): string[] => {
|
|
42
|
+
if (!path) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return normalisePathPattern(path).split('/').filter(Boolean);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const stripLeadingLocaleParam = (path?: string): string | undefined => {
|
|
50
|
+
const segments = splitPathSegments(path);
|
|
51
|
+
const leadingLocaleParam = getLocaleParamSegment(segments[0] || '');
|
|
52
|
+
|
|
53
|
+
if (!leadingLocaleParam) {
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const remainingPath = segments.slice(1).join('/');
|
|
58
|
+
return remainingPath ? `/${remainingPath}` : undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getLeadingLocaleParam = (path?: string): string | null => {
|
|
62
|
+
const segments = splitPathSegments(path);
|
|
63
|
+
return getLocaleParamSegment(segments[0] || '');
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const resolveLocalisedUrlsConfig = (
|
|
67
|
+
option: LocalisedUrlsOption | undefined,
|
|
68
|
+
): ResolvedLocalisedUrlsConfig => {
|
|
69
|
+
if (option === false) {
|
|
70
|
+
return { enabled: false, map: {} };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (option && typeof option === 'object') {
|
|
74
|
+
return { enabled: true, map: option };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { enabled: true, map: {} };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const isLocaleParamPath = (path?: string): boolean => {
|
|
81
|
+
const segments = splitPathSegments(path);
|
|
82
|
+
return segments.length === 1 && Boolean(getLocaleParamSegment(segments[0]));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isLocalisableRoutePath = (path?: string): path is string => {
|
|
86
|
+
const pathWithoutLocale = stripLeadingLocaleParam(path);
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
!pathWithoutLocale ||
|
|
90
|
+
pathWithoutLocale === '/' ||
|
|
91
|
+
pathWithoutLocale === '*'
|
|
92
|
+
) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const joinPath = (parentPath: string, routePath?: string): string => {
|
|
100
|
+
if (!isLocalisableRoutePath(routePath)) {
|
|
101
|
+
return parentPath;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const segment = normaliseRoutePath(stripLeadingLocaleParam(routePath) || '');
|
|
105
|
+
return normalisePathPattern(`${parentPath}/${segment}`);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const ensureLocalisedUrlsForPath = (
|
|
109
|
+
canonicalPath: string,
|
|
110
|
+
languages: string[],
|
|
111
|
+
localisedUrls: LocalisedUrlsMap,
|
|
112
|
+
): LocalisedUrlPathMap => {
|
|
113
|
+
const entry = localisedUrls[canonicalPath];
|
|
114
|
+
if (!entry) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`localisedUrls is enabled, but route "${canonicalPath}" does not define localised URLs for languages: ${languages.join(
|
|
117
|
+
', ',
|
|
118
|
+
)}. Add localisedUrls["${canonicalPath}"] or set localeDetection.localisedUrls to false.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const missingLanguages = languages.filter(language => !entry[language]);
|
|
123
|
+
if (missingLanguages.length > 0) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`localisedUrls["${canonicalPath}"] is missing languages: ${missingLanguages.join(
|
|
126
|
+
', ',
|
|
127
|
+
)}. Every configured language must have a localised URL.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return entry;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const validateLocalisedUrls = (
|
|
135
|
+
routes: (NestedRouteForCli | PageRoute)[],
|
|
136
|
+
languages: string[],
|
|
137
|
+
localisedUrls: LocalisedUrlsMap,
|
|
138
|
+
) => {
|
|
139
|
+
const visit = (route: NestedRouteForCli | PageRoute, parentPath: string) => {
|
|
140
|
+
const canonicalPath = joinPath(parentPath, route.path);
|
|
141
|
+
if (isLocalisableRoutePath(route.path)) {
|
|
142
|
+
ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if ('children' in route && route.children) {
|
|
146
|
+
route.children.forEach(child => visit(child, canonicalPath));
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
routes.forEach(route => visit(route, ''));
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const getLocalisedRoutePaths = (
|
|
154
|
+
canonicalPath: string,
|
|
155
|
+
parentLocalisedPaths: Record<string, string>,
|
|
156
|
+
languages: string[],
|
|
157
|
+
entry: LocalisedUrlPathMap,
|
|
158
|
+
): string[] => {
|
|
159
|
+
const paths = languages.map(language => {
|
|
160
|
+
const fullPath = normalisePathPattern(entry[language]);
|
|
161
|
+
const parentPath = normalisePathPattern(
|
|
162
|
+
parentLocalisedPaths[language] || '/',
|
|
163
|
+
);
|
|
164
|
+
if (parentPath === '/') {
|
|
165
|
+
return normaliseRoutePath(fullPath) || undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const parentPrefix = `${parentPath}/`;
|
|
169
|
+
if (!fullPath.startsWith(parentPrefix)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`localisedUrls["${canonicalPath}"].${language} must be nested under "${parentPath}" because its parent route is localised there.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return normaliseRoutePath(fullPath.slice(parentPath.length));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return Array.from(new Set(paths.filter(Boolean) as string[]));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const transformLocalisedRoute = (
|
|
182
|
+
route: NestedRouteForCli | PageRoute,
|
|
183
|
+
parentCanonicalPath: string,
|
|
184
|
+
parentLocalisedPaths: Record<string, string>,
|
|
185
|
+
languages: string[],
|
|
186
|
+
localisedUrls: LocalisedUrlsMap,
|
|
187
|
+
): (NestedRouteForCli | PageRoute)[] => {
|
|
188
|
+
const canonicalPath = joinPath(parentCanonicalPath, route.path);
|
|
189
|
+
const localisedUrlEntry = isLocalisableRoutePath(route.path)
|
|
190
|
+
? ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls)
|
|
191
|
+
: undefined;
|
|
192
|
+
const routeLocalisedPaths = localisedUrlEntry
|
|
193
|
+
? languages.reduce<Record<string, string>>((acc, language) => {
|
|
194
|
+
acc[language] = normalisePathPattern(localisedUrlEntry[language]);
|
|
195
|
+
return acc;
|
|
196
|
+
}, {})
|
|
197
|
+
: parentLocalisedPaths;
|
|
198
|
+
|
|
199
|
+
const children =
|
|
200
|
+
'children' in route && route.children
|
|
201
|
+
? route.children.flatMap(child =>
|
|
202
|
+
transformLocalisedRoute(
|
|
203
|
+
child,
|
|
204
|
+
canonicalPath,
|
|
205
|
+
routeLocalisedPaths,
|
|
206
|
+
languages,
|
|
207
|
+
localisedUrls,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
: undefined;
|
|
211
|
+
|
|
212
|
+
const baseRoute = {
|
|
213
|
+
...route,
|
|
214
|
+
...(children ? { children } : {}),
|
|
215
|
+
} as NestedRouteForCli | PageRoute;
|
|
216
|
+
|
|
217
|
+
if (!localisedUrlEntry) {
|
|
218
|
+
return [baseRoute];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return getLocalisedRoutePaths(
|
|
222
|
+
canonicalPath,
|
|
223
|
+
parentLocalisedPaths,
|
|
224
|
+
languages,
|
|
225
|
+
localisedUrlEntry,
|
|
226
|
+
).map((localisedPath, index) =>
|
|
227
|
+
cloneRouteWithLocalisedPath(baseRoute, localisedPath, index),
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const legalRouteIdPart = (value: string): string =>
|
|
232
|
+
value.replace(/[^a-zA-Z0-9_$-]+/g, '_').replace(/^_+|_+$/g, '') || 'index';
|
|
233
|
+
|
|
234
|
+
const suffixRouteIds = <T extends NestedRouteForCli | PageRoute>(
|
|
235
|
+
route: T,
|
|
236
|
+
suffix: string,
|
|
237
|
+
): T => {
|
|
238
|
+
const children =
|
|
239
|
+
'children' in route && route.children
|
|
240
|
+
? route.children.map(child => suffixRouteIds(child, suffix))
|
|
241
|
+
: undefined;
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
...route,
|
|
245
|
+
...(route.id ? { id: `${route.id}__localised_${suffix}` } : {}),
|
|
246
|
+
...(children ? { children } : {}),
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const cloneRouteWithLocalisedPath = (
|
|
251
|
+
route: NestedRouteForCli | PageRoute,
|
|
252
|
+
path: string,
|
|
253
|
+
index: number,
|
|
254
|
+
): NestedRouteForCli | PageRoute => {
|
|
255
|
+
const leadingLocaleParam = getLeadingLocaleParam(route.path);
|
|
256
|
+
const localisedPath = leadingLocaleParam
|
|
257
|
+
? normaliseRoutePath(`${leadingLocaleParam}/${path}`)
|
|
258
|
+
: path;
|
|
259
|
+
const routeWithPath = {
|
|
260
|
+
...route,
|
|
261
|
+
path: localisedPath,
|
|
262
|
+
} as NestedRouteForCli | PageRoute;
|
|
263
|
+
|
|
264
|
+
return index === 0
|
|
265
|
+
? routeWithPath
|
|
266
|
+
: suffixRouteIds(routeWithPath, legalRouteIdPart(localisedPath));
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const applyLocalisedUrlsToRoutes = (
|
|
270
|
+
routes: (NestedRouteForCli | PageRoute)[],
|
|
271
|
+
languages: string[],
|
|
272
|
+
localisedUrls: LocalisedUrlsMap,
|
|
273
|
+
): (NestedRouteForCli | PageRoute)[] => {
|
|
274
|
+
const rootLocalisedPaths = languages.reduce<Record<string, string>>(
|
|
275
|
+
(acc, language) => {
|
|
276
|
+
acc[language] = '/';
|
|
277
|
+
return acc;
|
|
278
|
+
},
|
|
279
|
+
{},
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
validateLocalisedUrls(routes, languages, localisedUrls);
|
|
283
|
+
|
|
284
|
+
return routes.flatMap(route =>
|
|
285
|
+
transformLocalisedRoute(
|
|
286
|
+
route,
|
|
287
|
+
'',
|
|
288
|
+
rootLocalisedPaths,
|
|
289
|
+
languages,
|
|
290
|
+
localisedUrls,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const escapeRegExp = (value: string): string =>
|
|
296
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
297
|
+
|
|
298
|
+
const getParamName = (segment: string): string =>
|
|
299
|
+
segment.slice(1).replace(/\?$/, '');
|
|
300
|
+
|
|
301
|
+
const compilePathPattern = (pattern: string) => {
|
|
302
|
+
const names: string[] = [];
|
|
303
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
304
|
+
const source = segments
|
|
305
|
+
.map(segment => {
|
|
306
|
+
if (segment.startsWith(':')) {
|
|
307
|
+
names.push(getParamName(segment));
|
|
308
|
+
const paramPattern = '([^/]+)';
|
|
309
|
+
return segment.endsWith('?')
|
|
310
|
+
? `(?:/${paramPattern})?`
|
|
311
|
+
: `/${paramPattern}`;
|
|
312
|
+
}
|
|
313
|
+
if (segment === '*') {
|
|
314
|
+
names.push('*');
|
|
315
|
+
return '/(.*)';
|
|
316
|
+
}
|
|
317
|
+
return `/${escapeRegExp(segment)}`;
|
|
318
|
+
})
|
|
319
|
+
.join('');
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
names,
|
|
323
|
+
regexp: new RegExp(`^${source || '/'}$`),
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const matchPathPattern = (
|
|
328
|
+
pathname: string,
|
|
329
|
+
pattern: string,
|
|
330
|
+
): Record<string, string> | null => {
|
|
331
|
+
const { names, regexp } = compilePathPattern(pattern);
|
|
332
|
+
const match = regexp.exec(normalisePathPattern(pathname));
|
|
333
|
+
if (!match) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return names.reduce<Record<string, string>>((params, name, index) => {
|
|
338
|
+
params[name] = decodeURIComponent(match[index + 1] || '');
|
|
339
|
+
return params;
|
|
340
|
+
}, {});
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const buildPathFromPattern = (
|
|
344
|
+
pattern: string,
|
|
345
|
+
params: Record<string, string>,
|
|
346
|
+
): string => {
|
|
347
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
348
|
+
const path = segments
|
|
349
|
+
.map(segment => {
|
|
350
|
+
if (segment.startsWith(':')) {
|
|
351
|
+
const param = params[getParamName(segment)];
|
|
352
|
+
return param ? encodeURIComponent(param) : '';
|
|
353
|
+
}
|
|
354
|
+
if (segment === '*') {
|
|
355
|
+
return params['*'] || '';
|
|
356
|
+
}
|
|
357
|
+
return segment;
|
|
358
|
+
})
|
|
359
|
+
.filter(Boolean)
|
|
360
|
+
.join('/');
|
|
361
|
+
|
|
362
|
+
return `/${path}`;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export const resolveLocalisedPath = (
|
|
366
|
+
pathname: string,
|
|
367
|
+
targetLanguage: string,
|
|
368
|
+
languages: string[],
|
|
369
|
+
localisedUrls: LocalisedUrlsMap,
|
|
370
|
+
): string => {
|
|
371
|
+
const normalizedPathname = normalisePathPattern(pathname);
|
|
372
|
+
|
|
373
|
+
for (const localisedUrlEntry of Object.values(localisedUrls)) {
|
|
374
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
375
|
+
if (!targetPattern) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const language of languages) {
|
|
380
|
+
const sourcePattern = localisedUrlEntry[language];
|
|
381
|
+
if (!sourcePattern) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
386
|
+
if (params) {
|
|
387
|
+
return buildPathFromPattern(targetPattern, params);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return normalizedPathname;
|
|
393
|
+
};
|
package/src/shared/type.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
LanguageDetectorOptions,
|
|
3
3
|
Resources,
|
|
4
4
|
} from '../runtime/i18n/instance';
|
|
5
|
+
import type { LocalisedUrlsOption } from './localisedUrls';
|
|
5
6
|
|
|
6
7
|
export interface BaseLocaleDetectionOptions {
|
|
7
8
|
localePathRedirect?: boolean;
|
|
@@ -10,6 +11,17 @@ export interface BaseLocaleDetectionOptions {
|
|
|
10
11
|
fallbackLanguage?: string;
|
|
11
12
|
detection?: LanguageDetectorOptions;
|
|
12
13
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
|
|
14
|
+
/**
|
|
15
|
+
* Enables localised pathnames in addition to the locale prefix.
|
|
16
|
+
*
|
|
17
|
+
* - `false`: keep only locale-prefix behavior (`/en/about`).
|
|
18
|
+
* - object: map canonical route paths to every configured language.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to `true` when `localePathRedirect` is enabled, so route
|
|
21
|
+
* generation validates that every localisable route path has entries for all
|
|
22
|
+
* configured languages.
|
|
23
|
+
*/
|
|
24
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
|