@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.9 → 3.2.0-ultramodern.91
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/defaults.node.js +2 -2
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +4 -4
- package/dist/cjs/runtime/i18n/instance.js +0 -24
- package/dist/cjs/runtime/i18n/react-i18next.js +49 -0
- package/dist/cjs/runtime/i18n/utils.js +0 -12
- package/dist/cjs/runtime/index.js +18 -10
- package/dist/cjs/runtime/routerAdapter.js +163 -0
- package/dist/cjs/runtime/utils.js +63 -94
- package/dist/cjs/server/index.js +60 -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/defaults.node.mjs +2 -2
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm/runtime/i18n/instance.mjs +1 -19
- package/dist/esm/runtime/i18n/react-i18next.mjs +15 -0
- package/dist/esm/runtime/i18n/utils.mjs +0 -12
- package/dist/esm/runtime/index.mjs +19 -11
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +11 -30
- package/dist/esm/server/index.mjs +53 -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/defaults.node.mjs +2 -2
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +3 -3
- package/dist/esm-node/runtime/i18n/instance.mjs +1 -19
- package/dist/esm-node/runtime/i18n/react-i18next.mjs +16 -0
- package/dist/esm-node/runtime/i18n/utils.mjs +0 -12
- package/dist/esm-node/runtime/index.mjs +19 -11
- 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 +53 -7
- package/dist/esm-node/shared/localisedUrls.mjs +192 -0
- package/dist/types/runtime/I18nLink.d.ts +15 -0
- package/dist/types/runtime/context.d.ts +3 -0
- package/dist/types/runtime/hooks.d.ts +4 -2
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
- package/dist/types/runtime/i18n/instance.d.ts +0 -5
- package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
- package/dist/types/runtime/index.d.ts +1 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/utils.d.ts +2 -7
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/shared/localisedUrls.d.ts +13 -0
- package/dist/types/shared/type.d.ts +12 -0
- package/package.json +18 -22
- 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.node.ts +2 -2
- 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 +0 -29
- package/src/runtime/i18n/react-i18next.ts +25 -0
- package/src/runtime/i18n/utils.ts +4 -26
- package/src/runtime/index.tsx +23 -10
- package/src/runtime/routerAdapter.tsx +333 -0
- package/src/runtime/utils.ts +22 -34
- package/src/server/index.ts +117 -10
- package/src/shared/localisedUrls.ts +393 -0
- package/src/shared/type.ts +12 -0
- package/tests/i18nUtils.test.ts +52 -0
- package/tests/localisedUrls.test.ts +312 -0
- package/tests/routerAdapter.test.tsx +382 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- package/dist/esm-node/rslib-runtime.mjs +0 -19
package/src/runtime/index.tsx
CHANGED
|
@@ -29,11 +29,7 @@ import {
|
|
|
29
29
|
mergeDetectionOptions,
|
|
30
30
|
} from './i18n/detection';
|
|
31
31
|
import { useI18nextLanguageDetector } from './i18n/detection/middleware';
|
|
32
|
-
import {
|
|
33
|
-
getI18nextInstanceForProvider,
|
|
34
|
-
getI18nextProvider,
|
|
35
|
-
getInitReactI18next,
|
|
36
|
-
} from './i18n/instance';
|
|
32
|
+
import { getI18nextInstanceForProvider } from './i18n/instance';
|
|
37
33
|
import {
|
|
38
34
|
changeI18nLanguage,
|
|
39
35
|
ensureLanguageMatch,
|
|
@@ -54,6 +50,7 @@ export interface I18nPluginOptions {
|
|
|
54
50
|
changeLanguage?: (lang: string) => void;
|
|
55
51
|
initOptions?: I18nInitOptions;
|
|
56
52
|
htmlLangAttr?: boolean;
|
|
53
|
+
reactI18next?: boolean;
|
|
57
54
|
[key: string]: any;
|
|
58
55
|
}
|
|
59
56
|
|
|
@@ -72,6 +69,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
72
69
|
localeDetection,
|
|
73
70
|
backend,
|
|
74
71
|
htmlLangAttr = false,
|
|
72
|
+
reactI18next = true,
|
|
75
73
|
} = options;
|
|
76
74
|
const {
|
|
77
75
|
localePathRedirect = false,
|
|
@@ -80,20 +78,31 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
80
78
|
fallbackLanguage = 'en',
|
|
81
79
|
detection,
|
|
82
80
|
ignoreRedirectRoutes,
|
|
81
|
+
localisedUrls,
|
|
83
82
|
} = localeDetection || {};
|
|
84
83
|
const { enabled: backendEnabled = false } = backend || {};
|
|
85
84
|
let latestI18nInstance: I18nInstance | undefined;
|
|
86
85
|
let I18nextProvider: React.FunctionComponent<any> | null;
|
|
87
86
|
|
|
87
|
+
const loadReactI18nextIntegration = async () => {
|
|
88
|
+
if (!reactI18next) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const { getReactI18nextIntegration } = await import(
|
|
92
|
+
'./i18n/react-i18next'
|
|
93
|
+
);
|
|
94
|
+
return getReactI18nextIntegration();
|
|
95
|
+
};
|
|
96
|
+
|
|
88
97
|
api.onBeforeRender(async context => {
|
|
89
98
|
let i18nInstance = await getI18nInstance(userI18nInstance);
|
|
90
99
|
const { i18n: otherConfig } = api.getRuntimeConfig();
|
|
91
100
|
const { initOptions: otherInitOptions } = otherConfig || {};
|
|
92
101
|
const userInitOptions = merge(otherInitOptions || {}, initOptions || {});
|
|
93
|
-
const
|
|
94
|
-
I18nextProvider =
|
|
95
|
-
if (initReactI18next) {
|
|
96
|
-
i18nInstance.use(initReactI18next);
|
|
102
|
+
const reactI18nextIntegration = await loadReactI18nextIntegration();
|
|
103
|
+
I18nextProvider = reactI18nextIntegration?.I18nextProvider ?? null;
|
|
104
|
+
if (reactI18nextIntegration?.initReactI18next) {
|
|
105
|
+
i18nInstance.use(reactI18nextIntegration.initReactI18next);
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
const pathname = getPathname(context);
|
|
@@ -227,6 +236,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
227
236
|
languages,
|
|
228
237
|
fallbackLanguage,
|
|
229
238
|
ignoreRedirectRoutes,
|
|
239
|
+
localisedUrls,
|
|
230
240
|
);
|
|
231
241
|
|
|
232
242
|
const contextValue = useMemo(
|
|
@@ -238,6 +248,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
238
248
|
languages,
|
|
239
249
|
localePathRedirect,
|
|
240
250
|
ignoreRedirectRoutes,
|
|
251
|
+
localisedUrls,
|
|
241
252
|
setLang,
|
|
242
253
|
),
|
|
243
254
|
[
|
|
@@ -247,15 +258,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
|
|
|
247
258
|
languages,
|
|
248
259
|
localePathRedirect,
|
|
249
260
|
ignoreRedirectRoutes,
|
|
261
|
+
localisedUrls,
|
|
250
262
|
forceUpdate,
|
|
251
263
|
],
|
|
252
264
|
);
|
|
253
265
|
|
|
266
|
+
const children = (props as React.PropsWithChildren).children;
|
|
254
267
|
const appContent = (
|
|
255
268
|
<>
|
|
256
269
|
{Boolean(htmlLangAttr) && <Helmet htmlAttributes={{ lang }} />}
|
|
257
270
|
<ModernI18nProvider value={contextValue}>
|
|
258
|
-
<App {...props}
|
|
271
|
+
{App ? <App {...props}>{children}</App> : children}
|
|
259
272
|
</ModernI18nProvider>
|
|
260
273
|
</>
|
|
261
274
|
);
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { isBrowser, RuntimeContext } from '@modern-js/runtime';
|
|
2
|
+
import {
|
|
3
|
+
InternalRuntimeContext,
|
|
4
|
+
type TInternalRuntimeContext,
|
|
5
|
+
type TRuntimeContext,
|
|
6
|
+
} from '@modern-js/runtime/context';
|
|
7
|
+
import {
|
|
8
|
+
Link as ReactRouterLink,
|
|
9
|
+
useInRouterContext,
|
|
10
|
+
useLocation as useReactRouterLocation,
|
|
11
|
+
useNavigate as useReactRouterNavigate,
|
|
12
|
+
useParams as useReactRouterParams,
|
|
13
|
+
} from '@modern-js/runtime/router';
|
|
14
|
+
import type React from 'react';
|
|
15
|
+
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
export type I18nRouterFramework = 'react-router' | 'tanstack' | string;
|
|
18
|
+
|
|
19
|
+
export interface I18nRouterLocation {
|
|
20
|
+
pathname: string;
|
|
21
|
+
search: string;
|
|
22
|
+
hash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface I18nRouterNavigateOptions {
|
|
26
|
+
replace?: boolean;
|
|
27
|
+
state?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type I18nRouterNavigate = (
|
|
31
|
+
href: string,
|
|
32
|
+
options?: I18nRouterNavigateOptions,
|
|
33
|
+
) => void | Promise<void>;
|
|
34
|
+
|
|
35
|
+
export type I18nRouterLink = React.ComponentType<{
|
|
36
|
+
to: string;
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
export interface I18nRouterAdapter {
|
|
42
|
+
framework?: I18nRouterFramework;
|
|
43
|
+
hasRouter: boolean;
|
|
44
|
+
location: I18nRouterLocation | null;
|
|
45
|
+
navigate: I18nRouterNavigate | null;
|
|
46
|
+
Link: I18nRouterLink | null;
|
|
47
|
+
params: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type RuntimeContextWithRouter = TRuntimeContext & {
|
|
51
|
+
router?: {
|
|
52
|
+
useRouter?: (options?: { warn?: boolean }) => unknown;
|
|
53
|
+
useLocation?: () => unknown;
|
|
54
|
+
useHref?: () => unknown;
|
|
55
|
+
Link?: I18nRouterLink;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type InternalRuntimeContextWithRouter = TInternalRuntimeContext & {
|
|
60
|
+
router?: RuntimeContextWithRouter['router'];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type RouterInstance = {
|
|
64
|
+
navigate?: (...args: any[]) => unknown;
|
|
65
|
+
state?: {
|
|
66
|
+
location?: unknown;
|
|
67
|
+
matches?: Array<{ params?: Record<string, string> }>;
|
|
68
|
+
};
|
|
69
|
+
stores?: {
|
|
70
|
+
location?: {
|
|
71
|
+
get?: () => unknown;
|
|
72
|
+
subscribe?: (listener: () => void) => () => void;
|
|
73
|
+
};
|
|
74
|
+
matches?: {
|
|
75
|
+
get?: () => Array<{ params?: Record<string, string> }>;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
subscribe?: (eventType: string, listener: () => void) => () => void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const normalizeUrlPart = (value: unknown, prefix: '?' | '#'): string => {
|
|
82
|
+
if (typeof value !== 'string' || !value) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const normalizeLocation = (location: unknown): I18nRouterLocation | null => {
|
|
89
|
+
if (!location || typeof location !== 'object') {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const locationValue = location as {
|
|
94
|
+
pathname?: unknown;
|
|
95
|
+
search?: unknown;
|
|
96
|
+
searchStr?: unknown;
|
|
97
|
+
hash?: unknown;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (typeof locationValue.pathname !== 'string') {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
pathname: locationValue.pathname,
|
|
106
|
+
search: normalizeUrlPart(
|
|
107
|
+
typeof locationValue.search === 'string'
|
|
108
|
+
? locationValue.search
|
|
109
|
+
: locationValue.searchStr,
|
|
110
|
+
'?',
|
|
111
|
+
),
|
|
112
|
+
hash: normalizeUrlPart(locationValue.hash, '#'),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const getWindowLocation = (): I18nRouterLocation | null => {
|
|
117
|
+
if (!isBrowser()) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
pathname: window.location.pathname,
|
|
123
|
+
search: window.location.search,
|
|
124
|
+
hash: window.location.hash,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const getRouterFramework = (
|
|
129
|
+
runtimeContext: RuntimeContextWithRouter,
|
|
130
|
+
internalContext: InternalRuntimeContextWithRouter,
|
|
131
|
+
inReactRouter: boolean,
|
|
132
|
+
): I18nRouterFramework | undefined => {
|
|
133
|
+
const framework =
|
|
134
|
+
internalContext.routerFramework ||
|
|
135
|
+
internalContext.routerRuntime?.framework ||
|
|
136
|
+
runtimeContext.routerFramework;
|
|
137
|
+
|
|
138
|
+
if (framework) {
|
|
139
|
+
return framework;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (internalContext.router?.useRouter || runtimeContext.router?.useRouter) {
|
|
143
|
+
return 'tanstack';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
internalContext.router?.useLocation ||
|
|
148
|
+
internalContext.router?.useHref ||
|
|
149
|
+
runtimeContext.router?.useLocation ||
|
|
150
|
+
runtimeContext.router?.useHref
|
|
151
|
+
) {
|
|
152
|
+
return 'react-router';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (inReactRouter) {
|
|
156
|
+
return 'react-router';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const getRouterInstance = (
|
|
163
|
+
internalContext: InternalRuntimeContextWithRouter,
|
|
164
|
+
contextRouter?: RouterInstance | null,
|
|
165
|
+
): RouterInstance | null => {
|
|
166
|
+
if (contextRouter) {
|
|
167
|
+
return contextRouter;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const router =
|
|
171
|
+
internalContext.routerInstance || internalContext.routerRuntime?.instance;
|
|
172
|
+
if (!router || typeof router !== 'object') {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return router as RouterInstance;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const getRouterStateLocation = (
|
|
179
|
+
internalContext: InternalRuntimeContextWithRouter,
|
|
180
|
+
contextRouter?: RouterInstance | null,
|
|
181
|
+
): I18nRouterLocation | null => {
|
|
182
|
+
const router = getRouterInstance(internalContext, contextRouter);
|
|
183
|
+
return (
|
|
184
|
+
normalizeLocation(router?.stores?.location?.get?.()) ||
|
|
185
|
+
normalizeLocation(router?.state?.location)
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const getRouterParams = (
|
|
190
|
+
internalContext: InternalRuntimeContextWithRouter,
|
|
191
|
+
contextRouter?: RouterInstance | null,
|
|
192
|
+
): Record<string, string> => {
|
|
193
|
+
const router = getRouterInstance(internalContext, contextRouter);
|
|
194
|
+
const matches = router?.stores?.matches?.get?.() || router?.state?.matches;
|
|
195
|
+
if (!Array.isArray(matches)) {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return matches.reduce<Record<string, string>>((params, match) => {
|
|
200
|
+
if (match?.params) {
|
|
201
|
+
Object.assign(params, match.params);
|
|
202
|
+
}
|
|
203
|
+
return params;
|
|
204
|
+
}, {});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const useI18nRouterAdapter = (): I18nRouterAdapter => {
|
|
208
|
+
const runtimeContext = useContext(RuntimeContext) as RuntimeContextWithRouter;
|
|
209
|
+
const internalContext = useContext(
|
|
210
|
+
InternalRuntimeContext,
|
|
211
|
+
) as InternalRuntimeContextWithRouter;
|
|
212
|
+
const inReactRouter = useInRouterContext();
|
|
213
|
+
const reactRouterNavigate = inReactRouter ? useReactRouterNavigate() : null;
|
|
214
|
+
const reactRouterLocation = inReactRouter ? useReactRouterLocation() : null;
|
|
215
|
+
const reactRouterParams = inReactRouter ? useReactRouterParams() : {};
|
|
216
|
+
const framework = getRouterFramework(
|
|
217
|
+
runtimeContext,
|
|
218
|
+
internalContext,
|
|
219
|
+
inReactRouter,
|
|
220
|
+
);
|
|
221
|
+
const contextUseRouter =
|
|
222
|
+
!inReactRouter && framework === 'tanstack'
|
|
223
|
+
? internalContext.router?.useRouter || runtimeContext.router?.useRouter
|
|
224
|
+
: undefined;
|
|
225
|
+
const contextRouter = contextUseRouter
|
|
226
|
+
? (contextUseRouter({ warn: false }) as RouterInstance | null)
|
|
227
|
+
: null;
|
|
228
|
+
const [, setRouterVersion] = useState(0);
|
|
229
|
+
const hasRouter =
|
|
230
|
+
framework === 'tanstack' ||
|
|
231
|
+
framework === 'react-router' ||
|
|
232
|
+
Boolean(reactRouterNavigate);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (framework !== 'tanstack') {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const router = getRouterInstance(internalContext, contextRouter);
|
|
240
|
+
if (!router) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const update = () => setRouterVersion(version => version + 1);
|
|
245
|
+
const unsubscribers: Array<() => void> = [];
|
|
246
|
+
|
|
247
|
+
if (typeof router.stores?.location?.subscribe === 'function') {
|
|
248
|
+
const unsubscribe = router.stores.location.subscribe(update);
|
|
249
|
+
if (typeof unsubscribe === 'function') {
|
|
250
|
+
unsubscribers.push(unsubscribe);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (typeof router.subscribe === 'function') {
|
|
255
|
+
for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
|
|
256
|
+
const unsubscribe = router.subscribe(eventType, update);
|
|
257
|
+
if (typeof unsubscribe === 'function') {
|
|
258
|
+
unsubscribers.push(unsubscribe);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return () => {
|
|
264
|
+
for (const unsubscribe of unsubscribers) {
|
|
265
|
+
unsubscribe();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}, [contextRouter, framework, internalContext]);
|
|
269
|
+
|
|
270
|
+
const navigate = useCallback<I18nRouterNavigate>(
|
|
271
|
+
(href, options) => {
|
|
272
|
+
const router = getRouterInstance(internalContext, contextRouter);
|
|
273
|
+
const activeFramework = getRouterFramework(
|
|
274
|
+
runtimeContext,
|
|
275
|
+
internalContext,
|
|
276
|
+
inReactRouter,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (activeFramework === 'tanstack') {
|
|
280
|
+
if (typeof router?.navigate === 'function') {
|
|
281
|
+
return router.navigate({
|
|
282
|
+
to: href,
|
|
283
|
+
replace: options?.replace,
|
|
284
|
+
...(options?.state === undefined ? {} : { state: options.state }),
|
|
285
|
+
}) as void | Promise<void>;
|
|
286
|
+
}
|
|
287
|
+
throw new Error('TanStack router instance is not available.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (reactRouterNavigate) {
|
|
291
|
+
return reactRouterNavigate(href, options);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (activeFramework === 'react-router') {
|
|
295
|
+
if (typeof router?.navigate === 'function') {
|
|
296
|
+
return router.navigate(href, options) as void | Promise<void>;
|
|
297
|
+
}
|
|
298
|
+
throw new Error('React Router instance is not available.');
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
[
|
|
302
|
+
contextRouter,
|
|
303
|
+
internalContext,
|
|
304
|
+
inReactRouter,
|
|
305
|
+
reactRouterNavigate,
|
|
306
|
+
runtimeContext,
|
|
307
|
+
],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const location =
|
|
311
|
+
(reactRouterLocation
|
|
312
|
+
? normalizeLocation(reactRouterLocation)
|
|
313
|
+
: getRouterStateLocation(internalContext, contextRouter)) ||
|
|
314
|
+
getWindowLocation();
|
|
315
|
+
const params = inReactRouter
|
|
316
|
+
? (reactRouterParams as Record<string, string>)
|
|
317
|
+
: getRouterParams(internalContext, contextRouter);
|
|
318
|
+
const Link =
|
|
319
|
+
framework === 'tanstack'
|
|
320
|
+
? internalContext.router?.Link || runtimeContext.router?.Link || null
|
|
321
|
+
: framework === 'react-router' || inReactRouter
|
|
322
|
+
? (ReactRouterLink as I18nRouterLink)
|
|
323
|
+
: null;
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
framework,
|
|
327
|
+
hasRouter,
|
|
328
|
+
location,
|
|
329
|
+
navigate: hasRouter ? navigate : null,
|
|
330
|
+
Link,
|
|
331
|
+
params,
|
|
332
|
+
};
|
|
333
|
+
};
|
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,24 @@ 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 expectedUrl = buildLocalizedUrl(
|
|
494
|
+
c.req,
|
|
495
|
+
originUrlPath,
|
|
496
|
+
language,
|
|
497
|
+
languages,
|
|
498
|
+
localisedUrls,
|
|
499
|
+
);
|
|
500
|
+
if (expectedUrl !== `${pathname}${url.search}${url.hash}`) {
|
|
501
|
+
return c.redirect(expectedUrl);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
397
504
|
await next();
|
|
398
505
|
},
|
|
399
506
|
});
|