@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.98 → 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.
Files changed (98) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/cli/index.js +17 -64
  3. package/dist/cjs/cli/locales.js +132 -0
  4. package/dist/cjs/runtime/I18nLink.js +17 -20
  5. package/dist/cjs/runtime/Link.js +264 -0
  6. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  7. package/dist/cjs/runtime/context.js +9 -5
  8. package/dist/cjs/runtime/hooks.js +9 -5
  9. package/dist/cjs/runtime/i18n/backend/config.js +9 -5
  10. package/dist/cjs/runtime/i18n/backend/defaults.js +20 -11
  11. package/dist/cjs/runtime/i18n/backend/defaults.node.js +79 -10
  12. package/dist/cjs/runtime/i18n/backend/index.js +9 -5
  13. package/dist/cjs/runtime/i18n/backend/middleware.common.js +9 -5
  14. package/dist/cjs/runtime/i18n/backend/middleware.js +9 -5
  15. package/dist/cjs/runtime/i18n/backend/middleware.node.js +9 -5
  16. package/dist/cjs/runtime/i18n/backend/sdk-backend.js +9 -5
  17. package/dist/cjs/runtime/i18n/backend/sdk-event.js +16 -11
  18. package/dist/cjs/runtime/i18n/detection/config.js +9 -5
  19. package/dist/cjs/runtime/i18n/detection/index.js +9 -5
  20. package/dist/cjs/runtime/i18n/detection/middleware.js +9 -5
  21. package/dist/cjs/runtime/i18n/detection/middleware.node.js +9 -5
  22. package/dist/cjs/runtime/i18n/index.js +9 -5
  23. package/dist/cjs/runtime/i18n/instance.js +17 -13
  24. package/dist/cjs/runtime/i18n/react-i18next.js +12 -8
  25. package/dist/cjs/runtime/i18n/utils.js +9 -5
  26. package/dist/cjs/runtime/index.js +32 -5
  27. package/dist/cjs/runtime/localizedPaths.js +102 -0
  28. package/dist/cjs/runtime/routerAdapter.js +11 -7
  29. package/dist/cjs/runtime/utils.js +31 -17
  30. package/dist/cjs/server/index.js +10 -14
  31. package/dist/cjs/shared/deepMerge.js +12 -8
  32. package/dist/cjs/shared/detection.js +9 -5
  33. package/dist/cjs/shared/localisedUrls.js +148 -34
  34. package/dist/cjs/shared/utils.js +15 -11
  35. package/dist/esm/cli/index.mjs +8 -48
  36. package/dist/esm/cli/locales.mjs +80 -0
  37. package/dist/esm/runtime/I18nLink.mjs +7 -14
  38. package/dist/esm/runtime/Link.mjs +221 -0
  39. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  40. package/dist/esm/runtime/i18n/backend/defaults.mjs +6 -2
  41. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +56 -5
  42. package/dist/esm/runtime/index.mjs +4 -2
  43. package/dist/esm/runtime/localizedPaths.mjs +55 -0
  44. package/dist/esm/runtime/routerAdapter.mjs +3 -3
  45. package/dist/esm/runtime/utils.mjs +19 -12
  46. package/dist/esm/server/index.mjs +2 -10
  47. package/dist/esm/shared/localisedUrls.mjs +115 -23
  48. package/dist/esm-node/cli/index.mjs +8 -48
  49. package/dist/esm-node/cli/locales.mjs +81 -0
  50. package/dist/esm-node/runtime/I18nLink.mjs +7 -14
  51. package/dist/esm-node/runtime/Link.mjs +222 -0
  52. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  53. package/dist/esm-node/runtime/i18n/backend/defaults.mjs +6 -2
  54. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +56 -5
  55. package/dist/esm-node/runtime/index.mjs +4 -2
  56. package/dist/esm-node/runtime/localizedPaths.mjs +56 -0
  57. package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
  58. package/dist/esm-node/runtime/utils.mjs +19 -12
  59. package/dist/esm-node/server/index.mjs +2 -10
  60. package/dist/esm-node/shared/localisedUrls.mjs +115 -23
  61. package/dist/types/cli/index.d.ts +1 -0
  62. package/dist/types/cli/locales.d.ts +17 -0
  63. package/dist/types/runtime/I18nLink.d.ts +4 -13
  64. package/dist/types/runtime/Link.d.ts +66 -0
  65. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  66. package/dist/types/runtime/i18n/backend/defaults.d.ts +10 -7
  67. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +13 -4
  68. package/dist/types/runtime/index.d.ts +5 -1
  69. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  70. package/dist/types/runtime/types.d.ts +1 -1
  71. package/dist/types/runtime/utils.d.ts +13 -4
  72. package/dist/types/shared/localisedUrls.d.ts +23 -0
  73. package/dist/types/shared/type.d.ts +27 -5
  74. package/package.json +28 -25
  75. package/rstest.config.mts +7 -2
  76. package/src/cli/index.ts +25 -98
  77. package/src/cli/locales.ts +186 -0
  78. package/src/runtime/I18nLink.tsx +13 -44
  79. package/src/runtime/Link.tsx +430 -0
  80. package/src/runtime/canonicalRoutes.ts +93 -0
  81. package/src/runtime/i18n/backend/defaults.node.ts +112 -7
  82. package/src/runtime/i18n/backend/defaults.ts +20 -18
  83. package/src/runtime/index.tsx +24 -2
  84. package/src/runtime/localizedPaths.ts +107 -0
  85. package/src/runtime/routerAdapter.tsx +4 -5
  86. package/src/runtime/types.ts +1 -1
  87. package/src/runtime/utils.ts +33 -26
  88. package/src/server/index.ts +7 -17
  89. package/src/shared/localisedUrls.ts +256 -26
  90. package/src/shared/type.ts +27 -5
  91. package/tests/backendDefaults.test.ts +51 -0
  92. package/tests/i18nUtils.test.ts +10 -3
  93. package/tests/link.test.tsx +525 -0
  94. package/tests/linkTypes.test.ts +28 -0
  95. package/tests/localisedUrls.test.ts +224 -0
  96. package/tests/routerAdapter.test.tsx +86 -12
  97. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  98. 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
- export const normalisePathPattern = (path: string): string => {
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 withoutTrailingSlash.replace(/\[(.+?)\]/g, ':$1');
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 === false) {
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: true, map: {} };
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
- const compilePathPattern = (pattern: string) => {
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 = normalisePathPattern(pattern).split('/').filter(Boolean);
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
- return {
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 matchPathPattern = (
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(normalisePathPattern(pathname));
428
+ const match = regexp.exec(normalisePathname(pathname));
333
429
  if (!match) {
334
430
  return null;
335
431
  }
336
432
 
337
- return names.reduce<Record<string, string>>((params, name, index) => {
338
- params[name] = decodeURIComponent(match[index + 1] || '');
339
- return params;
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 = normalisePathPattern(pathname);
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 Object.values(localisedUrls)) {
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(targetPattern, params);
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
+ };
@@ -14,12 +14,14 @@ export interface BaseLocaleDetectionOptions {
14
14
  /**
15
15
  * Enables localised pathnames in addition to the locale prefix.
16
16
  *
17
- * - `false`: keep only locale-prefix behavior (`/en/about`).
18
- * - object: map canonical route paths to every configured language.
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
- * Defaults to `true` when `localePathRedirect` is enabled, so route
21
- * generation validates that every localisable route path has entries for all
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
+ });
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from '@rstest/core';
2
2
  import type { I18nInstance } from '../src/runtime/i18n';
3
- import { DEFAULT_I18NEXT_BACKEND_OPTIONS as NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS } from '../src/runtime/i18n/backend/defaults.node';
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('uses the generated public locale directory for node fs backend defaults', () => {
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
- './config/public/locales/{{lng}}/{{ns}}.json',
29
+ `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`,
23
30
  );
24
31
  });
25
32