@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.12 → 3.2.0-ultramodern.121
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/README.md +221 -11
- package/dist/cjs/cli/index.js +32 -5
- package/dist/cjs/runtime/I18nLink.js +17 -28
- package/dist/cjs/runtime/Link.js +264 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/context.js +41 -10
- package/dist/cjs/runtime/hooks.js +17 -10
- package/dist/cjs/runtime/i18n/backend/config.js +9 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +15 -10
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +47 -8
- package/dist/cjs/runtime/i18n/backend/index.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/backend/middleware.node.js +13 -9
- package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
- package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
- package/dist/cjs/runtime/i18n/detection/config.js +9 -5
- package/dist/cjs/runtime/i18n/detection/index.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
- package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
- package/dist/cjs/runtime/i18n/index.js +9 -5
- package/dist/cjs/runtime/i18n/instance.js +17 -37
- package/dist/cjs/runtime/i18n/react-i18next.js +53 -0
- package/dist/cjs/runtime/i18n/utils.js +9 -17
- package/dist/cjs/runtime/index.js +50 -15
- package/dist/cjs/runtime/localizedPaths.js +102 -0
- package/dist/cjs/runtime/routerAdapter.js +167 -0
- package/dist/cjs/runtime/utils.js +80 -97
- package/dist/cjs/server/index.js +62 -14
- package/dist/cjs/shared/deepMerge.js +12 -8
- package/dist/cjs/shared/detection.js +9 -5
- package/dist/cjs/shared/localisedUrls.js +351 -0
- package/dist/cjs/shared/utils.js +15 -11
- package/dist/esm/cli/index.mjs +23 -0
- package/dist/esm/runtime/I18nLink.mjs +7 -22
- package/dist/esm/runtime/Link.mjs +221 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- 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 +24 -3
- 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 +23 -13
- package/dist/esm/runtime/localizedPaths.mjs +55 -0
- package/dist/esm/runtime/routerAdapter.mjs +129 -0
- package/dist/esm/runtime/utils.mjs +19 -31
- package/dist/esm/server/index.mjs +46 -8
- package/dist/esm/shared/localisedUrls.mjs +283 -0
- package/dist/esm-node/cli/index.mjs +23 -0
- package/dist/esm-node/runtime/I18nLink.mjs +7 -22
- package/dist/esm-node/runtime/Link.mjs +222 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- 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 +24 -3
- 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 +23 -13
- package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +130 -0
- package/dist/esm-node/runtime/utils.mjs +19 -31
- package/dist/esm-node/server/index.mjs +46 -8
- package/dist/esm-node/shared/localisedUrls.mjs +284 -0
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/runtime/I18nLink.d.ts +6 -0
- package/dist/types/runtime/Link.d.ts +66 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -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/defaults.node.d.ts +3 -2
- package/dist/types/runtime/i18n/backend/middleware.node.d.ts +1 -1
- package/dist/types/runtime/i18n/instance.d.ts +4 -6
- package/dist/types/runtime/i18n/react-i18next.d.ts +7 -0
- package/dist/types/runtime/index.d.ts +6 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/routerAdapter.d.ts +26 -0
- package/dist/types/runtime/types.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +13 -9
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/shared/localisedUrls.d.ts +36 -0
- package/dist/types/shared/type.d.ts +14 -0
- package/package.json +24 -24
- package/rstest.config.mts +44 -0
- package/src/cli/index.ts +44 -1
- package/src/runtime/I18nLink.tsx +14 -51
- package/src/runtime/Link.tsx +430 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/context.tsx +45 -7
- package/src/runtime/hooks.ts +13 -4
- package/src/runtime/i18n/backend/defaults.node.ts +40 -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 +3 -30
- package/src/runtime/i18n/react-i18next.ts +25 -0
- package/src/runtime/i18n/utils.ts +4 -26
- package/src/runtime/index.tsx +47 -12
- package/src/runtime/localizedPaths.ts +107 -0
- package/src/runtime/routerAdapter.tsx +332 -0
- package/src/runtime/types.ts +1 -1
- package/src/runtime/utils.ts +33 -38
- package/src/server/index.ts +108 -11
- package/src/shared/localisedUrls.ts +623 -0
- package/src/shared/type.ts +14 -0
- package/tests/backendDefaults.test.ts +51 -0
- package/tests/i18nUtils.test.ts +59 -0
- package/tests/link.test.tsx +525 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/localisedUrls.test.ts +536 -0
- package/tests/routerAdapter.test.tsx +456 -0
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
- package/dist/esm/rslib-runtime.mjs +0 -18
- package/dist/esm-node/rslib-runtime.mjs +0 -19
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
|
|
2
|
+
|
|
3
|
+
export type LocalisedUrlPathMap = Record<string, string>;
|
|
4
|
+
export type LocalisedUrlsMap = Record<string, LocalisedUrlPathMap>;
|
|
5
|
+
export type LocalisedUrlsOption = boolean | LocalisedUrlsMap;
|
|
6
|
+
|
|
7
|
+
export interface ResolvedLocalisedUrlsConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
map: LocalisedUrlsMap;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
|
|
13
|
+
|
|
14
|
+
const normaliseSlashes = (path: string): string => {
|
|
15
|
+
const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
|
|
16
|
+
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/')
|
|
17
|
+
? withoutDuplicateSlashes
|
|
18
|
+
: `/${withoutDuplicateSlashes}`;
|
|
19
|
+
|
|
20
|
+
return withLeadingSlash.length > 1
|
|
21
|
+
? withLeadingSlash.replace(/\/+$/, '')
|
|
22
|
+
: withLeadingSlash;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const normalisePathPattern = (path: string): string =>
|
|
26
|
+
normaliseSlashes(path).replace(/\[(.+?)\]/g, ':$1');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalise a concrete request pathname: slash cleanup only. Unlike
|
|
30
|
+
* {@link normalisePathPattern} it must not rewrite literal `[x]` segments to
|
|
31
|
+
* `:x` params — pathnames are values, not patterns.
|
|
32
|
+
*/
|
|
33
|
+
export const normalisePathname = (pathname: string): string =>
|
|
34
|
+
normaliseSlashes(pathname);
|
|
35
|
+
|
|
36
|
+
const normaliseRoutePath = (path: string): string => {
|
|
37
|
+
const normalized = normalisePathPattern(path);
|
|
38
|
+
return normalized === '/' ? '' : normalized.slice(1);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getLocaleParamSegment = (segment: string): string | null => {
|
|
42
|
+
if (!segment.startsWith(':')) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const paramName = segment.slice(1).replace(/\?$/, '');
|
|
47
|
+
return LOCALE_PARAM_NAMES.has(paramName) ? segment : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const splitPathSegments = (path?: string): string[] => {
|
|
51
|
+
if (!path) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return normalisePathPattern(path).split('/').filter(Boolean);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const stripLeadingLocaleParam = (path?: string): string | undefined => {
|
|
59
|
+
const segments = splitPathSegments(path);
|
|
60
|
+
const leadingLocaleParam = getLocaleParamSegment(segments[0] || '');
|
|
61
|
+
|
|
62
|
+
if (!leadingLocaleParam) {
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const remainingPath = segments.slice(1).join('/');
|
|
67
|
+
return remainingPath ? `/${remainingPath}` : undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const getLeadingLocaleParam = (path?: string): string | null => {
|
|
71
|
+
const segments = splitPathSegments(path);
|
|
72
|
+
return getLocaleParamSegment(segments[0] || '');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Localised URLs are strictly opt-in: only an explicit, non-empty map enables
|
|
77
|
+
* route expansion and validation. `true`, `false`, an empty map and absence
|
|
78
|
+
* all resolve to disabled, so upstream-style configs (`localePathRedirect` +
|
|
79
|
+
* `languages` without a map) keep plain locale-prefix behavior instead of
|
|
80
|
+
* failing the build for every route missing from a map they never wrote.
|
|
81
|
+
*/
|
|
82
|
+
export const resolveLocalisedUrlsConfig = (
|
|
83
|
+
option: LocalisedUrlsOption | undefined,
|
|
84
|
+
): ResolvedLocalisedUrlsConfig => {
|
|
85
|
+
if (option && typeof option === 'object' && Object.keys(option).length > 0) {
|
|
86
|
+
return { enabled: true, map: option };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { enabled: false, map: {} };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const isLocaleParamPath = (path?: string): boolean => {
|
|
93
|
+
const segments = splitPathSegments(path);
|
|
94
|
+
return segments.length === 1 && Boolean(getLocaleParamSegment(segments[0]));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const isLocalisableRoutePath = (path?: string): path is string => {
|
|
98
|
+
const pathWithoutLocale = stripLeadingLocaleParam(path);
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
!pathWithoutLocale ||
|
|
102
|
+
pathWithoutLocale === '/' ||
|
|
103
|
+
pathWithoutLocale === '*'
|
|
104
|
+
) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const joinPath = (parentPath: string, routePath?: string): string => {
|
|
112
|
+
if (!isLocalisableRoutePath(routePath)) {
|
|
113
|
+
return parentPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const segment = normaliseRoutePath(stripLeadingLocaleParam(routePath) || '');
|
|
117
|
+
return normalisePathPattern(`${parentPath}/${segment}`);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const ensureLocalisedUrlsForPath = (
|
|
121
|
+
canonicalPath: string,
|
|
122
|
+
languages: string[],
|
|
123
|
+
localisedUrls: LocalisedUrlsMap,
|
|
124
|
+
): LocalisedUrlPathMap => {
|
|
125
|
+
const entry = localisedUrls[canonicalPath];
|
|
126
|
+
if (!entry) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`localisedUrls is enabled, but route "${canonicalPath}" does not define localised URLs for languages: ${languages.join(
|
|
129
|
+
', ',
|
|
130
|
+
)}. Add localisedUrls["${canonicalPath}"] or set localeDetection.localisedUrls to false.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const missingLanguages = languages.filter(language => !entry[language]);
|
|
135
|
+
if (missingLanguages.length > 0) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`localisedUrls["${canonicalPath}"] is missing languages: ${missingLanguages.join(
|
|
138
|
+
', ',
|
|
139
|
+
)}. Every configured language must have a localised URL.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return entry;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const validateLocalisedUrls = (
|
|
147
|
+
routes: (NestedRouteForCli | PageRoute)[],
|
|
148
|
+
languages: string[],
|
|
149
|
+
localisedUrls: LocalisedUrlsMap,
|
|
150
|
+
) => {
|
|
151
|
+
const visit = (route: NestedRouteForCli | PageRoute, parentPath: string) => {
|
|
152
|
+
const canonicalPath = joinPath(parentPath, route.path);
|
|
153
|
+
if (isLocalisableRoutePath(route.path)) {
|
|
154
|
+
ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if ('children' in route && route.children) {
|
|
158
|
+
route.children.forEach(child => visit(child, canonicalPath));
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
routes.forEach(route => visit(route, ''));
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const getLocalisedRoutePaths = (
|
|
166
|
+
canonicalPath: string,
|
|
167
|
+
parentLocalisedPaths: Record<string, string>,
|
|
168
|
+
languages: string[],
|
|
169
|
+
entry: LocalisedUrlPathMap,
|
|
170
|
+
): string[] => {
|
|
171
|
+
const paths = languages.map(language => {
|
|
172
|
+
const fullPath = normalisePathPattern(entry[language]);
|
|
173
|
+
const parentPath = normalisePathPattern(
|
|
174
|
+
parentLocalisedPaths[language] || '/',
|
|
175
|
+
);
|
|
176
|
+
if (parentPath === '/') {
|
|
177
|
+
return normaliseRoutePath(fullPath) || undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const parentPrefix = `${parentPath}/`;
|
|
181
|
+
if (!fullPath.startsWith(parentPrefix)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`localisedUrls["${canonicalPath}"].${language} must be nested under "${parentPath}" because its parent route is localised there.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return normaliseRoutePath(fullPath.slice(parentPath.length));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return Array.from(new Set(paths.filter(Boolean) as string[]));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const transformLocalisedRoute = (
|
|
194
|
+
route: NestedRouteForCli | PageRoute,
|
|
195
|
+
parentCanonicalPath: string,
|
|
196
|
+
parentLocalisedPaths: Record<string, string>,
|
|
197
|
+
languages: string[],
|
|
198
|
+
localisedUrls: LocalisedUrlsMap,
|
|
199
|
+
): (NestedRouteForCli | PageRoute)[] => {
|
|
200
|
+
const canonicalPath = joinPath(parentCanonicalPath, route.path);
|
|
201
|
+
const localisedUrlEntry = isLocalisableRoutePath(route.path)
|
|
202
|
+
? ensureLocalisedUrlsForPath(canonicalPath, languages, localisedUrls)
|
|
203
|
+
: undefined;
|
|
204
|
+
const routeLocalisedPaths = localisedUrlEntry
|
|
205
|
+
? languages.reduce<Record<string, string>>((acc, language) => {
|
|
206
|
+
acc[language] = normalisePathPattern(localisedUrlEntry[language]);
|
|
207
|
+
return acc;
|
|
208
|
+
}, {})
|
|
209
|
+
: parentLocalisedPaths;
|
|
210
|
+
|
|
211
|
+
const children =
|
|
212
|
+
'children' in route && route.children
|
|
213
|
+
? route.children.flatMap(child =>
|
|
214
|
+
transformLocalisedRoute(
|
|
215
|
+
child,
|
|
216
|
+
canonicalPath,
|
|
217
|
+
routeLocalisedPaths,
|
|
218
|
+
languages,
|
|
219
|
+
localisedUrls,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
: undefined;
|
|
223
|
+
|
|
224
|
+
const baseRoute = {
|
|
225
|
+
...route,
|
|
226
|
+
...(children ? { children } : {}),
|
|
227
|
+
} as NestedRouteForCli | PageRoute;
|
|
228
|
+
|
|
229
|
+
if (!localisedUrlEntry) {
|
|
230
|
+
return [baseRoute];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return getLocalisedRoutePaths(
|
|
234
|
+
canonicalPath,
|
|
235
|
+
parentLocalisedPaths,
|
|
236
|
+
languages,
|
|
237
|
+
localisedUrlEntry,
|
|
238
|
+
).map((localisedPath, index) =>
|
|
239
|
+
cloneRouteWithLocalisedPath(baseRoute, localisedPath, index, canonicalPath),
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const legalRouteIdPart = (value: string): string =>
|
|
244
|
+
value.replace(/[^a-zA-Z0-9_$-]+/g, '_').replace(/^_+|_+$/g, '') || 'index';
|
|
245
|
+
|
|
246
|
+
const suffixRouteIds = <T extends NestedRouteForCli | PageRoute>(
|
|
247
|
+
route: T,
|
|
248
|
+
suffix: string,
|
|
249
|
+
): T => {
|
|
250
|
+
const children =
|
|
251
|
+
'children' in route && route.children
|
|
252
|
+
? route.children.map(child => suffixRouteIds(child, suffix))
|
|
253
|
+
: undefined;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
...route,
|
|
257
|
+
...(route.id ? { id: `${route.id}__localised_${suffix}` } : {}),
|
|
258
|
+
...(children ? { children } : {}),
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const cloneRouteWithLocalisedPath = (
|
|
263
|
+
route: NestedRouteForCli | PageRoute,
|
|
264
|
+
path: string,
|
|
265
|
+
index: number,
|
|
266
|
+
canonicalPath: string,
|
|
267
|
+
): NestedRouteForCli | PageRoute => {
|
|
268
|
+
const leadingLocaleParam = getLeadingLocaleParam(route.path);
|
|
269
|
+
const localisedPath = leadingLocaleParam
|
|
270
|
+
? normaliseRoutePath(`${leadingLocaleParam}/${path}`)
|
|
271
|
+
: path;
|
|
272
|
+
const routeWithPath = {
|
|
273
|
+
...route,
|
|
274
|
+
path: localisedPath,
|
|
275
|
+
} as NestedRouteForCli | PageRoute;
|
|
276
|
+
// Language-agnostic source pattern; lets downstream codegen collapse the
|
|
277
|
+
// localized physical variants back to one canonical route.
|
|
278
|
+
(routeWithPath as { modernCanonicalPath?: string }).modernCanonicalPath =
|
|
279
|
+
canonicalPath;
|
|
280
|
+
|
|
281
|
+
return index === 0
|
|
282
|
+
? routeWithPath
|
|
283
|
+
: suffixRouteIds(routeWithPath, legalRouteIdPart(localisedPath));
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const applyLocalisedUrlsToRoutes = (
|
|
287
|
+
routes: (NestedRouteForCli | PageRoute)[],
|
|
288
|
+
languages: string[],
|
|
289
|
+
localisedUrls: LocalisedUrlsMap,
|
|
290
|
+
): (NestedRouteForCli | PageRoute)[] => {
|
|
291
|
+
const rootLocalisedPaths = languages.reduce<Record<string, string>>(
|
|
292
|
+
(acc, language) => {
|
|
293
|
+
acc[language] = '/';
|
|
294
|
+
return acc;
|
|
295
|
+
},
|
|
296
|
+
{},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
validateLocalisedUrls(routes, languages, localisedUrls);
|
|
300
|
+
|
|
301
|
+
return routes.flatMap(route =>
|
|
302
|
+
transformLocalisedRoute(
|
|
303
|
+
route,
|
|
304
|
+
'',
|
|
305
|
+
rootLocalisedPaths,
|
|
306
|
+
languages,
|
|
307
|
+
localisedUrls,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const escapeRegExp = (value: string): string =>
|
|
313
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
314
|
+
|
|
315
|
+
const getParamName = (segment: string): string =>
|
|
316
|
+
segment.slice(1).replace(/\?$/, '');
|
|
317
|
+
|
|
318
|
+
interface CompiledPathPattern {
|
|
319
|
+
names: string[];
|
|
320
|
+
regexp: RegExp;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const compiledPathPatternCache = new Map<string, CompiledPathPattern>();
|
|
324
|
+
|
|
325
|
+
const compilePathPattern = (pattern: string): CompiledPathPattern => {
|
|
326
|
+
const normalizedPattern = normalisePathPattern(pattern);
|
|
327
|
+
const cached = compiledPathPatternCache.get(normalizedPattern);
|
|
328
|
+
if (cached) {
|
|
329
|
+
return cached;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const names: string[] = [];
|
|
333
|
+
const segments = normalizedPattern.split('/').filter(Boolean);
|
|
334
|
+
const source = segments
|
|
335
|
+
.map(segment => {
|
|
336
|
+
if (segment.startsWith(':')) {
|
|
337
|
+
names.push(getParamName(segment));
|
|
338
|
+
const paramPattern = '([^/]+)';
|
|
339
|
+
return segment.endsWith('?')
|
|
340
|
+
? `(?:/${paramPattern})?`
|
|
341
|
+
: `/${paramPattern}`;
|
|
342
|
+
}
|
|
343
|
+
if (segment === '*') {
|
|
344
|
+
names.push('*');
|
|
345
|
+
return '/(.*)';
|
|
346
|
+
}
|
|
347
|
+
return `/${escapeRegExp(segment)}`;
|
|
348
|
+
})
|
|
349
|
+
.join('');
|
|
350
|
+
|
|
351
|
+
const compiled = {
|
|
352
|
+
names,
|
|
353
|
+
regexp: new RegExp(`^${source || '/'}$`),
|
|
354
|
+
};
|
|
355
|
+
compiledPathPatternCache.set(normalizedPattern, compiled);
|
|
356
|
+
|
|
357
|
+
return compiled;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const getPatternSpecificity = (pattern: string) => {
|
|
361
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
362
|
+
let staticSegments = 0;
|
|
363
|
+
let dynamicSegments = 0;
|
|
364
|
+
let splatSegments = 0;
|
|
365
|
+
|
|
366
|
+
for (const segment of segments) {
|
|
367
|
+
if (segment === '*') {
|
|
368
|
+
splatSegments++;
|
|
369
|
+
} else if (segment.startsWith(':')) {
|
|
370
|
+
dynamicSegments++;
|
|
371
|
+
} else {
|
|
372
|
+
staticSegments++;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
staticSegments,
|
|
378
|
+
dynamicSegments,
|
|
379
|
+
splatSegments,
|
|
380
|
+
totalSegments: segments.length,
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const comparePatternSpecificity = (left: string, right: string): number => {
|
|
385
|
+
const a = getPatternSpecificity(left);
|
|
386
|
+
const b = getPatternSpecificity(right);
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
b.staticSegments - a.staticSegments ||
|
|
390
|
+
b.totalSegments - a.totalSegments ||
|
|
391
|
+
a.splatSegments - b.splatSegments ||
|
|
392
|
+
a.dynamicSegments - b.dynamicSegments
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const sortPatternsBySpecificity = <T extends { pattern: string }>(
|
|
397
|
+
patterns: T[],
|
|
398
|
+
): T[] =>
|
|
399
|
+
patterns
|
|
400
|
+
.map((pattern, index) => ({ pattern, index }))
|
|
401
|
+
.sort(
|
|
402
|
+
(left, right) =>
|
|
403
|
+
comparePatternSpecificity(
|
|
404
|
+
left.pattern.pattern,
|
|
405
|
+
right.pattern.pattern,
|
|
406
|
+
) || left.index - right.index,
|
|
407
|
+
)
|
|
408
|
+
.map(({ pattern }) => pattern);
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `decodeURIComponent` throws `URIError` on malformed percent-encoding
|
|
412
|
+
* (e.g. `%E0%A4%A`), which attacker-controlled request URLs can carry.
|
|
413
|
+
* Treat such segments as undecodable instead of throwing.
|
|
414
|
+
*/
|
|
415
|
+
const decodePathParam = (value: string): string | null => {
|
|
416
|
+
try {
|
|
417
|
+
return decodeURIComponent(value);
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
export const matchPathPattern = (
|
|
424
|
+
pathname: string,
|
|
425
|
+
pattern: string,
|
|
426
|
+
): Record<string, string> | null => {
|
|
427
|
+
const { names, regexp } = compilePathPattern(pattern);
|
|
428
|
+
const match = regexp.exec(normalisePathname(pathname));
|
|
429
|
+
if (!match) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const params: Record<string, string> = {};
|
|
434
|
+
for (let index = 0; index < names.length; index++) {
|
|
435
|
+
const decoded = decodePathParam(match[index + 1] || '');
|
|
436
|
+
if (decoded === null) {
|
|
437
|
+
// Malformed encoding cannot identify a localised route: no match.
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
params[names[index]] = decoded;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return params;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export const buildPathFromPattern = (
|
|
447
|
+
pattern: string,
|
|
448
|
+
params: Record<string, string>,
|
|
449
|
+
): string => {
|
|
450
|
+
const segments = normalisePathPattern(pattern).split('/').filter(Boolean);
|
|
451
|
+
const path = segments
|
|
452
|
+
.map(segment => {
|
|
453
|
+
if (segment.startsWith(':')) {
|
|
454
|
+
const param = params[getParamName(segment)];
|
|
455
|
+
return param ? encodeURIComponent(param) : '';
|
|
456
|
+
}
|
|
457
|
+
if (segment === '*') {
|
|
458
|
+
return params['*'] || '';
|
|
459
|
+
}
|
|
460
|
+
return segment;
|
|
461
|
+
})
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.join('/');
|
|
464
|
+
|
|
465
|
+
return `/${path}`;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
export const resolveLocalisedPath = (
|
|
469
|
+
pathname: string,
|
|
470
|
+
targetLanguage: string,
|
|
471
|
+
languages: string[],
|
|
472
|
+
localisedUrls: LocalisedUrlsMap,
|
|
473
|
+
): string => {
|
|
474
|
+
const normalizedPathname = normalisePathname(pathname);
|
|
475
|
+
|
|
476
|
+
// Canonical keys take precedence: authors write language-agnostic paths,
|
|
477
|
+
// which are the map keys, even when no language pattern equals the key.
|
|
478
|
+
const canonicalCandidates = sortPatternsBySpecificity(
|
|
479
|
+
Object.entries(localisedUrls).map(
|
|
480
|
+
([canonicalPattern, localisedUrlEntry]) => ({
|
|
481
|
+
pattern: canonicalPattern,
|
|
482
|
+
canonicalPattern,
|
|
483
|
+
localisedUrlEntry,
|
|
484
|
+
}),
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
|
|
489
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
490
|
+
if (!targetPattern) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const params = matchPathPattern(normalizedPathname, canonicalPattern);
|
|
495
|
+
if (params) {
|
|
496
|
+
return buildPathFromPattern(targetPattern, params);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const localisedCandidates = sortPatternsBySpecificity(
|
|
501
|
+
Object.values(localisedUrls).flatMap(localisedUrlEntry => {
|
|
502
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
503
|
+
if (!targetPattern) {
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return languages
|
|
508
|
+
.map(language => localisedUrlEntry[language])
|
|
509
|
+
.filter((sourcePattern): sourcePattern is string =>
|
|
510
|
+
Boolean(sourcePattern),
|
|
511
|
+
)
|
|
512
|
+
.map(sourcePattern => ({
|
|
513
|
+
pattern: sourcePattern,
|
|
514
|
+
sourcePattern,
|
|
515
|
+
targetPattern,
|
|
516
|
+
}));
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
for (const { sourcePattern, targetPattern } of localisedCandidates) {
|
|
521
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
522
|
+
if (params) {
|
|
523
|
+
return buildPathFromPattern(targetPattern, params);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return normalizedPathname;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Reverse-map a language-specific pathname (without language prefix) back to
|
|
532
|
+
* the canonical, language-agnostic path: localized slug patterns are matched
|
|
533
|
+
* against every language variant and rebuilt from the canonical map key.
|
|
534
|
+
*/
|
|
535
|
+
export const resolveCanonicalLocalisedPath = (
|
|
536
|
+
pathname: string,
|
|
537
|
+
languages: string[],
|
|
538
|
+
localisedUrls: LocalisedUrlsMap,
|
|
539
|
+
): string => {
|
|
540
|
+
const normalizedPathname = normalisePathname(pathname);
|
|
541
|
+
|
|
542
|
+
const canonicalCandidates = sortPatternsBySpecificity(
|
|
543
|
+
Object.entries(localisedUrls).map(
|
|
544
|
+
([canonicalPattern, localisedUrlEntry]) => ({
|
|
545
|
+
pattern: canonicalPattern,
|
|
546
|
+
canonicalPattern,
|
|
547
|
+
localisedUrlEntry,
|
|
548
|
+
}),
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
|
|
553
|
+
const canonicalParams = matchPathPattern(
|
|
554
|
+
normalizedPathname,
|
|
555
|
+
canonicalPattern,
|
|
556
|
+
);
|
|
557
|
+
if (canonicalParams) {
|
|
558
|
+
return buildPathFromPattern(canonicalPattern, canonicalParams);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const language of languages) {
|
|
562
|
+
const sourcePattern = localisedUrlEntry[language];
|
|
563
|
+
if (!sourcePattern) {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
568
|
+
if (params) {
|
|
569
|
+
return buildPathFromPattern(canonicalPattern, params);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return normalizedPathname;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const stripLanguagePrefix = (pathname: string, languages: string[]): string => {
|
|
578
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
579
|
+
|
|
580
|
+
if (segments.length > 0 && languages.includes(segments[0])) {
|
|
581
|
+
return `/${segments.slice(1).join('/')}`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return pathname || '/';
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export const localiseTargetPathname = (
|
|
588
|
+
pathname: string,
|
|
589
|
+
language: string,
|
|
590
|
+
languages: string[],
|
|
591
|
+
localisedUrls?: LocalisedUrlsOption,
|
|
592
|
+
): string => {
|
|
593
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
594
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
595
|
+
const resolvedPath = localisedUrlsConfig.enabled
|
|
596
|
+
? resolveLocalisedPath(
|
|
597
|
+
pathWithoutLanguage,
|
|
598
|
+
language,
|
|
599
|
+
languages,
|
|
600
|
+
localisedUrlsConfig.map,
|
|
601
|
+
)
|
|
602
|
+
: pathWithoutLanguage;
|
|
603
|
+
const resolvedSegments = resolvedPath.split('/').filter(Boolean);
|
|
604
|
+
|
|
605
|
+
return `/${[language, ...resolvedSegments].join('/')}`;
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
export const canonicalTargetPathname = (
|
|
609
|
+
pathname: string,
|
|
610
|
+
languages: string[],
|
|
611
|
+
localisedUrls?: LocalisedUrlsOption,
|
|
612
|
+
): string => {
|
|
613
|
+
const pathWithoutLanguage = stripLanguagePrefix(pathname, languages);
|
|
614
|
+
const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
|
|
615
|
+
|
|
616
|
+
return localisedUrlsConfig.enabled
|
|
617
|
+
? resolveCanonicalLocalisedPath(
|
|
618
|
+
pathWithoutLanguage,
|
|
619
|
+
languages,
|
|
620
|
+
localisedUrlsConfig.map,
|
|
621
|
+
)
|
|
622
|
+
: pathWithoutLanguage;
|
|
623
|
+
};
|
package/src/shared/type.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
LanguageDetectorOptions,
|
|
3
3
|
Resources,
|
|
4
4
|
} from '../runtime/i18n/instance';
|
|
5
|
+
import type { LocalisedUrlsOption } from './localisedUrls';
|
|
5
6
|
|
|
6
7
|
export interface BaseLocaleDetectionOptions {
|
|
7
8
|
localePathRedirect?: boolean;
|
|
@@ -10,6 +11,19 @@ export interface BaseLocaleDetectionOptions {
|
|
|
10
11
|
fallbackLanguage?: string;
|
|
11
12
|
detection?: LanguageDetectorOptions;
|
|
12
13
|
ignoreRedirectRoutes?: string[] | ((pathname: string) => boolean);
|
|
14
|
+
/**
|
|
15
|
+
* Enables localised pathnames in addition to the locale prefix.
|
|
16
|
+
*
|
|
17
|
+
* - non-empty object: map canonical route paths to every configured
|
|
18
|
+
* language; route generation then validates that every localisable route
|
|
19
|
+
* path has entries for all configured languages.
|
|
20
|
+
* - absent / `false` / `true` / empty object: keep only locale-prefix
|
|
21
|
+
* behavior (`/en/about`).
|
|
22
|
+
*
|
|
23
|
+
* Strictly opt-in: without a map, `localePathRedirect` + `languages` behave
|
|
24
|
+
* exactly like upstream Modern.js.
|
|
25
|
+
*/
|
|
26
|
+
localisedUrls?: LocalisedUrlsOption;
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, test } from '@rstest/core';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_I18NEXT_BACKEND_OPTIONS,
|
|
7
|
+
resolveDefaultLocalesDir,
|
|
8
|
+
} from '../src/runtime/i18n/backend/defaults.node';
|
|
9
|
+
|
|
10
|
+
const makeTempDir = (...dirs: string[]): string => {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-defaults-'));
|
|
12
|
+
for (const dir of dirs) {
|
|
13
|
+
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
return root;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('node backend default loadPath', () => {
|
|
19
|
+
const originalCwd = process.cwd();
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.chdir(originalCwd);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('prefers the project-root ./locales convention (the detection root)', () => {
|
|
26
|
+
const root = makeTempDir('locales', 'config/public/locales');
|
|
27
|
+
expect(resolveDefaultLocalesDir(root)).toBe('./locales');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('falls back to ./config/public/locales when only it exists', () => {
|
|
31
|
+
const root = makeTempDir('config/public/locales');
|
|
32
|
+
expect(resolveDefaultLocalesDir(root)).toBe('./config/public/locales');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('defaults to ./locales when neither conventional directory exists', () => {
|
|
36
|
+
const root = makeTempDir();
|
|
37
|
+
expect(resolveDefaultLocalesDir(root)).toBe('./locales');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('DEFAULT_I18NEXT_BACKEND_OPTIONS resolves against the working directory', () => {
|
|
41
|
+
const root = makeTempDir('locales');
|
|
42
|
+
process.chdir(root);
|
|
43
|
+
|
|
44
|
+
expect(DEFAULT_I18NEXT_BACKEND_OPTIONS.loadPath).toBe(
|
|
45
|
+
'./locales/{{lng}}/{{ns}}.json',
|
|
46
|
+
);
|
|
47
|
+
expect(DEFAULT_I18NEXT_BACKEND_OPTIONS.addPath).toBe(
|
|
48
|
+
'./locales/{{lng}}/{{ns}}.json',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|