@modern-js/plugin-i18n 2.69.7 → 3.0.0-alpha.1

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.
Files changed (151) hide show
  1. package/README.md +6 -0
  2. package/dist/cjs/cli/index.cjs +154 -0
  3. package/dist/cjs/runtime/I18nLink.cjs +68 -0
  4. package/dist/cjs/runtime/context.cjs +138 -0
  5. package/dist/cjs/runtime/hooks.cjs +189 -0
  6. package/dist/cjs/runtime/i18n/backend/config.cjs +39 -0
  7. package/dist/cjs/runtime/i18n/backend/defaults.cjs +56 -0
  8. package/dist/cjs/runtime/i18n/backend/defaults.node.cjs +56 -0
  9. package/dist/cjs/runtime/i18n/backend/index.cjs +108 -0
  10. package/dist/cjs/runtime/i18n/backend/middleware.cjs +54 -0
  11. package/dist/cjs/runtime/i18n/backend/middleware.common.cjs +105 -0
  12. package/dist/cjs/runtime/i18n/backend/middleware.node.cjs +58 -0
  13. package/dist/cjs/runtime/i18n/backend/sdk-backend.cjs +171 -0
  14. package/dist/cjs/runtime/i18n/detection/config.cjs +63 -0
  15. package/dist/cjs/runtime/i18n/detection/index.cjs +309 -0
  16. package/dist/cjs/runtime/i18n/detection/middleware.cjs +185 -0
  17. package/dist/cjs/runtime/i18n/detection/middleware.node.cjs +74 -0
  18. package/dist/cjs/runtime/i18n/index.cjs +43 -0
  19. package/dist/cjs/runtime/i18n/instance.cjs +132 -0
  20. package/dist/cjs/runtime/i18n/utils.cjs +185 -0
  21. package/dist/cjs/runtime/index.cjs +172 -0
  22. package/dist/cjs/runtime/types.cjs +18 -0
  23. package/dist/cjs/runtime/utils.cjs +134 -0
  24. package/dist/cjs/server/index.cjs +178 -0
  25. package/dist/cjs/shared/deepMerge.cjs +54 -0
  26. package/dist/cjs/shared/detection.cjs +105 -0
  27. package/dist/cjs/shared/type.cjs +18 -0
  28. package/dist/cjs/shared/utils.cjs +78 -0
  29. package/dist/esm/cli/index.js +106 -0
  30. package/dist/esm/runtime/I18nLink.js +31 -0
  31. package/dist/esm/runtime/context.js +101 -0
  32. package/dist/esm/runtime/hooks.js +146 -0
  33. package/dist/esm/runtime/i18n/backend/config.js +5 -0
  34. package/dist/esm/runtime/i18n/backend/defaults.js +19 -0
  35. package/dist/esm/runtime/i18n/backend/defaults.node.js +19 -0
  36. package/dist/esm/runtime/i18n/backend/index.js +74 -0
  37. package/dist/esm/runtime/i18n/backend/middleware.common.js +61 -0
  38. package/dist/esm/runtime/i18n/backend/middleware.js +7 -0
  39. package/dist/esm/runtime/i18n/backend/middleware.node.js +8 -0
  40. package/dist/esm/runtime/i18n/backend/sdk-backend.js +137 -0
  41. package/dist/esm/runtime/i18n/detection/config.js +26 -0
  42. package/dist/esm/runtime/i18n/detection/index.js +260 -0
  43. package/dist/esm/runtime/i18n/detection/middleware.js +132 -0
  44. package/dist/esm/runtime/i18n/detection/middleware.node.js +31 -0
  45. package/dist/esm/runtime/i18n/index.js +3 -0
  46. package/dist/esm/runtime/i18n/instance.js +77 -0
  47. package/dist/esm/runtime/i18n/utils.js +136 -0
  48. package/dist/esm/runtime/index.js +129 -0
  49. package/dist/esm/runtime/types.js +0 -0
  50. package/dist/esm/runtime/utils.js +82 -0
  51. package/dist/esm/server/index.js +168 -0
  52. package/dist/esm/shared/deepMerge.js +20 -0
  53. package/dist/esm/shared/detection.js +71 -0
  54. package/dist/esm/shared/type.js +0 -0
  55. package/dist/esm/shared/utils.js +35 -0
  56. package/dist/esm-node/cli/index.js +106 -0
  57. package/dist/esm-node/runtime/I18nLink.js +31 -0
  58. package/dist/esm-node/runtime/context.js +101 -0
  59. package/dist/esm-node/runtime/hooks.js +146 -0
  60. package/dist/esm-node/runtime/i18n/backend/config.js +5 -0
  61. package/dist/esm-node/runtime/i18n/backend/defaults.js +19 -0
  62. package/dist/esm-node/runtime/i18n/backend/defaults.node.js +19 -0
  63. package/dist/esm-node/runtime/i18n/backend/index.js +74 -0
  64. package/dist/esm-node/runtime/i18n/backend/middleware.common.js +61 -0
  65. package/dist/esm-node/runtime/i18n/backend/middleware.js +7 -0
  66. package/dist/esm-node/runtime/i18n/backend/middleware.node.js +8 -0
  67. package/dist/esm-node/runtime/i18n/backend/sdk-backend.js +137 -0
  68. package/dist/esm-node/runtime/i18n/detection/config.js +26 -0
  69. package/dist/esm-node/runtime/i18n/detection/index.js +260 -0
  70. package/dist/esm-node/runtime/i18n/detection/middleware.js +132 -0
  71. package/dist/esm-node/runtime/i18n/detection/middleware.node.js +31 -0
  72. package/dist/esm-node/runtime/i18n/index.js +3 -0
  73. package/dist/esm-node/runtime/i18n/instance.js +77 -0
  74. package/dist/esm-node/runtime/i18n/utils.js +136 -0
  75. package/dist/esm-node/runtime/index.js +129 -0
  76. package/dist/esm-node/runtime/types.js +0 -0
  77. package/dist/esm-node/runtime/utils.js +82 -0
  78. package/dist/esm-node/server/index.js +168 -0
  79. package/dist/esm-node/shared/deepMerge.js +20 -0
  80. package/dist/esm-node/shared/detection.js +71 -0
  81. package/dist/esm-node/shared/type.js +0 -0
  82. package/dist/esm-node/shared/utils.js +35 -0
  83. package/dist/types/cli/index.d.ts +21 -0
  84. package/dist/types/runtime/I18nLink.d.ts +8 -0
  85. package/dist/types/runtime/context.d.ts +38 -0
  86. package/dist/types/runtime/hooks.d.ts +28 -0
  87. package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
  88. package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
  89. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
  90. package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
  91. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
  92. package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
  93. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
  94. package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +52 -0
  95. package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
  96. package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
  97. package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
  98. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
  99. package/dist/types/runtime/i18n/index.d.ts +3 -0
  100. package/dist/types/runtime/i18n/instance.d.ts +93 -0
  101. package/dist/types/runtime/i18n/utils.d.ts +29 -0
  102. package/dist/types/runtime/index.d.ts +20 -0
  103. package/dist/types/runtime/types.d.ts +15 -0
  104. package/dist/types/runtime/utils.d.ts +33 -0
  105. package/dist/types/server/index.d.ts +8 -0
  106. package/dist/types/shared/deepMerge.d.ts +1 -0
  107. package/dist/types/shared/detection.d.ts +11 -0
  108. package/dist/types/shared/type.d.ts +156 -0
  109. package/dist/types/shared/utils.d.ts +5 -0
  110. package/package.json +100 -34
  111. package/rslib.config.mts +4 -0
  112. package/src/cli/index.ts +245 -0
  113. package/src/runtime/I18nLink.tsx +76 -0
  114. package/src/runtime/context.tsx +256 -0
  115. package/src/runtime/hooks.ts +274 -0
  116. package/src/runtime/i18n/backend/config.ts +10 -0
  117. package/src/runtime/i18n/backend/defaults.node.ts +31 -0
  118. package/src/runtime/i18n/backend/defaults.ts +37 -0
  119. package/src/runtime/i18n/backend/index.ts +181 -0
  120. package/src/runtime/i18n/backend/middleware.common.ts +116 -0
  121. package/src/runtime/i18n/backend/middleware.node.ts +32 -0
  122. package/src/runtime/i18n/backend/middleware.ts +28 -0
  123. package/src/runtime/i18n/backend/sdk-backend.ts +292 -0
  124. package/src/runtime/i18n/detection/config.ts +32 -0
  125. package/src/runtime/i18n/detection/index.ts +641 -0
  126. package/src/runtime/i18n/detection/middleware.node.ts +84 -0
  127. package/src/runtime/i18n/detection/middleware.ts +251 -0
  128. package/src/runtime/i18n/index.ts +8 -0
  129. package/src/runtime/i18n/instance.ts +227 -0
  130. package/src/runtime/i18n/utils.ts +333 -0
  131. package/src/runtime/index.tsx +281 -0
  132. package/src/runtime/types.ts +17 -0
  133. package/src/runtime/utils.ts +151 -0
  134. package/src/server/index.ts +336 -0
  135. package/src/shared/deepMerge.ts +38 -0
  136. package/src/shared/detection.ts +131 -0
  137. package/src/shared/type.ts +170 -0
  138. package/src/shared/utils.ts +82 -0
  139. package/tsconfig.json +12 -0
  140. package/dist/cjs/index.js +0 -73
  141. package/dist/cjs/languageDetector.js +0 -51
  142. package/dist/cjs/utils/index.js +0 -39
  143. package/dist/esm/index.js +0 -61
  144. package/dist/esm/languageDetector.js +0 -33
  145. package/dist/esm/utils/index.js +0 -16
  146. package/dist/esm-node/index.js +0 -49
  147. package/dist/esm-node/languageDetector.js +0 -26
  148. package/dist/esm-node/utils/index.js +0 -15
  149. package/dist/types/index.d.ts +0 -34
  150. package/dist/types/languageDetector.d.ts +0 -6
  151. package/dist/types/utils/index.d.ts +0 -5
@@ -0,0 +1,151 @@
1
+ import { isBrowser } from '@modern-js/runtime';
2
+ import {
3
+ type TInternalRuntimeContext,
4
+ getGlobalBasename,
5
+ } from '@modern-js/runtime/context';
6
+
7
+ export const getPathname = (context: TInternalRuntimeContext): string => {
8
+ if (isBrowser()) {
9
+ return window.location.pathname;
10
+ }
11
+ return context.ssrContext?.request?.pathname || '/';
12
+ };
13
+
14
+ export const getEntryPath = (): string => {
15
+ const basename = getGlobalBasename();
16
+ if (basename) {
17
+ return basename === '/' ? '' : basename;
18
+ }
19
+ return '';
20
+ };
21
+ /**
22
+ * Helper function to get language from current pathname
23
+ * @param pathname - The current pathname
24
+ * @param languages - Array of supported languages
25
+ * @param fallbackLanguage - Fallback language when no language is detected
26
+ * @returns The detected language or fallback language
27
+ */
28
+ export const getLanguageFromPath = (
29
+ pathname: string,
30
+ languages: string[],
31
+ fallbackLanguage: string,
32
+ ): string => {
33
+ const segments = pathname.split('/').filter(Boolean);
34
+ const firstSegment = segments[0];
35
+
36
+ if (languages.includes(firstSegment)) {
37
+ return firstSegment;
38
+ }
39
+
40
+ return fallbackLanguage;
41
+ };
42
+
43
+ /**
44
+ * Helper function to build localized URL
45
+ * @param pathname - The current pathname
46
+ * @param language - The target language
47
+ * @param languages - Array of supported languages
48
+ * @returns The localized URL path
49
+ */
50
+ export const buildLocalizedUrl = (
51
+ pathname: string,
52
+ language: string,
53
+ languages: string[],
54
+ ): string => {
55
+ const segments = pathname.split('/').filter(Boolean);
56
+
57
+ if (segments.length > 0 && languages.includes(segments[0])) {
58
+ // Replace existing language prefix
59
+ segments[0] = language;
60
+ } else {
61
+ // Add language prefix
62
+ segments.unshift(language);
63
+ }
64
+
65
+ return `/${segments.join('/')}`;
66
+ };
67
+
68
+ export const detectLanguageFromPath = (
69
+ pathname: string,
70
+ languages: string[],
71
+ localePathRedirect: boolean,
72
+ ): {
73
+ detected: boolean;
74
+ language?: string;
75
+ } => {
76
+ if (!localePathRedirect) {
77
+ return { detected: false };
78
+ }
79
+
80
+ const relativePath = pathname.replace(getEntryPath(), '');
81
+ const segments = relativePath.split('/').filter(Boolean);
82
+ const firstSegment = segments[0];
83
+
84
+ if (firstSegment && languages.includes(firstSegment)) {
85
+ return { detected: true, language: firstSegment };
86
+ }
87
+
88
+ return { detected: false };
89
+ };
90
+
91
+ /**
92
+ * Check if the given pathname should ignore automatic locale redirect
93
+ */
94
+ export const shouldIgnoreRedirect = (
95
+ pathname: string,
96
+ languages: string[],
97
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
98
+ ): boolean => {
99
+ if (!ignoreRedirectRoutes) {
100
+ return false;
101
+ }
102
+
103
+ // Remove language prefix if present (e.g., /en/api -> /api)
104
+ const segments = pathname.split('/').filter(Boolean);
105
+ let pathWithoutLang = pathname;
106
+ if (segments.length > 0 && languages.includes(segments[0])) {
107
+ // Remove language prefix
108
+ pathWithoutLang = `/${segments.slice(1).join('/')}`;
109
+ }
110
+
111
+ // Normalize path (ensure it starts with /)
112
+ const normalizedPath = pathWithoutLang.startsWith('/')
113
+ ? pathWithoutLang
114
+ : `/${pathWithoutLang}`;
115
+
116
+ if (typeof ignoreRedirectRoutes === 'function') {
117
+ return ignoreRedirectRoutes(normalizedPath);
118
+ }
119
+
120
+ // Check if pathname matches any of the ignore patterns
121
+ return ignoreRedirectRoutes.some(pattern => {
122
+ // Support both exact match and prefix match
123
+ return (
124
+ normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
125
+ );
126
+ });
127
+ };
128
+
129
+ // Safe hook wrapper to handle cases where router context is not available
130
+ export const useRouterHooks = () => {
131
+ try {
132
+ const {
133
+ useLocation,
134
+ useNavigate,
135
+ useParams,
136
+ } = require('@modern-js/runtime/router');
137
+ return {
138
+ navigate: useNavigate(),
139
+ location: useLocation(),
140
+ params: useParams(),
141
+ hasRouter: true,
142
+ };
143
+ } catch (error) {
144
+ return {
145
+ navigate: null,
146
+ location: null,
147
+ params: {},
148
+ hasRouter: false,
149
+ };
150
+ }
151
+ };
@@ -0,0 +1,336 @@
1
+ import * as honoPkg from '@modern-js/server-core/hono';
2
+ const { languageDetector } = honoPkg;
3
+ import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime';
4
+ import {
5
+ DEFAULT_I18NEXT_DETECTION_OPTIONS,
6
+ mergeDetectionOptions,
7
+ } from '../runtime/i18n/detection/config.js';
8
+ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
9
+ import type { LocaleDetectionOptions } from '../shared/type';
10
+ import { getLocaleDetectionOptions } from '../shared/utils.js';
11
+
12
+ export interface I18nPluginOptions {
13
+ localeDetection: LocaleDetectionOptions;
14
+ staticRoutePrefixes: string[];
15
+ }
16
+
17
+ /**
18
+ * Convert i18next detection options to hono languageDetector options
19
+ */
20
+ const convertToHonoLanguageDetectorOptions = (
21
+ languages: string[],
22
+ fallbackLanguage: string,
23
+ detectionOptions?: LanguageDetectorOptions,
24
+ ) => {
25
+ // Merge user detection options with defaults
26
+ const mergedDetection = detectionOptions
27
+ ? mergeDetectionOptions(detectionOptions)
28
+ : DEFAULT_I18NEXT_DETECTION_OPTIONS;
29
+
30
+ // Get detection order, excluding 'path' and browser-only detectors
31
+ const order = (mergedDetection.order || []).filter(
32
+ (item: string) =>
33
+ !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes(
34
+ item,
35
+ ),
36
+ );
37
+
38
+ // If no order specified, use default server-side order
39
+ const detectionOrder =
40
+ order.length > 0 ? order : ['querystring', 'cookie', 'header'];
41
+
42
+ // Map i18next order to hono order
43
+ const honoOrder = detectionOrder.map(item => {
44
+ // Map 'querystring' to 'querystring', 'cookie' to 'cookie', 'header' to 'header'
45
+ if (item === 'querystring') return 'querystring';
46
+ if (item === 'cookie') return 'cookie';
47
+ if (item === 'header') return 'header';
48
+ return item;
49
+ }) as ('querystring' | 'cookie' | 'header' | 'path')[];
50
+
51
+ // Determine caches option
52
+ // hono languageDetector expects: false | "cookie"[] | undefined
53
+ const caches: false | ['cookie'] | undefined =
54
+ mergedDetection.caches === false
55
+ ? false
56
+ : Array.isArray(mergedDetection.caches) &&
57
+ !mergedDetection.caches.includes('cookie')
58
+ ? false
59
+ : (['cookie'] as ['cookie']);
60
+
61
+ return {
62
+ supportedLanguages: languages.length > 0 ? languages : [fallbackLanguage],
63
+ fallbackLanguage,
64
+ order: honoOrder,
65
+ lookupQueryString:
66
+ mergedDetection.lookupQuerystring ||
67
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring ||
68
+ 'lng',
69
+ lookupCookie:
70
+ mergedDetection.lookupCookie ||
71
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie ||
72
+ 'i18next',
73
+ lookupFromHeaderKey:
74
+ mergedDetection.lookupHeader ||
75
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader ||
76
+ 'accept-language',
77
+ ...(caches !== undefined && { caches }),
78
+ ignoreCase: true,
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Check if the given pathname should ignore automatic locale redirect
84
+ */
85
+ const shouldIgnoreRedirect = (
86
+ pathname: string,
87
+ urlPath: string,
88
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
89
+ ): boolean => {
90
+ if (!ignoreRedirectRoutes) {
91
+ return false;
92
+ }
93
+
94
+ // Remove urlPath prefix to get remaining path for matching
95
+ const basePath = urlPath.replace('/*', '');
96
+ const remainingPath = pathname.startsWith(basePath)
97
+ ? pathname.slice(basePath.length)
98
+ : pathname;
99
+
100
+ // Normalize path (ensure it starts with /)
101
+ const normalizedPath = remainingPath.startsWith('/')
102
+ ? remainingPath
103
+ : `/${remainingPath}`;
104
+
105
+ if (typeof ignoreRedirectRoutes === 'function') {
106
+ return ignoreRedirectRoutes(normalizedPath);
107
+ }
108
+
109
+ // Check if pathname matches any of the ignore patterns
110
+ return ignoreRedirectRoutes.some(pattern => {
111
+ // Support both exact match and prefix match
112
+ return (
113
+ normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
114
+ );
115
+ });
116
+ };
117
+
118
+ /**
119
+ * Check if the given pathname is a static resource request
120
+ * This includes:
121
+ * 1. Paths matching staticRoutePrefixes (from public directories)
122
+ * 2. Standard static resource paths like /static/, /upload/
123
+ * 3. Paths with language prefix like /en/static/, /zh/static/
124
+ */
125
+ const isStaticResourceRequest = (
126
+ pathname: string,
127
+ staticRoutePrefixes: string[],
128
+ languages: string[] = [],
129
+ ): boolean => {
130
+ // Check against staticRoutePrefixes (from public directories)
131
+ if (
132
+ staticRoutePrefixes.some(
133
+ prefix => pathname.startsWith(`${prefix}/`) || pathname === prefix,
134
+ )
135
+ ) {
136
+ return true;
137
+ }
138
+
139
+ // Check standard static resource paths
140
+ const standardStaticPrefixes = ['/static/', '/upload/'];
141
+ if (standardStaticPrefixes.some(prefix => pathname.startsWith(prefix))) {
142
+ return true;
143
+ }
144
+
145
+ // Check paths with language prefix (e.g., /en/static/, /zh/static/)
146
+ // Remove language prefix if present and check again
147
+ const pathSegments = pathname.split('/').filter(Boolean);
148
+ if (pathSegments.length > 0 && languages.includes(pathSegments[0])) {
149
+ // biome-ignore lint/style/useTemplate: <explanation>
150
+ const pathWithoutLang = '/' + pathSegments.slice(1).join('/');
151
+ if (
152
+ standardStaticPrefixes.some(prefix =>
153
+ pathWithoutLang.startsWith(prefix),
154
+ ) ||
155
+ staticRoutePrefixes.some(
156
+ prefix =>
157
+ pathWithoutLang.startsWith(`${prefix}/`) ||
158
+ pathWithoutLang === prefix,
159
+ )
160
+ ) {
161
+ return true;
162
+ }
163
+ }
164
+
165
+ return false;
166
+ };
167
+
168
+ const getLanguageFromPath = (
169
+ req: any,
170
+ urlPath: string,
171
+ languages: string[],
172
+ ): string | null => {
173
+ const url = new URL(req.url, `http://${req.header().host}`);
174
+ const pathname = url.pathname;
175
+
176
+ // Remove urlPath prefix to get remaining path
177
+ // urlPath format is /lang/*, need to remove /lang part
178
+ const basePath = urlPath.replace('/*', '');
179
+ const remainingPath = pathname.startsWith(basePath)
180
+ ? pathname.slice(basePath.length)
181
+ : pathname;
182
+
183
+ const segments = remainingPath.split('/').filter(Boolean);
184
+ const firstSegment = segments[0];
185
+
186
+ if (languages.includes(firstSegment)) {
187
+ return firstSegment;
188
+ }
189
+
190
+ return null;
191
+ };
192
+
193
+ const buildLocalizedUrl = (
194
+ req: any,
195
+ urlPath: string,
196
+ language: string,
197
+ languages: string[],
198
+ ): string => {
199
+ const url = new URL(req.url);
200
+ const pathname = url.pathname;
201
+
202
+ // Remove urlPath prefix to get remaining path
203
+ const basePath = urlPath.replace('/*', '');
204
+ const remainingPath = pathname.startsWith(basePath)
205
+ ? pathname.slice(basePath.length)
206
+ : pathname;
207
+
208
+ const segments = remainingPath.split('/').filter(Boolean);
209
+
210
+ if (segments.length > 0 && languages.includes(segments[0])) {
211
+ // Replace existing language prefix
212
+ segments[0] = language;
213
+ } else {
214
+ // If path doesn't start with language, add language prefix
215
+ segments.unshift(language);
216
+ }
217
+
218
+ const newPathname = `/${segments.join('/')}`;
219
+ // Handle root path case to avoid double slashes like //en
220
+ const suffix = `${url.search}${url.hash}`;
221
+ const localizedUrl =
222
+ basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
223
+
224
+ return localizedUrl;
225
+ };
226
+
227
+ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
228
+ name: '@modern-js/plugin-i18n/server',
229
+ setup: api => {
230
+ api.onPrepare(() => {
231
+ const { middlewares, routes } = api.getServerContext();
232
+ routes.map(route => {
233
+ const { entryName } = route;
234
+ if (!entryName) {
235
+ return;
236
+ }
237
+ if (!options.localeDetection) {
238
+ return;
239
+ }
240
+ const {
241
+ localePathRedirect,
242
+ i18nextDetector = true,
243
+ languages = [],
244
+ fallbackLanguage = 'en',
245
+ detection,
246
+ ignoreRedirectRoutes,
247
+ } = getLocaleDetectionOptions(entryName, options.localeDetection);
248
+ const staticRoutePrefixes = options.staticRoutePrefixes;
249
+ const originUrlPath = route.urlPath;
250
+ const urlPath = originUrlPath.endsWith('/')
251
+ ? `${originUrlPath}*`
252
+ : `${originUrlPath}/*`;
253
+ if (localePathRedirect) {
254
+ // Add languageDetector middleware before the redirect handler
255
+ if (i18nextDetector) {
256
+ const detectorOptions = convertToHonoLanguageDetectorOptions(
257
+ languages,
258
+ fallbackLanguage,
259
+ detection,
260
+ );
261
+ const detectorHandler = languageDetector(detectorOptions);
262
+ middlewares.push({
263
+ name: 'i18n-language-detector',
264
+ path: urlPath,
265
+ handler: async (c: Context, next: Next) => {
266
+ const url = new URL(c.req.url);
267
+ const pathname = url.pathname;
268
+
269
+ // For static resource requests, skip language detection
270
+ if (
271
+ isStaticResourceRequest(
272
+ pathname,
273
+ staticRoutePrefixes,
274
+ languages,
275
+ )
276
+ ) {
277
+ return await next();
278
+ }
279
+
280
+ return detectorHandler(c, next);
281
+ },
282
+ });
283
+ }
284
+
285
+ middlewares.push({
286
+ name: 'i18n-server-middleware',
287
+ path: urlPath,
288
+ handler: async (c: Context, next: Next) => {
289
+ const url = new URL(c.req.url);
290
+ const pathname = url.pathname;
291
+
292
+ // For static resource requests, skip i18n processing
293
+ if (
294
+ isStaticResourceRequest(
295
+ pathname,
296
+ staticRoutePrefixes,
297
+ languages,
298
+ )
299
+ ) {
300
+ return await next();
301
+ }
302
+
303
+ // Check if this route should ignore automatic redirect
304
+ if (
305
+ shouldIgnoreRedirect(pathname, urlPath, ignoreRedirectRoutes)
306
+ ) {
307
+ return await next();
308
+ }
309
+
310
+ const language = getLanguageFromPath(c.req, urlPath, languages);
311
+ if (!language) {
312
+ // Get detected language from languageDetector middleware
313
+ let detectedLanguage: string | null = null;
314
+ if (i18nextDetector) {
315
+ detectedLanguage = c.get('language') || null;
316
+ }
317
+ // Use detected language or fallback to fallbackLanguage
318
+ const targetLanguage = detectedLanguage || fallbackLanguage;
319
+ const localizedUrl = buildLocalizedUrl(
320
+ c.req,
321
+ originUrlPath,
322
+ targetLanguage,
323
+ languages,
324
+ );
325
+ return c.redirect(localizedUrl);
326
+ }
327
+ await next();
328
+ },
329
+ });
330
+ }
331
+ });
332
+ });
333
+ },
334
+ });
335
+
336
+ export default i18nServerPlugin;
@@ -0,0 +1,38 @@
1
+ function isPlainObject(value: any): boolean {
2
+ return (
3
+ value !== null &&
4
+ typeof value === 'object' &&
5
+ !Array.isArray(value) &&
6
+ !(value instanceof Date)
7
+ );
8
+ }
9
+
10
+ export function deepMerge<T extends Record<string, any>>(
11
+ defaultOptions: T,
12
+ userOptions?: Partial<T>,
13
+ ): T {
14
+ if (!userOptions) {
15
+ return defaultOptions;
16
+ }
17
+
18
+ const merged: Record<string, any> = { ...defaultOptions };
19
+
20
+ for (const key in userOptions) {
21
+ const userValue = userOptions[key];
22
+ if (userValue === undefined) {
23
+ continue;
24
+ }
25
+
26
+ const defaultValue = merged[key];
27
+ const isUserValueObject = isPlainObject(userValue);
28
+ const isDefaultValueObject = isPlainObject(defaultValue);
29
+
30
+ if (isUserValueObject && isDefaultValueObject) {
31
+ merged[key] = deepMerge(defaultValue, userValue);
32
+ } else {
33
+ merged[key] = userValue;
34
+ }
35
+ }
36
+
37
+ return merged as T;
38
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ DEFAULT_I18NEXT_DETECTION_OPTIONS,
3
+ mergeDetectionOptions,
4
+ } from '../runtime/i18n/detection/config.js';
5
+ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
6
+
7
+ /**
8
+ * Detect language from request using the same detection logic as i18next
9
+ * This ensures consistency between server-side and client-side detection
10
+ */
11
+ export function detectLanguageFromRequest(
12
+ req: {
13
+ url: string;
14
+ headers:
15
+ | {
16
+ get: (name: string) => string | null;
17
+ }
18
+ | Headers;
19
+ },
20
+ languages: string[],
21
+ detectionOptions?: LanguageDetectorOptions,
22
+ ): string | null {
23
+ try {
24
+ // Merge user detection options with defaults
25
+ const mergedDetection = detectionOptions
26
+ ? mergeDetectionOptions(detectionOptions)
27
+ : DEFAULT_I18NEXT_DETECTION_OPTIONS;
28
+
29
+ // Get detection order, excluding 'path' and browser-only detectors
30
+ const order = (mergedDetection.order || []).filter(
31
+ (item: string) =>
32
+ !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes(
33
+ item,
34
+ ),
35
+ );
36
+
37
+ // If no order specified, use default server-side order
38
+ const detectionOrder =
39
+ order.length > 0 ? order : ['querystring', 'cookie', 'header'];
40
+
41
+ // Helper to get header value
42
+ const getHeader = (name: string): string | null => {
43
+ if (req.headers instanceof Headers) {
44
+ return req.headers.get(name);
45
+ }
46
+ return req.headers.get(name);
47
+ };
48
+
49
+ // Try each detection method in order
50
+ for (const method of detectionOrder) {
51
+ let detectedLang: string | null = null;
52
+
53
+ switch (method) {
54
+ case 'querystring': {
55
+ const lookupKey =
56
+ mergedDetection.lookupQuerystring ||
57
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring ||
58
+ 'lng';
59
+ const host = getHeader('host') || 'localhost';
60
+ const url = new URL(req.url, `http://${host}`);
61
+ detectedLang = url.searchParams.get(lookupKey);
62
+ break;
63
+ }
64
+ case 'cookie': {
65
+ const lookupKey =
66
+ mergedDetection.lookupCookie ||
67
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie ||
68
+ 'i18next';
69
+ const cookieHeader = getHeader('Cookie');
70
+ if (cookieHeader) {
71
+ const cookies = cookieHeader
72
+ .split(';')
73
+ .reduce((acc: Record<string, string>, item: string) => {
74
+ const [key, value] = item.trim().split('=');
75
+ if (key && value) {
76
+ acc[key] = value;
77
+ }
78
+ return acc;
79
+ }, {});
80
+ detectedLang = cookies[lookupKey] || null;
81
+ }
82
+ break;
83
+ }
84
+ case 'header': {
85
+ const lookupKey =
86
+ mergedDetection.lookupHeader ||
87
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader ||
88
+ 'accept-language';
89
+ const acceptLanguage = getHeader(lookupKey);
90
+ if (acceptLanguage) {
91
+ // Parse Accept-Language header: "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"
92
+ const languagesList = acceptLanguage
93
+ .split(',')
94
+ .map((lang: string) => {
95
+ const [code, q] = lang.trim().split(';');
96
+ return {
97
+ code: code.split('-')[0], // Extract base language code
98
+ quality: q ? parseFloat(q.split('=')[1]) : 1.0,
99
+ };
100
+ })
101
+ .sort(
102
+ (a: { quality: number }, b: { quality: number }) =>
103
+ b.quality - a.quality,
104
+ );
105
+
106
+ // Find first matching language
107
+ for (const lang of languagesList) {
108
+ if (languages.length === 0 || languages.includes(lang.code)) {
109
+ detectedLang = lang.code;
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ break;
115
+ }
116
+ }
117
+
118
+ // If detected and valid, return it
119
+ if (
120
+ detectedLang &&
121
+ (languages.length === 0 || languages.includes(detectedLang))
122
+ ) {
123
+ return detectedLang;
124
+ }
125
+ }
126
+ } catch (error) {
127
+ // Silently ignore errors
128
+ }
129
+
130
+ return null;
131
+ }