@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.120 → 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/dist/cjs/runtime/Link.js +33 -21
- package/dist/cjs/runtime/i18n/backend/defaults.node.js +42 -8
- package/dist/cjs/runtime/localizedPaths.js +1 -4
- package/dist/cjs/runtime/routerAdapter.js +2 -2
- package/dist/cjs/runtime/utils.js +2 -9
- package/dist/cjs/server/index.js +1 -9
- package/dist/cjs/shared/localisedUrls.js +107 -27
- package/dist/esm/runtime/Link.mjs +33 -21
- package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
- package/dist/esm/runtime/localizedPaths.mjs +2 -5
- package/dist/esm/runtime/routerAdapter.mjs +3 -3
- package/dist/esm/runtime/utils.mjs +3 -10
- package/dist/esm/server/index.mjs +2 -10
- package/dist/esm/shared/localisedUrls.mjs +99 -28
- package/dist/esm-node/runtime/Link.mjs +33 -21
- package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
- package/dist/esm-node/runtime/localizedPaths.mjs +2 -5
- package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
- package/dist/esm-node/runtime/utils.mjs +3 -10
- package/dist/esm-node/server/index.mjs +2 -10
- package/dist/esm-node/shared/localisedUrls.mjs +99 -28
- package/dist/types/runtime/Link.d.ts +10 -0
- package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
- package/dist/types/runtime/utils.d.ts +2 -2
- package/dist/types/shared/localisedUrls.d.ts +15 -0
- package/dist/types/shared/type.d.ts +7 -5
- package/package.json +16 -12
- package/rstest.config.mts +6 -1
- package/src/runtime/Link.tsx +28 -12
- package/src/runtime/i18n/backend/defaults.node.ts +40 -2
- package/src/runtime/localizedPaths.ts +6 -17
- package/src/runtime/routerAdapter.tsx +4 -5
- package/src/runtime/utils.ts +11 -23
- package/src/server/index.ts +7 -17
- package/src/shared/localisedUrls.ts +212 -42
- package/src/shared/type.ts +7 -5
- package/tests/backendDefaults.test.ts +51 -0
- package/tests/i18nUtils.test.ts +10 -3
- package/tests/link.test.tsx +51 -1
- package/tests/localisedUrls.test.ts +224 -0
- package/tests/routerAdapter.test.tsx +12 -8
|
@@ -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 => {
|
|
@@ -303,9 +315,22 @@ const escapeRegExp = (value: string): string =>
|
|
|
303
315
|
const getParamName = (segment: string): string =>
|
|
304
316
|
segment.slice(1).replace(/\?$/, '');
|
|
305
317
|
|
|
306
|
-
|
|
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
|
+
|
|
307
332
|
const names: string[] = [];
|
|
308
|
-
const segments =
|
|
333
|
+
const segments = normalizedPattern.split('/').filter(Boolean);
|
|
309
334
|
const source = segments
|
|
310
335
|
.map(segment => {
|
|
311
336
|
if (segment.startsWith(':')) {
|
|
@@ -323,10 +348,76 @@ const compilePathPattern = (pattern: string) => {
|
|
|
323
348
|
})
|
|
324
349
|
.join('');
|
|
325
350
|
|
|
326
|
-
|
|
351
|
+
const compiled = {
|
|
327
352
|
names,
|
|
328
353
|
regexp: new RegExp(`^${source || '/'}$`),
|
|
329
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
|
+
}
|
|
330
421
|
};
|
|
331
422
|
|
|
332
423
|
export const matchPathPattern = (
|
|
@@ -334,15 +425,22 @@ export const matchPathPattern = (
|
|
|
334
425
|
pattern: string,
|
|
335
426
|
): Record<string, string> | null => {
|
|
336
427
|
const { names, regexp } = compilePathPattern(pattern);
|
|
337
|
-
const match = regexp.exec(
|
|
428
|
+
const match = regexp.exec(normalisePathname(pathname));
|
|
338
429
|
if (!match) {
|
|
339
430
|
return null;
|
|
340
431
|
}
|
|
341
432
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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;
|
|
346
444
|
};
|
|
347
445
|
|
|
348
446
|
export const buildPathFromPattern = (
|
|
@@ -373,13 +471,21 @@ export const resolveLocalisedPath = (
|
|
|
373
471
|
languages: string[],
|
|
374
472
|
localisedUrls: LocalisedUrlsMap,
|
|
375
473
|
): string => {
|
|
376
|
-
const normalizedPathname =
|
|
474
|
+
const normalizedPathname = normalisePathname(pathname);
|
|
377
475
|
|
|
378
476
|
// Canonical keys take precedence: authors write language-agnostic paths,
|
|
379
477
|
// which are the map keys, even when no language pattern equals the key.
|
|
380
|
-
|
|
381
|
-
localisedUrls
|
|
382
|
-
|
|
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) {
|
|
383
489
|
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
384
490
|
if (!targetPattern) {
|
|
385
491
|
continue;
|
|
@@ -391,22 +497,30 @@ export const resolveLocalisedPath = (
|
|
|
391
497
|
}
|
|
392
498
|
}
|
|
393
499
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
for (const language of languages) {
|
|
401
|
-
const sourcePattern = localisedUrlEntry[language];
|
|
402
|
-
if (!sourcePattern) {
|
|
403
|
-
continue;
|
|
500
|
+
const localisedCandidates = sortPatternsBySpecificity(
|
|
501
|
+
Object.values(localisedUrls).flatMap(localisedUrlEntry => {
|
|
502
|
+
const targetPattern = localisedUrlEntry[targetLanguage];
|
|
503
|
+
if (!targetPattern) {
|
|
504
|
+
return [];
|
|
404
505
|
}
|
|
405
506
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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);
|
|
410
524
|
}
|
|
411
525
|
}
|
|
412
526
|
|
|
@@ -423,11 +537,19 @@ export const resolveCanonicalLocalisedPath = (
|
|
|
423
537
|
languages: string[],
|
|
424
538
|
localisedUrls: LocalisedUrlsMap,
|
|
425
539
|
): string => {
|
|
426
|
-
const normalizedPathname =
|
|
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
|
+
);
|
|
427
551
|
|
|
428
|
-
for (const
|
|
429
|
-
localisedUrls,
|
|
430
|
-
)) {
|
|
552
|
+
for (const { canonicalPattern, localisedUrlEntry } of canonicalCandidates) {
|
|
431
553
|
const canonicalParams = matchPathPattern(
|
|
432
554
|
normalizedPathname,
|
|
433
555
|
canonicalPattern,
|
|
@@ -451,3 +573,51 @@ export const resolveCanonicalLocalisedPath = (
|
|
|
451
573
|
|
|
452
574
|
return normalizedPathname;
|
|
453
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
|
}
|
|
@@ -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
|
|
package/tests/link.test.tsx
CHANGED
|
@@ -46,10 +46,13 @@ const requestContext = {
|
|
|
46
46
|
|
|
47
47
|
const capturedLinkProps: any[] = [];
|
|
48
48
|
|
|
49
|
+
// Mirrors the real TanStack Link contract: it consumes its own props
|
|
50
|
+
// (`preload`, `search`, `hash`, ...) and spreads everything else onto the
|
|
51
|
+
// anchor. Deliberately does NOT strip `prefetch` — TanStack has no such prop,
|
|
52
|
+
// so a forwarded `prefetch` would leak into the DOM and fail assertions.
|
|
49
53
|
const TanstackLink = ({ to, children, ...props }: any) => {
|
|
50
54
|
capturedLinkProps.push({ to, ...props });
|
|
51
55
|
const {
|
|
52
|
-
prefetch: _prefetch,
|
|
53
56
|
preload: _preload,
|
|
54
57
|
search: _search,
|
|
55
58
|
hash: _hash,
|
|
@@ -378,6 +381,53 @@ describe('framework Link', () => {
|
|
|
378
381
|
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
379
382
|
});
|
|
380
383
|
|
|
384
|
+
test('maps prefetch to the TanStack preload prop', async () => {
|
|
385
|
+
const router = createTanstackRouter('/en/products', 'en');
|
|
386
|
+
rendered = await renderWithRuntime(
|
|
387
|
+
<ModernI18nProvider value={providerValue('en')}>
|
|
388
|
+
<Link to="/products" data-testid="pf" prefetch="intent">
|
|
389
|
+
Products
|
|
390
|
+
</Link>
|
|
391
|
+
</ModernI18nProvider>,
|
|
392
|
+
createTanstackRuntimeContext(router),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const props = capturedLinkProps.at(-1);
|
|
396
|
+
expect(props.preload).toBe('intent');
|
|
397
|
+
expect(props.prefetch).toBeUndefined();
|
|
398
|
+
|
|
399
|
+
const link = rendered.container.querySelector('[data-testid="pf"]');
|
|
400
|
+
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('maps prefetch="none" to preload={false}; explicit preload wins', async () => {
|
|
404
|
+
const router = createTanstackRouter('/en/products', 'en');
|
|
405
|
+
rendered = await renderWithRuntime(
|
|
406
|
+
<ModernI18nProvider value={providerValue('en')}>
|
|
407
|
+
<Link to="/products" data-testid="none" prefetch="none">
|
|
408
|
+
Products
|
|
409
|
+
</Link>
|
|
410
|
+
<Link
|
|
411
|
+
to="/products"
|
|
412
|
+
data-testid="explicit"
|
|
413
|
+
prefetch="intent"
|
|
414
|
+
preload="viewport"
|
|
415
|
+
>
|
|
416
|
+
Products
|
|
417
|
+
</Link>
|
|
418
|
+
</ModernI18nProvider>,
|
|
419
|
+
createTanstackRuntimeContext(router),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const noneProps = capturedLinkProps[capturedLinkProps.length - 2];
|
|
423
|
+
expect(noneProps.preload).toBe(false);
|
|
424
|
+
expect(noneProps.prefetch).toBeUndefined();
|
|
425
|
+
|
|
426
|
+
const explicitProps = capturedLinkProps.at(-1);
|
|
427
|
+
expect(explicitProps.preload).toBe('viewport');
|
|
428
|
+
expect(explicitProps.prefetch).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
381
431
|
test('marks the canonical target active on any localized variant', async () => {
|
|
382
432
|
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
383
433
|
rendered = await renderWithRuntime(
|