@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.120 → 3.2.0-ultramodern.122
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
|
@@ -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';
|
|
@@ -59,22 +60,23 @@ function createRuntimeContext(
|
|
|
59
60
|
router: unknown,
|
|
60
61
|
framework: 'tanstack' | 'react-router',
|
|
61
62
|
) {
|
|
62
|
-
|
|
63
|
+
const context = {
|
|
63
64
|
isBrowser: true,
|
|
64
65
|
requestContext,
|
|
65
66
|
context: requestContext,
|
|
66
|
-
routerFramework: framework,
|
|
67
|
-
routerInstance: router,
|
|
68
|
-
routerRuntime: {
|
|
69
|
-
framework,
|
|
70
|
-
instance: router,
|
|
71
|
-
},
|
|
72
67
|
router: {
|
|
73
68
|
...(framework === 'tanstack'
|
|
74
69
|
? { Link: TanstackLink, useRouter: () => router }
|
|
75
70
|
: { useLocation: () => undefined, useHref: () => undefined }),
|
|
76
71
|
},
|
|
77
72
|
} as any;
|
|
73
|
+
|
|
74
|
+
applyRouterRuntimeState(context, {
|
|
75
|
+
framework,
|
|
76
|
+
instance: router,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return context;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
function createTanstackRuntimeContext(router: unknown) {
|
|
@@ -298,11 +300,13 @@ describe('i18n router adapter', () => {
|
|
|
298
300
|
);
|
|
299
301
|
|
|
300
302
|
const linkProps = capturedTanstackLinkProps.at(-1);
|
|
303
|
+
// TanStack has no `prefetch` prop: the explicit native `preload` wins and
|
|
304
|
+
// `prefetch` must not be forwarded.
|
|
301
305
|
expect(linkProps).toMatchObject({
|
|
302
306
|
to: '/cs/podminky-pouzivani',
|
|
303
|
-
prefetch: 'viewport',
|
|
304
307
|
preload: 'intent',
|
|
305
308
|
});
|
|
309
|
+
expect(linkProps.prefetch).toBeUndefined();
|
|
306
310
|
});
|
|
307
311
|
|
|
308
312
|
test('does not leak warmup props to fallback anchors', async () => {
|