@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.99 → 3.4.0-ultramodern.1
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { NestedRouteForCli } from '@modern-js/types';
|
|
2
2
|
import { describe, expect, test } from '@rstest/core';
|
|
3
|
+
import { i18nPlugin as i18nCliPlugin } from '../src/cli';
|
|
3
4
|
import {
|
|
4
5
|
collectApiPrefixes,
|
|
5
6
|
i18nServerPlugin,
|
|
@@ -7,7 +8,12 @@ import {
|
|
|
7
8
|
} from '../src/server';
|
|
8
9
|
import {
|
|
9
10
|
applyLocalisedUrlsToRoutes,
|
|
11
|
+
canonicalTargetPathname,
|
|
12
|
+
localiseTargetPathname,
|
|
13
|
+
matchPathPattern,
|
|
14
|
+
resolveCanonicalLocalisedPath,
|
|
10
15
|
resolveLocalisedPath,
|
|
16
|
+
resolveLocalisedUrlsConfig,
|
|
11
17
|
validateLocalisedUrls,
|
|
12
18
|
} from '../src/shared/localisedUrls';
|
|
13
19
|
|
|
@@ -31,6 +37,74 @@ const createRequestContext = (pathname: string) =>
|
|
|
31
37
|
},
|
|
32
38
|
}) as any;
|
|
33
39
|
|
|
40
|
+
describe('resolveLocalisedUrlsConfig', () => {
|
|
41
|
+
test('is opt-in: only a non-empty map enables the feature', () => {
|
|
42
|
+
const map = { '/about': { en: '/about', cs: '/o-nas' } };
|
|
43
|
+
expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('absent, boolean and empty-map options resolve to disabled', () => {
|
|
47
|
+
const disabled = { enabled: false, map: {} };
|
|
48
|
+
expect(resolveLocalisedUrlsConfig(undefined)).toEqual(disabled);
|
|
49
|
+
expect(resolveLocalisedUrlsConfig(false)).toEqual(disabled);
|
|
50
|
+
expect(resolveLocalisedUrlsConfig(true)).toEqual(disabled);
|
|
51
|
+
expect(resolveLocalisedUrlsConfig({})).toEqual(disabled);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('cli modifyFileSystemRoutes', () => {
|
|
56
|
+
const setupModifyRoutes = (localeDetection: Record<string, unknown>) => {
|
|
57
|
+
let modifyRoutes:
|
|
58
|
+
| ((args: { entrypoint: any; routes: any[] }) => {
|
|
59
|
+
entrypoint: any;
|
|
60
|
+
routes: any[];
|
|
61
|
+
})
|
|
62
|
+
| undefined;
|
|
63
|
+
|
|
64
|
+
i18nCliPlugin({ localeDetection }).setup({
|
|
65
|
+
_internalRuntimePlugins: () => {},
|
|
66
|
+
modifyFileSystemRoutes: (fn: any) => {
|
|
67
|
+
modifyRoutes = fn;
|
|
68
|
+
},
|
|
69
|
+
_internalServerPlugins: () => {},
|
|
70
|
+
} as any);
|
|
71
|
+
|
|
72
|
+
expect(modifyRoutes).toBeDefined();
|
|
73
|
+
return modifyRoutes!;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
test('upstream-style configs without a map keep routes untouched', () => {
|
|
77
|
+
const modifyRoutes = setupModifyRoutes({
|
|
78
|
+
localePathRedirect: true,
|
|
79
|
+
languages: ['en', 'cs'],
|
|
80
|
+
});
|
|
81
|
+
const routes = [createRoute(':lang', [createRoute('about')])];
|
|
82
|
+
|
|
83
|
+
const result = modifyRoutes({ entrypoint: { entryName: 'main' }, routes });
|
|
84
|
+
|
|
85
|
+
expect(result.routes).toBe(routes);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a configured map still expands localised route aliases', () => {
|
|
89
|
+
const modifyRoutes = setupModifyRoutes({
|
|
90
|
+
localePathRedirect: true,
|
|
91
|
+
languages: ['en', 'cs'],
|
|
92
|
+
localisedUrls: {
|
|
93
|
+
'/about': { en: '/about', cs: '/o-nas' },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const routes = [createRoute(':lang', [createRoute('about')])];
|
|
97
|
+
|
|
98
|
+
const result = modifyRoutes({ entrypoint: { entryName: 'main' }, routes });
|
|
99
|
+
|
|
100
|
+
const localeRoute = result.routes[0] as NestedRouteForCli;
|
|
101
|
+
expect(localeRoute.children?.map(route => route.path)).toEqual([
|
|
102
|
+
'about',
|
|
103
|
+
'o-nas',
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
34
108
|
describe('localisedUrls', () => {
|
|
35
109
|
test('requires every localisable route path to define every language', () => {
|
|
36
110
|
const routes = [createRoute(':lang', [createRoute('terms-of-service')])];
|
|
@@ -163,6 +237,55 @@ describe('localisedUrls', () => {
|
|
|
163
237
|
).toBe('/products/cervena-bota');
|
|
164
238
|
});
|
|
165
239
|
|
|
240
|
+
test('resolves static patterns before param patterns', () => {
|
|
241
|
+
const localisedUrls = {
|
|
242
|
+
'/products/:slug': {
|
|
243
|
+
en: '/products/:slug',
|
|
244
|
+
cs: '/produkty/:slug',
|
|
245
|
+
},
|
|
246
|
+
'/products/new': {
|
|
247
|
+
en: '/products/new',
|
|
248
|
+
cs: '/produkty/novinka',
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
expect(
|
|
253
|
+
resolveLocalisedPath('/products/new', 'cs', ['en', 'cs'], localisedUrls),
|
|
254
|
+
).toBe('/produkty/novinka');
|
|
255
|
+
expect(
|
|
256
|
+
resolveCanonicalLocalisedPath(
|
|
257
|
+
'/produkty/novinka',
|
|
258
|
+
['en', 'cs'],
|
|
259
|
+
localisedUrls,
|
|
260
|
+
),
|
|
261
|
+
).toBe('/products/new');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('localises and canonicalises full target pathnames through one helper', () => {
|
|
265
|
+
const localisedUrls = {
|
|
266
|
+
'/products/:slug': {
|
|
267
|
+
en: '/products/:slug',
|
|
268
|
+
cs: '/produkty/:slug',
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
expect(
|
|
273
|
+
localiseTargetPathname(
|
|
274
|
+
'/en/products/cervena-bota',
|
|
275
|
+
'cs',
|
|
276
|
+
['en', 'cs'],
|
|
277
|
+
localisedUrls,
|
|
278
|
+
),
|
|
279
|
+
).toBe('/cs/produkty/cervena-bota');
|
|
280
|
+
expect(
|
|
281
|
+
canonicalTargetPathname(
|
|
282
|
+
'/cs/produkty/cervena-bota',
|
|
283
|
+
['en', 'cs'],
|
|
284
|
+
localisedUrls,
|
|
285
|
+
),
|
|
286
|
+
).toBe('/products/cervena-bota');
|
|
287
|
+
});
|
|
288
|
+
|
|
166
289
|
test('resolves nested optional route params with translated ancestors', () => {
|
|
167
290
|
const localisedUrls = {
|
|
168
291
|
'/checkout': {
|
|
@@ -198,6 +321,47 @@ describe('localisedUrls', () => {
|
|
|
198
321
|
});
|
|
199
322
|
});
|
|
200
323
|
|
|
324
|
+
describe('matchPathPattern decoding', () => {
|
|
325
|
+
test('decodes valid percent-encoded params', () => {
|
|
326
|
+
expect(
|
|
327
|
+
matchPathPattern('/produkty/%C4%8Derven%C3%A1', '/produkty/:slug'),
|
|
328
|
+
).toEqual({ slug: 'červená' });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('treats malformed percent-encoding as no match instead of throwing', () => {
|
|
332
|
+
expect(() =>
|
|
333
|
+
matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
|
|
334
|
+
).not.toThrow();
|
|
335
|
+
expect(
|
|
336
|
+
matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
|
|
337
|
+
).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('keeps literal bracket segments in pathnames (no pattern rewrite)', () => {
|
|
341
|
+
expect(matchPathPattern('/produkty/[x]', '/produkty/:slug')).toEqual({
|
|
342
|
+
slug: '[x]',
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('resolveLocalisedPath returns malformed paths unchanged', () => {
|
|
347
|
+
const localisedUrls = {
|
|
348
|
+
'/products/:slug': {
|
|
349
|
+
en: '/products/:slug',
|
|
350
|
+
cs: '/produkty/:slug',
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
expect(
|
|
355
|
+
resolveLocalisedPath(
|
|
356
|
+
'/produkty/%E0%A4%A',
|
|
357
|
+
'en',
|
|
358
|
+
['en', 'cs'],
|
|
359
|
+
localisedUrls,
|
|
360
|
+
),
|
|
361
|
+
).toBe('/produkty/%E0%A4%A');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
201
365
|
describe('i18n server API prefix skips', () => {
|
|
202
366
|
test('collects API route prefixes and normalized BFF config prefixes', () => {
|
|
203
367
|
expect(
|
|
@@ -272,6 +436,66 @@ describe('i18n server API prefix skips', () => {
|
|
|
272
436
|
}
|
|
273
437
|
});
|
|
274
438
|
|
|
439
|
+
test('canonical redirect survives malformed percent-encoding', async () => {
|
|
440
|
+
const middlewares: any[] = [];
|
|
441
|
+
const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
|
|
442
|
+
let prepare: (() => void) | undefined;
|
|
443
|
+
|
|
444
|
+
i18nServerPlugin({
|
|
445
|
+
localeDetection: {
|
|
446
|
+
fallbackLanguage: 'en',
|
|
447
|
+
languages: ['en', 'cs'],
|
|
448
|
+
localePathRedirect: true,
|
|
449
|
+
localisedUrls: {
|
|
450
|
+
'/products/:slug': {
|
|
451
|
+
en: '/products/:slug',
|
|
452
|
+
cs: '/produkty/:slug',
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
staticRoutePrefixes: [],
|
|
457
|
+
}).setup({
|
|
458
|
+
getServerConfig: () => ({}),
|
|
459
|
+
getServerContext: () => ({ middlewares, routes }),
|
|
460
|
+
onPrepare: fn => {
|
|
461
|
+
prepare = fn;
|
|
462
|
+
},
|
|
463
|
+
} as any);
|
|
464
|
+
|
|
465
|
+
prepare?.();
|
|
466
|
+
|
|
467
|
+
const redirectMiddleware = middlewares.find(
|
|
468
|
+
middleware => middleware.name === 'i18n-server-middleware',
|
|
469
|
+
);
|
|
470
|
+
const createContext = (pathname: string) =>
|
|
471
|
+
({
|
|
472
|
+
req: {
|
|
473
|
+
url: `http://localhost${pathname}`,
|
|
474
|
+
header: () => ({ host: 'localhost' }),
|
|
475
|
+
},
|
|
476
|
+
get: () => null,
|
|
477
|
+
redirect: (url: string) => ({ redirectedTo: url }),
|
|
478
|
+
}) as any;
|
|
479
|
+
|
|
480
|
+
// Sanity: well-formed non-canonical slugs still redirect.
|
|
481
|
+
const redirected = await redirectMiddleware.handler(
|
|
482
|
+
createContext('/cs/products/bota'),
|
|
483
|
+
async () => {},
|
|
484
|
+
);
|
|
485
|
+
expect(redirected).toEqual({ redirectedTo: '/cs/produkty/bota' });
|
|
486
|
+
|
|
487
|
+
// Malformed encoding must fall through to next() instead of throwing.
|
|
488
|
+
let nextCalls = 0;
|
|
489
|
+
const response = await redirectMiddleware.handler(
|
|
490
|
+
createContext('/cs/produkty/%E0%A4%A'),
|
|
491
|
+
async () => {
|
|
492
|
+
nextCalls++;
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
expect(response).toBeUndefined();
|
|
496
|
+
expect(nextCalls).toBe(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
275
499
|
test('uses /api as the BFF prefix when BFF config is present without prefix', async () => {
|
|
276
500
|
const middlewares: any[] = [];
|
|
277
501
|
const routes = [{ entryName: 'main', entryPath: '', urlPath: '/' }];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
applyRouterRuntimeState,
|
|
2
3
|
InternalRuntimeContext,
|
|
3
4
|
RuntimeContext,
|
|
4
5
|
} from '@modern-js/runtime/context';
|
|
@@ -28,11 +29,18 @@ const requestContext = {
|
|
|
28
29
|
response: {},
|
|
29
30
|
};
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
const capturedTanstackLinkProps: any[] = [];
|
|
33
|
+
|
|
34
|
+
const TanstackLink = ({ to, children, ...props }: any) => {
|
|
35
|
+
capturedTanstackLinkProps.push({ to, ...props });
|
|
36
|
+
const { prefetch: _prefetch, preload: _preload, ...anchorProps } = props;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<a href={to} data-router-link="tanstack" {...anchorProps}>
|
|
40
|
+
{children}
|
|
41
|
+
</a>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
36
44
|
|
|
37
45
|
function createI18nInstance(language = 'en'): I18nInstance {
|
|
38
46
|
return {
|
|
@@ -52,22 +60,23 @@ function createRuntimeContext(
|
|
|
52
60
|
router: unknown,
|
|
53
61
|
framework: 'tanstack' | 'react-router',
|
|
54
62
|
) {
|
|
55
|
-
|
|
63
|
+
const context = {
|
|
56
64
|
isBrowser: true,
|
|
57
65
|
requestContext,
|
|
58
66
|
context: requestContext,
|
|
59
|
-
routerFramework: framework,
|
|
60
|
-
routerInstance: router,
|
|
61
|
-
routerRuntime: {
|
|
62
|
-
framework,
|
|
63
|
-
instance: router,
|
|
64
|
-
},
|
|
65
67
|
router: {
|
|
66
68
|
...(framework === 'tanstack'
|
|
67
69
|
? { Link: TanstackLink, useRouter: () => router }
|
|
68
70
|
: { useLocation: () => undefined, useHref: () => undefined }),
|
|
69
71
|
},
|
|
70
72
|
} as any;
|
|
73
|
+
|
|
74
|
+
applyRouterRuntimeState(context, {
|
|
75
|
+
framework,
|
|
76
|
+
instance: router,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return context;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
function createTanstackRuntimeContext(router: unknown) {
|
|
@@ -236,6 +245,7 @@ describe('i18n router adapter', () => {
|
|
|
236
245
|
afterEach(() => {
|
|
237
246
|
cleanup(rendered);
|
|
238
247
|
rendered = undefined;
|
|
248
|
+
capturedTanstackLinkProps.length = 0;
|
|
239
249
|
window.history.replaceState(null, '', '/');
|
|
240
250
|
});
|
|
241
251
|
|
|
@@ -265,6 +275,70 @@ describe('i18n router adapter', () => {
|
|
|
265
275
|
expect(link?.getAttribute('data-router-link')).toBe('tanstack');
|
|
266
276
|
});
|
|
267
277
|
|
|
278
|
+
test('forwards warmup props through I18nLink with a localized string target', async () => {
|
|
279
|
+
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
280
|
+
rendered = await renderWithRuntime(
|
|
281
|
+
<ModernI18nProvider
|
|
282
|
+
value={{
|
|
283
|
+
language: 'cs',
|
|
284
|
+
i18nInstance: createI18nInstance('cs'),
|
|
285
|
+
languages: ['en', 'cs'],
|
|
286
|
+
localePathRedirect: true,
|
|
287
|
+
localisedUrls,
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<I18nLink
|
|
291
|
+
to="/terms-of-service"
|
|
292
|
+
data-testid="terms-link"
|
|
293
|
+
prefetch="viewport"
|
|
294
|
+
preload="intent"
|
|
295
|
+
>
|
|
296
|
+
Terms
|
|
297
|
+
</I18nLink>
|
|
298
|
+
</ModernI18nProvider>,
|
|
299
|
+
createTanstackRuntimeContext(router),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const linkProps = capturedTanstackLinkProps.at(-1);
|
|
303
|
+
// TanStack has no `prefetch` prop: the explicit native `preload` wins and
|
|
304
|
+
// `prefetch` must not be forwarded.
|
|
305
|
+
expect(linkProps).toMatchObject({
|
|
306
|
+
to: '/cs/podminky-pouzivani',
|
|
307
|
+
preload: 'intent',
|
|
308
|
+
});
|
|
309
|
+
expect(linkProps.prefetch).toBeUndefined();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('does not leak warmup props to fallback anchors', async () => {
|
|
313
|
+
rendered = await renderI18nRoot(
|
|
314
|
+
<ModernI18nProvider
|
|
315
|
+
value={{
|
|
316
|
+
language: 'cs',
|
|
317
|
+
i18nInstance: createI18nInstance('cs'),
|
|
318
|
+
languages: ['en', 'cs'],
|
|
319
|
+
localePathRedirect: true,
|
|
320
|
+
localisedUrls,
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
<I18nLink
|
|
324
|
+
to="/terms-of-service"
|
|
325
|
+
data-testid="terms-link"
|
|
326
|
+
prefetch="none"
|
|
327
|
+
preload={false}
|
|
328
|
+
>
|
|
329
|
+
Terms
|
|
330
|
+
</I18nLink>
|
|
331
|
+
</ModernI18nProvider>,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const link = rendered.container.querySelector<HTMLAnchorElement>(
|
|
335
|
+
'[data-testid="terms-link"]',
|
|
336
|
+
);
|
|
337
|
+
expect(link?.getAttribute('href')).toBe('/cs/podminky-pouzivani');
|
|
338
|
+
expect(link?.hasAttribute('prefetch')).toBe(false);
|
|
339
|
+
expect(link?.hasAttribute('preload')).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
268
342
|
test('uses TanStack-shaped replacement when changeLanguage updates the URL', async () => {
|
|
269
343
|
window.history.replaceState(
|
|
270
344
|
null,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Link } from '@modern-js/plugin-i18n/runtime';
|
|
2
|
+
|
|
3
|
+
declare module '@modern-js/plugin-i18n/runtime' {
|
|
4
|
+
interface UltramodernCanonicalRoutes {
|
|
5
|
+
'/': Record<string, never>;
|
|
6
|
+
'/talks': Record<string, never>;
|
|
7
|
+
'/talks/$slug': { slug: string };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// --- valid uses ---
|
|
12
|
+
|
|
13
|
+
// Known route with required params: must compile.
|
|
14
|
+
const _a = <Link to="/talks/$slug" params={{ slug: 'x' }} />;
|
|
15
|
+
|
|
16
|
+
// Known route without params: must compile.
|
|
17
|
+
const _b = <Link to="/talks" />;
|
|
18
|
+
|
|
19
|
+
// Root route: must compile.
|
|
20
|
+
const _c = <Link to="/" />;
|
|
21
|
+
|
|
22
|
+
// Canonical path with hash suffix: must compile.
|
|
23
|
+
const _d = <Link to="/#work-with-me" />;
|
|
24
|
+
|
|
25
|
+
// Canonical path with query and hash: must compile.
|
|
26
|
+
const _e = <Link to="/talks?tag=x#abstract" />;
|
|
27
|
+
|
|
28
|
+
// Bare hash: must compile.
|
|
29
|
+
const _f = <Link to="#hash" />;
|
|
30
|
+
|
|
31
|
+
// External HTTPS URL: must compile.
|
|
32
|
+
const _g = <Link to="https://example.com" />;
|
|
33
|
+
|
|
34
|
+
// Dynamic (computed) value — escape hatch: must compile.
|
|
35
|
+
declare function compute(): string;
|
|
36
|
+
const dynamic: string = compute();
|
|
37
|
+
const _h = <Link to={dynamic} />;
|
|
38
|
+
|
|
39
|
+
// --- invalid uses (each preceded by @ts-expect-error) ---
|
|
40
|
+
|
|
41
|
+
// Unknown route.
|
|
42
|
+
// @ts-expect-error
|
|
43
|
+
const _i = <Link to="/talkz" />;
|
|
44
|
+
|
|
45
|
+
// Known param route but params prop is missing.
|
|
46
|
+
// @ts-expect-error
|
|
47
|
+
const _j = <Link to="/talks/$slug" />;
|
|
48
|
+
|
|
49
|
+
// Known route without params but params are provided (forbidden).
|
|
50
|
+
// @ts-expect-error
|
|
51
|
+
const _k = <Link to="/talks" params={{ slug: 'x' }} />;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"jsx": "react-jsx",
|
|
4
|
+
"strict": true,
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"lib": ["ESNext", "DOM"],
|
|
10
|
+
"paths": {
|
|
11
|
+
"@modern-js/plugin-i18n/runtime": ["../../src/runtime/index.tsx"]
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"include": ["*.tsx", "*.ts"]
|
|
15
|
+
}
|