@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.0

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 (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/cjs/cli/index.js +154 -0
  4. package/dist/cjs/runtime/I18nLink.js +68 -0
  5. package/dist/cjs/runtime/context.js +142 -0
  6. package/dist/cjs/runtime/hooks.js +194 -0
  7. package/dist/cjs/runtime/i18n/backend/config.js +39 -0
  8. package/dist/cjs/runtime/i18n/backend/defaults.js +56 -0
  9. package/dist/cjs/runtime/i18n/backend/defaults.node.js +56 -0
  10. package/dist/cjs/runtime/i18n/backend/index.js +108 -0
  11. package/dist/cjs/runtime/i18n/backend/middleware.common.js +105 -0
  12. package/dist/cjs/runtime/i18n/backend/middleware.js +54 -0
  13. package/dist/cjs/runtime/i18n/backend/middleware.node.js +58 -0
  14. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +175 -0
  15. package/dist/cjs/runtime/i18n/backend/sdk-event.js +64 -0
  16. package/dist/cjs/runtime/i18n/detection/config.js +63 -0
  17. package/dist/cjs/runtime/i18n/detection/index.js +309 -0
  18. package/dist/cjs/runtime/i18n/detection/middleware.js +185 -0
  19. package/dist/cjs/runtime/i18n/detection/middleware.node.js +74 -0
  20. package/dist/cjs/runtime/i18n/index.js +43 -0
  21. package/dist/cjs/runtime/i18n/instance.js +132 -0
  22. package/dist/cjs/runtime/i18n/utils.js +189 -0
  23. package/dist/cjs/runtime/index.js +174 -0
  24. package/dist/cjs/runtime/types.js +18 -0
  25. package/dist/cjs/runtime/utils.js +136 -0
  26. package/dist/cjs/server/index.js +218 -0
  27. package/dist/cjs/shared/deepMerge.js +54 -0
  28. package/dist/cjs/shared/detection.js +105 -0
  29. package/dist/cjs/shared/type.js +18 -0
  30. package/dist/cjs/shared/utils.js +78 -0
  31. package/dist/esm/cli/index.mjs +107 -0
  32. package/dist/esm/rslib-runtime.mjs +18 -0
  33. package/dist/esm/runtime/I18nLink.mjs +32 -0
  34. package/dist/esm/runtime/context.mjs +105 -0
  35. package/dist/esm/runtime/hooks.mjs +151 -0
  36. package/dist/esm/runtime/i18n/backend/config.mjs +5 -0
  37. package/dist/esm/runtime/i18n/backend/defaults.mjs +19 -0
  38. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +19 -0
  39. package/dist/esm/runtime/i18n/backend/index.mjs +74 -0
  40. package/dist/esm/runtime/i18n/backend/middleware.common.mjs +61 -0
  41. package/dist/esm/runtime/i18n/backend/middleware.mjs +7 -0
  42. package/dist/esm/runtime/i18n/backend/middleware.node.mjs +8 -0
  43. package/dist/esm/runtime/i18n/backend/sdk-backend.mjs +141 -0
  44. package/dist/esm/runtime/i18n/backend/sdk-event.mjs +21 -0
  45. package/dist/esm/runtime/i18n/detection/config.mjs +26 -0
  46. package/dist/esm/runtime/i18n/detection/index.mjs +260 -0
  47. package/dist/esm/runtime/i18n/detection/middleware.mjs +132 -0
  48. package/dist/esm/runtime/i18n/detection/middleware.node.mjs +31 -0
  49. package/dist/esm/runtime/i18n/index.mjs +2 -0
  50. package/dist/esm/runtime/i18n/instance.mjs +77 -0
  51. package/dist/esm/runtime/i18n/utils.mjs +140 -0
  52. package/dist/esm/runtime/index.mjs +132 -0
  53. package/dist/esm/runtime/types.mjs +0 -0
  54. package/dist/esm/runtime/utils.mjs +75 -0
  55. package/dist/esm/server/index.mjs +182 -0
  56. package/dist/esm/shared/deepMerge.mjs +20 -0
  57. package/dist/esm/shared/detection.mjs +71 -0
  58. package/dist/esm/shared/type.mjs +0 -0
  59. package/dist/esm/shared/utils.mjs +35 -0
  60. package/dist/esm-node/cli/index.mjs +108 -0
  61. package/dist/esm-node/rslib-runtime.mjs +19 -0
  62. package/dist/esm-node/runtime/I18nLink.mjs +33 -0
  63. package/dist/esm-node/runtime/context.mjs +106 -0
  64. package/dist/esm-node/runtime/hooks.mjs +152 -0
  65. package/dist/esm-node/runtime/i18n/backend/config.mjs +6 -0
  66. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +20 -0
  67. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +20 -0
  68. package/dist/esm-node/runtime/i18n/backend/index.mjs +75 -0
  69. package/dist/esm-node/runtime/i18n/backend/middleware.common.mjs +62 -0
  70. package/dist/esm-node/runtime/i18n/backend/middleware.mjs +8 -0
  71. package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +9 -0
  72. package/dist/esm-node/runtime/i18n/backend/sdk-backend.mjs +142 -0
  73. package/dist/esm-node/runtime/i18n/backend/sdk-event.mjs +22 -0
  74. package/dist/esm-node/runtime/i18n/detection/config.mjs +27 -0
  75. package/dist/esm-node/runtime/i18n/detection/index.mjs +261 -0
  76. package/dist/esm-node/runtime/i18n/detection/middleware.mjs +133 -0
  77. package/dist/esm-node/runtime/i18n/detection/middleware.node.mjs +32 -0
  78. package/dist/esm-node/runtime/i18n/index.mjs +3 -0
  79. package/dist/esm-node/runtime/i18n/instance.mjs +78 -0
  80. package/dist/esm-node/runtime/i18n/utils.mjs +141 -0
  81. package/dist/esm-node/runtime/index.mjs +133 -0
  82. package/dist/esm-node/runtime/types.mjs +1 -0
  83. package/dist/esm-node/runtime/utils.mjs +76 -0
  84. package/dist/esm-node/server/index.mjs +183 -0
  85. package/dist/esm-node/shared/deepMerge.mjs +21 -0
  86. package/dist/esm-node/shared/detection.mjs +72 -0
  87. package/dist/esm-node/shared/type.mjs +1 -0
  88. package/dist/esm-node/shared/utils.mjs +36 -0
  89. package/dist/types/cli/index.d.ts +21 -0
  90. package/dist/types/runtime/I18nLink.d.ts +8 -0
  91. package/dist/types/runtime/context.d.ts +38 -0
  92. package/dist/types/runtime/hooks.d.ts +28 -0
  93. package/dist/types/runtime/i18n/backend/config.d.ts +2 -0
  94. package/dist/types/runtime/i18n/backend/defaults.d.ts +13 -0
  95. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +8 -0
  96. package/dist/types/runtime/i18n/backend/index.d.ts +3 -0
  97. package/dist/types/runtime/i18n/backend/middleware.common.d.ts +14 -0
  98. package/dist/types/runtime/i18n/backend/middleware.d.ts +12 -0
  99. package/dist/types/runtime/i18n/backend/middleware.node.d.ts +13 -0
  100. package/dist/types/runtime/i18n/backend/sdk-backend.d.ts +53 -0
  101. package/dist/types/runtime/i18n/backend/sdk-event.d.ts +9 -0
  102. package/dist/types/runtime/i18n/detection/config.d.ts +11 -0
  103. package/dist/types/runtime/i18n/detection/index.d.ts +50 -0
  104. package/dist/types/runtime/i18n/detection/middleware.d.ts +24 -0
  105. package/dist/types/runtime/i18n/detection/middleware.node.d.ts +17 -0
  106. package/dist/types/runtime/i18n/index.d.ts +3 -0
  107. package/dist/types/runtime/i18n/instance.d.ts +93 -0
  108. package/dist/types/runtime/i18n/utils.d.ts +29 -0
  109. package/dist/types/runtime/index.d.ts +20 -0
  110. package/dist/types/runtime/types.d.ts +15 -0
  111. package/dist/types/runtime/utils.d.ts +33 -0
  112. package/dist/types/server/index.d.ts +8 -0
  113. package/dist/types/shared/deepMerge.d.ts +1 -0
  114. package/dist/types/shared/detection.d.ts +11 -0
  115. package/dist/types/shared/type.d.ts +156 -0
  116. package/dist/types/shared/utils.d.ts +5 -0
  117. package/package.json +136 -0
  118. package/rslib.config.mts +4 -0
  119. package/src/cli/index.ts +245 -0
  120. package/src/runtime/I18nLink.tsx +76 -0
  121. package/src/runtime/context.tsx +281 -0
  122. package/src/runtime/hooks.ts +298 -0
  123. package/src/runtime/i18n/backend/config.ts +10 -0
  124. package/src/runtime/i18n/backend/defaults.node.ts +31 -0
  125. package/src/runtime/i18n/backend/defaults.ts +37 -0
  126. package/src/runtime/i18n/backend/index.ts +181 -0
  127. package/src/runtime/i18n/backend/middleware.common.ts +116 -0
  128. package/src/runtime/i18n/backend/middleware.node.ts +32 -0
  129. package/src/runtime/i18n/backend/middleware.ts +28 -0
  130. package/src/runtime/i18n/backend/sdk-backend.ts +306 -0
  131. package/src/runtime/i18n/backend/sdk-event.ts +39 -0
  132. package/src/runtime/i18n/detection/config.ts +32 -0
  133. package/src/runtime/i18n/detection/index.ts +641 -0
  134. package/src/runtime/i18n/detection/middleware.node.ts +84 -0
  135. package/src/runtime/i18n/detection/middleware.ts +251 -0
  136. package/src/runtime/i18n/index.ts +8 -0
  137. package/src/runtime/i18n/instance.ts +227 -0
  138. package/src/runtime/i18n/utils.ts +351 -0
  139. package/src/runtime/index.tsx +285 -0
  140. package/src/runtime/types.ts +17 -0
  141. package/src/runtime/utils.ts +163 -0
  142. package/src/server/index.ts +406 -0
  143. package/src/shared/deepMerge.ts +38 -0
  144. package/src/shared/detection.ts +131 -0
  145. package/src/shared/type.ts +170 -0
  146. package/src/shared/utils.ts +82 -0
  147. package/tsconfig.json +13 -0
@@ -0,0 +1,163 @@
1
+ import { isBrowser } from '@modern-js/runtime';
2
+ import {
3
+ getGlobalBasename,
4
+ type TInternalRuntimeContext,
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 entryPath = getEntryPath();
81
+ const relativePath = pathname.replace(entryPath, '');
82
+ const segments = relativePath.split('/').filter(Boolean);
83
+
84
+ // If entryPath is empty and first segment is not a language,
85
+ // it might be an entry path (e.g., /lang/en -> lang is entry, en is language)
86
+ const segmentsToCheck =
87
+ !entryPath &&
88
+ segments.length > 1 &&
89
+ segments[0] &&
90
+ !languages.includes(segments[0])
91
+ ? segments.slice(1) // Skip the first segment (entry path) and check the second segment
92
+ : segments;
93
+
94
+ const firstSegment = segmentsToCheck[0];
95
+
96
+ if (firstSegment && languages.includes(firstSegment)) {
97
+ return { detected: true, language: firstSegment };
98
+ }
99
+
100
+ return { detected: false };
101
+ };
102
+
103
+ /**
104
+ * Check if the given pathname should ignore automatic locale redirect
105
+ */
106
+ export const shouldIgnoreRedirect = (
107
+ pathname: string,
108
+ languages: string[],
109
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
110
+ ): boolean => {
111
+ if (!ignoreRedirectRoutes) {
112
+ return false;
113
+ }
114
+
115
+ // Remove language prefix if present (e.g., /en/api -> /api)
116
+ const segments = pathname.split('/').filter(Boolean);
117
+ let pathWithoutLang = pathname;
118
+ if (segments.length > 0 && languages.includes(segments[0])) {
119
+ // Remove language prefix
120
+ pathWithoutLang = `/${segments.slice(1).join('/')}`;
121
+ }
122
+
123
+ // Normalize path (ensure it starts with /)
124
+ const normalizedPath = pathWithoutLang.startsWith('/')
125
+ ? pathWithoutLang
126
+ : `/${pathWithoutLang}`;
127
+
128
+ if (typeof ignoreRedirectRoutes === 'function') {
129
+ return ignoreRedirectRoutes(normalizedPath);
130
+ }
131
+
132
+ // Check if pathname matches any of the ignore patterns
133
+ return ignoreRedirectRoutes.some(pattern => {
134
+ // Support both exact match and prefix match
135
+ return (
136
+ normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
137
+ );
138
+ });
139
+ };
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
+ };
@@ -0,0 +1,406 @@
1
+ import * as honoPkg from '@modern-js/server-core/hono';
2
+
3
+ const { languageDetector } = honoPkg;
4
+
5
+ import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime';
6
+ import {
7
+ DEFAULT_I18NEXT_DETECTION_OPTIONS,
8
+ mergeDetectionOptions,
9
+ } from '../runtime/i18n/detection/config.js';
10
+ import type { LanguageDetectorOptions } from '../runtime/i18n/instance';
11
+ import type { LocaleDetectionOptions } from '../shared/type';
12
+ import { getLocaleDetectionOptions } from '../shared/utils.js';
13
+
14
+ export interface I18nPluginOptions {
15
+ localeDetection: LocaleDetectionOptions;
16
+ staticRoutePrefixes: string[];
17
+ }
18
+
19
+ /**
20
+ * Convert i18next detection options to hono languageDetector options
21
+ */
22
+ const convertToHonoLanguageDetectorOptions = (
23
+ languages: string[],
24
+ fallbackLanguage: string,
25
+ detectionOptions?: LanguageDetectorOptions,
26
+ ) => {
27
+ // Merge user detection options with defaults
28
+ const mergedDetection = detectionOptions
29
+ ? mergeDetectionOptions(detectionOptions)
30
+ : DEFAULT_I18NEXT_DETECTION_OPTIONS;
31
+
32
+ // Get detection order, excluding 'path' and browser-only detectors
33
+ const order = (mergedDetection.order || []).filter(
34
+ (item: string) =>
35
+ !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes(
36
+ item,
37
+ ),
38
+ );
39
+
40
+ // If no order specified, use default server-side order
41
+ const detectionOrder =
42
+ order.length > 0 ? order : ['querystring', 'cookie', 'header'];
43
+
44
+ // Map i18next order to hono order
45
+ const honoOrder = detectionOrder.map(item => {
46
+ // Map 'querystring' to 'querystring', 'cookie' to 'cookie', 'header' to 'header'
47
+ if (item === 'querystring') return 'querystring';
48
+ if (item === 'cookie') return 'cookie';
49
+ if (item === 'header') return 'header';
50
+ return item;
51
+ }) as ('querystring' | 'cookie' | 'header' | 'path')[];
52
+
53
+ // Determine caches option
54
+ // hono languageDetector expects: false | "cookie"[] | undefined
55
+ const caches: false | ['cookie'] | undefined =
56
+ mergedDetection.caches === false
57
+ ? false
58
+ : Array.isArray(mergedDetection.caches) &&
59
+ !mergedDetection.caches.includes('cookie')
60
+ ? false
61
+ : (['cookie'] as ['cookie']);
62
+
63
+ const cookieMinutes = (mergedDetection as Record<string, unknown>)
64
+ .cookieMinutes;
65
+ const cookieMaxAge =
66
+ typeof cookieMinutes === 'number' && Number.isFinite(cookieMinutes)
67
+ ? Math.max(0, Math.floor(cookieMinutes * 60))
68
+ : DEFAULT_I18NEXT_DETECTION_OPTIONS.cookieMinutes * 60;
69
+
70
+ const cookieDomain = (mergedDetection as Record<string, unknown>)
71
+ .cookieDomain;
72
+ const cookieSecure = (mergedDetection as Record<string, unknown>)
73
+ .cookieSecure;
74
+ const cookieHttpOnly = (mergedDetection as Record<string, unknown>)
75
+ .cookieHttpOnly;
76
+ const cookieSameSite = (mergedDetection as Record<string, unknown>)
77
+ .cookieSameSite;
78
+
79
+ const normalizedCookieDomain =
80
+ typeof cookieDomain === 'string' ? cookieDomain : undefined;
81
+
82
+ // Keep cookie defaults aligned with i18next language detector behavior:
83
+ // language cookie should be readable from browser-side detector.
84
+ const cookieOptions = {
85
+ maxAge: cookieMaxAge,
86
+ sameSite:
87
+ cookieSameSite === 'None' || cookieSameSite === 'none'
88
+ ? ('None' as const)
89
+ : cookieSameSite === 'Lax' || cookieSameSite === 'lax'
90
+ ? ('Lax' as const)
91
+ : ('Strict' as const),
92
+ secure: typeof cookieSecure === 'boolean' ? cookieSecure : false,
93
+ httpOnly: typeof cookieHttpOnly === 'boolean' ? cookieHttpOnly : false,
94
+ ...(normalizedCookieDomain ? { domain: normalizedCookieDomain } : {}),
95
+ };
96
+
97
+ return {
98
+ supportedLanguages: languages.length > 0 ? languages : [fallbackLanguage],
99
+ fallbackLanguage,
100
+ order: honoOrder,
101
+ lookupQueryString:
102
+ mergedDetection.lookupQuerystring ||
103
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring ||
104
+ 'lng',
105
+ lookupCookie:
106
+ mergedDetection.lookupCookie ||
107
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie ||
108
+ 'i18next',
109
+ lookupFromHeaderKey:
110
+ mergedDetection.lookupHeader ||
111
+ DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader ||
112
+ 'accept-language',
113
+ ...(caches !== undefined && { caches }),
114
+ ...(caches !== false && { cookieOptions }),
115
+ ignoreCase: true,
116
+ };
117
+ };
118
+
119
+ /**
120
+ * Check if the given pathname should ignore automatic locale redirect
121
+ */
122
+ const shouldIgnoreRedirect = (
123
+ pathname: string,
124
+ urlPath: string,
125
+ ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean),
126
+ ): boolean => {
127
+ if (!ignoreRedirectRoutes) {
128
+ return false;
129
+ }
130
+
131
+ // Remove urlPath prefix to get remaining path for matching
132
+ const basePath = urlPath.replace('/*', '');
133
+ const remainingPath = pathname.startsWith(basePath)
134
+ ? pathname.slice(basePath.length)
135
+ : pathname;
136
+
137
+ // Normalize path (ensure it starts with /)
138
+ const normalizedPath = remainingPath.startsWith('/')
139
+ ? remainingPath
140
+ : `/${remainingPath}`;
141
+
142
+ if (typeof ignoreRedirectRoutes === 'function') {
143
+ return ignoreRedirectRoutes(normalizedPath);
144
+ }
145
+
146
+ // Check if pathname matches any of the ignore patterns
147
+ return ignoreRedirectRoutes.some(pattern => {
148
+ // Support both exact match and prefix match
149
+ return (
150
+ normalizedPath === pattern || normalizedPath.startsWith(`${pattern}/`)
151
+ );
152
+ });
153
+ };
154
+
155
+ /**
156
+ * Check if the given pathname is a static resource request
157
+ * This includes:
158
+ * 1. Paths matching staticRoutePrefixes (from public directories)
159
+ * 2. Standard static resource paths like /static/, /upload/
160
+ * 3. Paths with language prefix like /en/static/, /zh/static/
161
+ */
162
+ const isStaticResourceRequest = (
163
+ pathname: string,
164
+ staticRoutePrefixes: string[],
165
+ languages: string[] = [],
166
+ ): boolean => {
167
+ // Check against staticRoutePrefixes (from public directories)
168
+ if (
169
+ staticRoutePrefixes.some(
170
+ prefix => pathname.startsWith(`${prefix}/`) || pathname === prefix,
171
+ )
172
+ ) {
173
+ return true;
174
+ }
175
+
176
+ // Check standard static resource paths
177
+ const standardStaticPrefixes = ['/static/', '/upload/'];
178
+ if (standardStaticPrefixes.some(prefix => pathname.startsWith(prefix))) {
179
+ return true;
180
+ }
181
+
182
+ // Check paths with language prefix (e.g., /en/static/, /zh/static/)
183
+ // Remove language prefix if present and check again
184
+ const pathSegments = pathname.split('/').filter(Boolean);
185
+ if (pathSegments.length > 0 && languages.includes(pathSegments[0])) {
186
+ const pathWithoutLang = '/' + pathSegments.slice(1).join('/');
187
+ if (
188
+ standardStaticPrefixes.some(prefix =>
189
+ pathWithoutLang.startsWith(prefix),
190
+ ) ||
191
+ staticRoutePrefixes.some(
192
+ prefix =>
193
+ pathWithoutLang.startsWith(`${prefix}/`) ||
194
+ pathWithoutLang === prefix,
195
+ )
196
+ ) {
197
+ return true;
198
+ }
199
+ }
200
+
201
+ return false;
202
+ };
203
+
204
+ const getLanguageFromPath = (
205
+ req: any,
206
+ urlPath: string,
207
+ languages: string[],
208
+ ): string | null => {
209
+ const url = new URL(req.url, `http://${req.header().host}`);
210
+ const pathname = url.pathname;
211
+
212
+ // Remove urlPath prefix to get remaining path
213
+ // urlPath format is /lang/*, need to remove /lang part
214
+ const basePath = urlPath.replace('/*', '');
215
+ const remainingPath = pathname.startsWith(basePath)
216
+ ? pathname.slice(basePath.length)
217
+ : pathname;
218
+
219
+ const segments = remainingPath.split('/').filter(Boolean);
220
+ const firstSegment = segments[0];
221
+
222
+ if (languages.includes(firstSegment)) {
223
+ return firstSegment;
224
+ }
225
+
226
+ return null;
227
+ };
228
+
229
+ const buildLocalizedUrl = (
230
+ req: any,
231
+ urlPath: string,
232
+ language: string,
233
+ languages: string[],
234
+ ): string => {
235
+ const url = new URL(req.url);
236
+ const pathname = url.pathname;
237
+
238
+ // Remove urlPath prefix to get remaining path
239
+ const basePath = urlPath.replace('/*', '');
240
+ const remainingPath = pathname.startsWith(basePath)
241
+ ? pathname.slice(basePath.length)
242
+ : pathname;
243
+
244
+ const segments = remainingPath.split('/').filter(Boolean);
245
+
246
+ if (segments.length > 0 && languages.includes(segments[0])) {
247
+ // Replace existing language prefix
248
+ segments[0] = language;
249
+ } else {
250
+ // If path doesn't start with language, add language prefix
251
+ segments.unshift(language);
252
+ }
253
+
254
+ const newPathname = `/${segments.join('/')}`;
255
+ // Handle root path case to avoid double slashes like //en
256
+ const suffix = `${url.search}${url.hash}`;
257
+ const localizedUrl =
258
+ basePath === '/' ? newPathname + suffix : basePath + newPathname + suffix;
259
+
260
+ return localizedUrl;
261
+ };
262
+
263
+ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({
264
+ name: '@modern-js/plugin-i18n/server',
265
+ setup: api => {
266
+ api.onPrepare(() => {
267
+ const { middlewares, routes } = api.getServerContext();
268
+
269
+ // Collect all non-root entry paths for cross-entry path detection
270
+ const entryPaths = new Set<string>();
271
+ routes.forEach(route => {
272
+ if (route.entryName && route.urlPath && route.urlPath !== '/') {
273
+ const pathSegments = route.urlPath.split('/').filter(Boolean);
274
+ if (pathSegments.length > 0) {
275
+ entryPaths.add(`/${pathSegments[0]}`);
276
+ }
277
+ }
278
+ });
279
+
280
+ routes.map(route => {
281
+ const { entryName } = route;
282
+ if (!entryName) {
283
+ return;
284
+ }
285
+ if (!options.localeDetection) {
286
+ return;
287
+ }
288
+ const {
289
+ localePathRedirect,
290
+ i18nextDetector = true,
291
+ languages = [],
292
+ fallbackLanguage = 'en',
293
+ detection,
294
+ ignoreRedirectRoutes,
295
+ } = getLocaleDetectionOptions(entryName, options.localeDetection);
296
+ const staticRoutePrefixes = options.staticRoutePrefixes;
297
+ const originUrlPath = route.urlPath;
298
+ const urlPath = originUrlPath.endsWith('/')
299
+ ? `${originUrlPath}*`
300
+ : `${originUrlPath}/*`;
301
+ if (localePathRedirect) {
302
+ // Add languageDetector middleware before the redirect handler
303
+ if (i18nextDetector) {
304
+ const detectorOptions = convertToHonoLanguageDetectorOptions(
305
+ languages,
306
+ fallbackLanguage,
307
+ detection,
308
+ );
309
+ const detectorHandler = languageDetector(detectorOptions);
310
+ middlewares.push({
311
+ name: 'i18n-language-detector',
312
+ path: urlPath,
313
+ handler: async (c: Context, next: Next) => {
314
+ const url = new URL(c.req.url);
315
+ const pathname = url.pathname;
316
+
317
+ // For static resource requests, skip language detection
318
+ if (
319
+ isStaticResourceRequest(
320
+ pathname,
321
+ staticRoutePrefixes,
322
+ languages,
323
+ )
324
+ ) {
325
+ return await next();
326
+ }
327
+
328
+ // If basePath is '/', check if path belongs to another entry
329
+ if (originUrlPath === '/') {
330
+ const pathSegments = pathname.split('/').filter(Boolean);
331
+ if (pathSegments.length > 0) {
332
+ const firstSegment = `/${pathSegments[0]}`;
333
+ if (entryPaths.has(firstSegment)) {
334
+ return await next();
335
+ }
336
+ }
337
+ }
338
+
339
+ return detectorHandler(c, next);
340
+ },
341
+ });
342
+ }
343
+
344
+ middlewares.push({
345
+ name: 'i18n-server-middleware',
346
+ path: urlPath,
347
+ handler: async (c: Context, next: Next) => {
348
+ const url = new URL(c.req.url);
349
+ const pathname = url.pathname;
350
+
351
+ // For static resource requests, skip i18n processing
352
+ if (
353
+ isStaticResourceRequest(
354
+ pathname,
355
+ staticRoutePrefixes,
356
+ languages,
357
+ )
358
+ ) {
359
+ return await next();
360
+ }
361
+
362
+ // Check if this route should ignore automatic redirect
363
+ if (
364
+ shouldIgnoreRedirect(pathname, urlPath, ignoreRedirectRoutes)
365
+ ) {
366
+ return await next();
367
+ }
368
+
369
+ // If basePath is '/', check if path belongs to another entry
370
+ if (originUrlPath === '/') {
371
+ const pathSegments = pathname.split('/').filter(Boolean);
372
+ if (pathSegments.length > 0) {
373
+ const firstSegment = `/${pathSegments[0]}`;
374
+ if (entryPaths.has(firstSegment)) {
375
+ return await next();
376
+ }
377
+ }
378
+ }
379
+
380
+ const language = getLanguageFromPath(c.req, urlPath, languages);
381
+ if (!language) {
382
+ // Get detected language from languageDetector middleware
383
+ let detectedLanguage: string | null = null;
384
+ if (i18nextDetector) {
385
+ detectedLanguage = c.get('language') || null;
386
+ }
387
+ // Use detected language or fallback to fallbackLanguage
388
+ const targetLanguage = detectedLanguage || fallbackLanguage;
389
+ const localizedUrl = buildLocalizedUrl(
390
+ c.req,
391
+ originUrlPath,
392
+ targetLanguage,
393
+ languages,
394
+ );
395
+ return c.redirect(localizedUrl);
396
+ }
397
+ await next();
398
+ },
399
+ });
400
+ }
401
+ });
402
+ });
403
+ },
404
+ });
405
+
406
+ 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
+ }