@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.99 → 3.4.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/README.md +221 -11
- package/dist/cjs/cli/index.js +17 -64
- package/dist/cjs/cli/locales.js +132 -0
- package/dist/cjs/runtime/I18nLink.js +17 -20
- package/dist/cjs/runtime/Link.js +264 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/context.js +9 -5
- package/dist/cjs/runtime/hooks.js +9 -5
- package/dist/cjs/runtime/i18n/backend/config.js +9 -5
- package/dist/cjs/runtime/i18n/backend/defaults.js +20 -11
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +79 -10
- 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 +9 -5
- 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 -13
- package/dist/cjs/runtime/i18n/react-i18next.js +12 -8
- package/dist/cjs/runtime/i18n/utils.js +9 -5
- package/dist/cjs/runtime/index.js +32 -5
- package/dist/cjs/runtime/localizedPaths.js +102 -0
- package/dist/cjs/runtime/routerAdapter.js +11 -7
- package/dist/cjs/runtime/utils.js +31 -17
- package/dist/cjs/server/index.js +10 -14
- package/dist/cjs/shared/deepMerge.js +12 -8
- package/dist/cjs/shared/detection.js +9 -5
- package/dist/cjs/shared/localisedUrls.js +148 -34
- package/dist/cjs/shared/utils.js +15 -11
- package/dist/esm/cli/index.mjs +8 -48
- package/dist/esm/cli/locales.mjs +80 -0
- package/dist/esm/runtime/I18nLink.mjs +7 -14
- package/dist/esm/runtime/Link.mjs +221 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- package/dist/esm/runtime/i18n/backend/defaults.mjs +6 -2
- package/dist/esm/runtime/i18n/backend/defaults.node.mjs +56 -5
- package/dist/esm/runtime/index.mjs +4 -2
- package/dist/esm/runtime/localizedPaths.mjs +55 -0
- package/dist/esm/runtime/routerAdapter.mjs +3 -3
- package/dist/esm/runtime/utils.mjs +19 -12
- package/dist/esm/server/index.mjs +2 -10
- package/dist/esm/shared/localisedUrls.mjs +115 -23
- package/dist/esm-node/cli/index.mjs +8 -48
- package/dist/esm-node/cli/locales.mjs +81 -0
- package/dist/esm-node/runtime/I18nLink.mjs +7 -14
- package/dist/esm-node/runtime/Link.mjs +222 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- package/dist/esm-node/runtime/i18n/backend/defaults.mjs +6 -2
- package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +56 -5
- package/dist/esm-node/runtime/index.mjs +4 -2
- package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
- package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
- package/dist/esm-node/runtime/utils.mjs +19 -12
- package/dist/esm-node/server/index.mjs +2 -10
- package/dist/esm-node/shared/localisedUrls.mjs +115 -23
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/locales.d.ts +17 -0
- package/dist/types/runtime/I18nLink.d.ts +4 -13
- package/dist/types/runtime/Link.d.ts +66 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/i18n/backend/defaults.d.ts +10 -7
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +13 -4
- package/dist/types/runtime/index.d.ts +5 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/types.d.ts +1 -1
- package/dist/types/runtime/utils.d.ts +13 -4
- package/dist/types/shared/localisedUrls.d.ts +23 -0
- package/dist/types/shared/type.d.ts +27 -5
- package/package.json +28 -25
- package/rstest.config.mts +7 -2
- package/src/cli/index.ts +25 -98
- package/src/cli/locales.ts +186 -0
- package/src/runtime/I18nLink.tsx +13 -44
- package/src/runtime/Link.tsx +430 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/i18n/backend/defaults.node.ts +112 -7
- package/src/runtime/i18n/backend/defaults.ts +20 -18
- package/src/runtime/index.tsx +24 -2
- package/src/runtime/localizedPaths.ts +107 -0
- package/src/runtime/routerAdapter.tsx +4 -5
- package/src/runtime/types.ts +1 -1
- package/src/runtime/utils.ts +33 -26
- package/src/server/index.ts +7 -17
- package/src/shared/localisedUrls.ts +256 -26
- package/src/shared/type.ts +27 -5
- package/tests/backendDefaults.test.ts +51 -0
- package/tests/i18nUtils.test.ts +10 -3
- package/tests/link.test.tsx +525 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/localisedUrls.test.ts +224 -0
- package/tests/routerAdapter.test.tsx +86 -12
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- package/tests/type-fixture/tsconfig.json +15 -0
|
@@ -11,19 +11,28 @@ export interface ResolvedLocalisedUrlsConfig {
|
|
|
11
11
|
|
|
12
12
|
const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const normaliseSlashes = (path: string): string => {
|
|
15
15
|
const withoutDuplicateSlashes = path.replace(/\/+/g, '/');
|
|
16
16
|
const withLeadingSlash = withoutDuplicateSlashes.startsWith('/')
|
|
17
17
|
? withoutDuplicateSlashes
|
|
18
18
|
: `/${withoutDuplicateSlashes}`;
|
|
19
|
-
const withoutTrailingSlash =
|
|
20
|
-
withLeadingSlash.length > 1
|
|
21
|
-
? withLeadingSlash.replace(/\/+$/, '')
|
|
22
|
-
: withLeadingSlash;
|
|
23
19
|
|
|
24
|
-
return
|
|
20
|
+
return withLeadingSlash.length > 1
|
|
21
|
+
? withLeadingSlash.replace(/\/+$/, '')
|
|
22
|
+
: withLeadingSlash;
|
|
25
23
|
};
|
|
26
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
|
+
|
|
27
36
|
const normaliseRoutePath = (path: string): string => {
|
|
28
37
|
const normalized = normalisePathPattern(path);
|
|
29
38
|
return normalized === '/' ? '' : normalized.slice(1);
|
|
@@ -63,18 +72,21 @@ const getLeadingLocaleParam = (path?: string): string | null => {
|
|
|
63
72
|
return getLocaleParamSegment(segments[0] || '');
|
|
64
73
|
};
|
|
65
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
|
+
*/
|
|
66
82
|
export const resolveLocalisedUrlsConfig = (
|
|
67
83
|
option: LocalisedUrlsOption | undefined,
|
|
68
84
|
): ResolvedLocalisedUrlsConfig => {
|
|
69
|
-
if (option ===
|
|
70
|
-
return { enabled: false, map: {} };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (option && typeof option === 'object') {
|
|
85
|
+
if (option && typeof option === 'object' && Object.keys(option).length > 0) {
|
|
74
86
|
return { enabled: true, map: option };
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
return { enabled:
|
|
89
|
+
return { enabled: false, map: {} };
|
|
78
90
|
};
|
|
79
91
|
|
|
80
92
|
const isLocaleParamPath = (path?: string): boolean => {
|
|
@@ -224,7 +236,7 @@ const transformLocalisedRoute = (
|
|
|
224
236
|
languages,
|
|
225
237
|
localisedUrlEntry,
|
|
226
238
|
).map((localisedPath, index) =>
|
|
227
|
-
cloneRouteWithLocalisedPath(baseRoute, localisedPath, index),
|
|
239
|
+
cloneRouteWithLocalisedPath(baseRoute, localisedPath, index, canonicalPath),
|
|
228
240
|
);
|
|
229
241
|
};
|
|
230
242
|
|
|
@@ -251,6 +263,7 @@ const cloneRouteWithLocalisedPath = (
|
|
|
251
263
|
route: NestedRouteForCli | PageRoute,
|
|
252
264
|
path: string,
|
|
253
265
|
index: number,
|
|
266
|
+
canonicalPath: string,
|
|
254
267
|
): NestedRouteForCli | PageRoute => {
|
|
255
268
|
const leadingLocaleParam = getLeadingLocaleParam(route.path);
|
|
256
269
|
const localisedPath = leadingLocaleParam
|
|
@@ -260,6 +273,10 @@ const cloneRouteWithLocalisedPath = (
|
|
|
260
273
|
...route,
|
|
261
274
|
path: localisedPath,
|
|
262
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;
|
|
263
280
|
|
|
264
281
|
return index === 0
|
|
265
282
|
? routeWithPath
|
|
@@ -298,9 +315,22 @@ const escapeRegExp = (value: string): string =>
|
|
|
298
315
|
const getParamName = (segment: string): string =>
|
|
299
316
|
segment.slice(1).replace(/\?$/, '');
|
|
300
317
|
|
|
301
|
-
|
|
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
|
+
|
|
302
332
|
const names: string[] = [];
|
|
303
|
-
const segments =
|
|
333
|
+
const segments = normalizedPattern.split('/').filter(Boolean);
|
|
304
334
|
const source = segments
|
|
305
335
|
.map(segment => {
|
|
306
336
|
if (segment.startsWith(':')) {
|
|
@@ -318,29 +348,102 @@ const compilePathPattern = (pattern: string) => {
|
|
|
318
348
|
})
|
|
319
349
|
.join('');
|
|
320
350
|
|
|
321
|
-
|
|
351
|
+
const compiled = {
|
|
322
352
|
names,
|
|
323
353
|
regexp: new RegExp(`^${source || '/'}$`),
|
|
324
354
|
};
|
|
355
|
+
compiledPathPatternCache.set(normalizedPattern, compiled);
|
|
356
|
+
|
|
357
|
+
return compiled;
|
|
325
358
|
};
|
|
326
359
|
|
|
327
|
-
const
|
|
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 = (
|
|
328
424
|
pathname: string,
|
|
329
425
|
pattern: string,
|
|
330
426
|
): Record<string, string> | null => {
|
|
331
427
|
const { names, regexp } = compilePathPattern(pattern);
|
|
332
|
-
const match = regexp.exec(
|
|
428
|
+
const match = regexp.exec(normalisePathname(pathname));
|
|
333
429
|
if (!match) {
|
|
334
430
|
return null;
|
|
335
431
|
}
|
|
336
432
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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;
|
|
341
444
|
};
|
|
342
445
|
|
|
343
|
-
const buildPathFromPattern = (
|
|
446
|
+
export const buildPathFromPattern = (
|
|
344
447
|
pattern: string,
|
|
345
448
|
params: Record<string, string>,
|
|
346
449
|
): string => {
|
|
@@ -368,14 +471,93 @@ export const resolveLocalisedPath = (
|
|
|
368
471
|
languages: string[],
|
|
369
472
|
localisedUrls: LocalisedUrlsMap,
|
|
370
473
|
): string => {
|
|
371
|
-
const normalizedPathname =
|
|
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
|
+
);
|
|
372
487
|
|
|
373
|
-
for (const localisedUrlEntry of
|
|
488
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
|
|
374
489
|
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
375
490
|
if (!targetPattern) {
|
|
376
491
|
continue;
|
|
377
492
|
}
|
|
378
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
|
+
|
|
379
561
|
for (const language of languages) {
|
|
380
562
|
const sourcePattern = localisedUrlEntry[language];
|
|
381
563
|
if (!sourcePattern) {
|
|
@@ -384,10 +566,58 @@ export const resolveLocalisedPath = (
|
|
|
384
566
|
|
|
385
567
|
const params = matchPathPattern(normalizedPathname, sourcePattern);
|
|
386
568
|
if (params) {
|
|
387
|
-
return buildPathFromPattern(
|
|
569
|
+
return buildPathFromPattern(canonicalPattern, params);
|
|
388
570
|
}
|
|
389
571
|
}
|
|
390
572
|
}
|
|
391
573
|
|
|
392
574
|
return normalizedPathname;
|
|
393
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
|
@@ -14,12 +14,14 @@ export interface BaseLocaleDetectionOptions {
|
|
|
14
14
|
/**
|
|
15
15
|
* Enables localised pathnames in addition to the locale prefix.
|
|
16
16
|
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
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`).
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* configured languages.
|
|
23
|
+
* Strictly opt-in: without a map, `localePathRedirect` + `languages` behave
|
|
24
|
+
* exactly like upstream Modern.js.
|
|
23
25
|
*/
|
|
24
26
|
localisedUrls?: LocalisedUrlsOption;
|
|
25
27
|
}
|
|
@@ -83,6 +85,26 @@ export interface BaseBackendOptions {
|
|
|
83
85
|
enabled?: boolean;
|
|
84
86
|
loadPath?: string;
|
|
85
87
|
addPath?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Internal file-system path used by the Node.js FS backend.
|
|
90
|
+
* Browser HTTP backend keeps using `loadPath`.
|
|
91
|
+
*/
|
|
92
|
+
serverLoadPath?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Internal file-system path candidates used by the Node.js FS backend.
|
|
95
|
+
* The first existing path under current cwd will be used.
|
|
96
|
+
*/
|
|
97
|
+
serverLoadPaths?: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Internal file-system path used by the Node.js FS backend.
|
|
100
|
+
* Browser HTTP backend keeps using `addPath`.
|
|
101
|
+
*/
|
|
102
|
+
serverAddPath?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Internal file-system path candidates used by the Node.js FS backend.
|
|
105
|
+
* The first existing path under current cwd will be used.
|
|
106
|
+
*/
|
|
107
|
+
serverAddPaths?: string[];
|
|
86
108
|
/**
|
|
87
109
|
* Cache hit mode for chained backend (only effective when both `loadPath` and `sdk` are provided)
|
|
88
110
|
*
|
|
@@ -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
|
+
});
|
package/tests/i18nUtils.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from '@rstest/core';
|
|
2
2
|
import type { I18nInstance } from '../src/runtime/i18n';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_I18NEXT_BACKEND_OPTIONS as NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS,
|
|
5
|
+
resolveDefaultLocalesDir,
|
|
6
|
+
} from '../src/runtime/i18n/backend/defaults.node';
|
|
4
7
|
import { initializeI18nInstance } from '../src/runtime/i18n/utils';
|
|
5
8
|
|
|
6
9
|
function createBackendI18nInstance(): I18nInstance {
|
|
@@ -17,9 +20,13 @@ function createBackendI18nInstance(): I18nInstance {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
describe('i18n runtime utils', () => {
|
|
20
|
-
test('
|
|
23
|
+
test('node fs backend defaults follow the detected locales directory', () => {
|
|
24
|
+
// The default must match whichever conventional root exists at runtime
|
|
25
|
+
// (./locales first, then ./config/public/locales), mirroring the CLI
|
|
26
|
+
// plugin's detectLocalesDirectory. Detailed precedence cases live in
|
|
27
|
+
// tests/backendDefaults.test.ts.
|
|
21
28
|
expect(NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS.loadPath).toBe(
|
|
22
|
-
|
|
29
|
+
`${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`,
|
|
23
30
|
);
|
|
24
31
|
});
|
|
25
32
|
|