@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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/cjs/cli/index.js +154 -0
- package/dist/cjs/runtime/I18nLink.js +68 -0
- package/dist/cjs/runtime/context.js +142 -0
- package/dist/cjs/runtime/hooks.js +194 -0
- package/dist/cjs/runtime/i18n/backend/config.js +39 -0
- package/dist/cjs/runtime/i18n/backend/defaults.js +56 -0
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +56 -0
- package/dist/cjs/runtime/i18n/backend/index.js +108 -0
- package/dist/cjs/runtime/i18n/backend/middleware.common.js +105 -0
- package/dist/cjs/runtime/i18n/backend/middleware.js +54 -0
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +58 -0
- package/dist/cjs/runtime/i18n/backend/sdk-backend.js +175 -0
- package/dist/cjs/runtime/i18n/backend/sdk-event.js +64 -0
- package/dist/cjs/runtime/i18n/detection/config.js +63 -0
- package/dist/cjs/runtime/i18n/detection/index.js +309 -0
- package/dist/cjs/runtime/i18n/detection/middleware.js +185 -0
- package/dist/cjs/runtime/i18n/detection/middleware.node.js +74 -0
- package/dist/cjs/runtime/i18n/index.js +43 -0
- package/dist/cjs/runtime/i18n/instance.js +132 -0
- package/dist/cjs/runtime/i18n/utils.js +189 -0
- package/dist/cjs/runtime/index.js +174 -0
- package/dist/cjs/runtime/types.js +18 -0
- package/dist/cjs/runtime/utils.js +136 -0
- package/dist/cjs/server/index.js +218 -0
- package/dist/cjs/shared/deepMerge.js +54 -0
- package/dist/cjs/shared/detection.js +105 -0
- package/dist/cjs/shared/type.js +18 -0
- package/dist/cjs/shared/utils.js +78 -0
- package/dist/esm/cli/index.mjs +107 -0
- package/dist/esm/rslib-runtime.mjs +18 -0
- package/dist/esm/runtime/I18nLink.mjs +32 -0
- package/dist/esm/runtime/context.mjs +105 -0
- package/dist/esm/runtime/hooks.mjs +151 -0
- package/dist/esm/runtime/i18n/backend/config.mjs +5 -0
- package/dist/esm/runtime/i18n/backend/defaults.mjs +19 -0
- package/dist/esm/runtime/i18n/backend/defaults.node.mjs +19 -0
- package/dist/esm/runtime/i18n/backend/index.mjs +74 -0
- package/dist/esm/runtime/i18n/backend/middleware.common.mjs +61 -0
- package/dist/esm/runtime/i18n/backend/middleware.mjs +7 -0
- package/dist/esm/runtime/i18n/backend/middleware.node.mjs +8 -0
- package/dist/esm/runtime/i18n/backend/sdk-backend.mjs +141 -0
- package/dist/esm/runtime/i18n/backend/sdk-event.mjs +21 -0
- package/dist/esm/runtime/i18n/detection/config.mjs +26 -0
- package/dist/esm/runtime/i18n/detection/index.mjs +260 -0
- package/dist/esm/runtime/i18n/detection/middleware.mjs +132 -0
- package/dist/esm/runtime/i18n/detection/middleware.node.mjs +31 -0
- package/dist/esm/runtime/i18n/index.mjs +2 -0
- package/dist/esm/runtime/i18n/instance.mjs +77 -0
- package/dist/esm/runtime/i18n/utils.mjs +140 -0
- package/dist/esm/runtime/index.mjs +132 -0
- package/dist/esm/runtime/types.mjs +0 -0
- package/dist/esm/runtime/utils.mjs +75 -0
- package/dist/esm/server/index.mjs +182 -0
- package/dist/esm/shared/deepMerge.mjs +20 -0
- package/dist/esm/shared/detection.mjs +71 -0
- package/dist/esm/shared/type.mjs +0 -0
- package/dist/esm/shared/utils.mjs +35 -0
- package/dist/esm-node/cli/index.mjs +108 -0
- package/dist/esm-node/rslib-runtime.mjs +19 -0
- package/dist/esm-node/runtime/I18nLink.mjs +33 -0
- package/dist/esm-node/runtime/context.mjs +106 -0
- package/dist/esm-node/runtime/hooks.mjs +152 -0
- package/dist/esm-node/runtime/i18n/backend/config.mjs +6 -0
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +20 -0
- package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +20 -0
- package/dist/esm-node/runtime/i18n/backend/index.mjs +75 -0
- package/dist/esm-node/runtime/i18n/backend/middleware.common.mjs +62 -0
- package/dist/esm-node/runtime/i18n/backend/middleware.mjs +8 -0
- package/dist/esm-node/runtime/i18n/backend/middleware.node.mjs +9 -0
- package/dist/esm-node/runtime/i18n/backend/sdk-backend.mjs +142 -0
- package/dist/esm-node/runtime/i18n/backend/sdk-event.mjs +22 -0
- package/dist/esm-node/runtime/i18n/detection/config.mjs +27 -0
- package/dist/esm-node/runtime/i18n/detection/index.mjs +261 -0
- package/dist/esm-node/runtime/i18n/detection/middleware.mjs +133 -0
- package/dist/esm-node/runtime/i18n/detection/middleware.node.mjs +32 -0
- package/dist/esm-node/runtime/i18n/index.mjs +3 -0
- package/dist/esm-node/runtime/i18n/instance.mjs +78 -0
- package/dist/esm-node/runtime/i18n/utils.mjs +141 -0
- package/dist/esm-node/runtime/index.mjs +133 -0
- package/dist/esm-node/runtime/types.mjs +1 -0
- package/dist/esm-node/runtime/utils.mjs +76 -0
- package/dist/esm-node/server/index.mjs +183 -0
- package/dist/esm-node/shared/deepMerge.mjs +21 -0
- package/dist/esm-node/shared/detection.mjs +72 -0
- package/dist/esm-node/shared/type.mjs +1 -0
- package/dist/esm-node/shared/utils.mjs +36 -0
- package/dist/types/cli/index.d.ts +21 -0
- package/dist/types/runtime/I18nLink.d.ts +8 -0
- package/dist/types/runtime/context.d.ts +38 -0
- package/dist/types/runtime/hooks.d.ts +28 -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 +93 -0
- package/dist/types/runtime/i18n/utils.d.ts +29 -0
- package/dist/types/runtime/index.d.ts +20 -0
- package/dist/types/runtime/types.d.ts +15 -0
- package/dist/types/runtime/utils.d.ts +33 -0
- package/dist/types/server/index.d.ts +8 -0
- package/dist/types/shared/deepMerge.d.ts +1 -0
- package/dist/types/shared/detection.d.ts +11 -0
- package/dist/types/shared/type.d.ts +156 -0
- package/dist/types/shared/utils.d.ts +5 -0
- package/package.json +136 -0
- package/rslib.config.mts +4 -0
- package/src/cli/index.ts +245 -0
- package/src/runtime/I18nLink.tsx +76 -0
- package/src/runtime/context.tsx +281 -0
- package/src/runtime/hooks.ts +298 -0
- package/src/runtime/i18n/backend/config.ts +10 -0
- package/src/runtime/i18n/backend/defaults.node.ts +31 -0
- package/src/runtime/i18n/backend/defaults.ts +37 -0
- package/src/runtime/i18n/backend/index.ts +181 -0
- package/src/runtime/i18n/backend/middleware.common.ts +116 -0
- package/src/runtime/i18n/backend/middleware.node.ts +32 -0
- package/src/runtime/i18n/backend/middleware.ts +28 -0
- package/src/runtime/i18n/backend/sdk-backend.ts +306 -0
- package/src/runtime/i18n/backend/sdk-event.ts +39 -0
- package/src/runtime/i18n/detection/config.ts +32 -0
- package/src/runtime/i18n/detection/index.ts +641 -0
- package/src/runtime/i18n/detection/middleware.node.ts +84 -0
- package/src/runtime/i18n/detection/middleware.ts +251 -0
- package/src/runtime/i18n/index.ts +8 -0
- package/src/runtime/i18n/instance.ts +227 -0
- package/src/runtime/i18n/utils.ts +351 -0
- package/src/runtime/index.tsx +285 -0
- package/src/runtime/types.ts +17 -0
- package/src/runtime/utils.ts +163 -0
- package/src/server/index.ts +406 -0
- package/src/shared/deepMerge.ts +38 -0
- package/src/shared/detection.ts +131 -0
- package/src/shared/type.ts +170 -0
- package/src/shared/utils.ts +82 -0
- 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
|
+
}
|