@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.
Files changed (41) hide show
  1. package/dist/cjs/runtime/Link.js +33 -21
  2. package/dist/cjs/runtime/i18n/backend/defaults.node.js +42 -8
  3. package/dist/cjs/runtime/localizedPaths.js +1 -4
  4. package/dist/cjs/runtime/routerAdapter.js +2 -2
  5. package/dist/cjs/runtime/utils.js +2 -9
  6. package/dist/cjs/server/index.js +1 -9
  7. package/dist/cjs/shared/localisedUrls.js +107 -27
  8. package/dist/esm/runtime/Link.mjs +33 -21
  9. package/dist/esm/runtime/i18n/backend/defaults.node.mjs +24 -3
  10. package/dist/esm/runtime/localizedPaths.mjs +2 -5
  11. package/dist/esm/runtime/routerAdapter.mjs +3 -3
  12. package/dist/esm/runtime/utils.mjs +3 -10
  13. package/dist/esm/server/index.mjs +2 -10
  14. package/dist/esm/shared/localisedUrls.mjs +99 -28
  15. package/dist/esm-node/runtime/Link.mjs +33 -21
  16. package/dist/esm-node/runtime/i18n/backend/defaults.node.mjs +24 -3
  17. package/dist/esm-node/runtime/localizedPaths.mjs +2 -5
  18. package/dist/esm-node/runtime/routerAdapter.mjs +3 -3
  19. package/dist/esm-node/runtime/utils.mjs +3 -10
  20. package/dist/esm-node/server/index.mjs +2 -10
  21. package/dist/esm-node/shared/localisedUrls.mjs +99 -28
  22. package/dist/types/runtime/Link.d.ts +10 -0
  23. package/dist/types/runtime/i18n/backend/defaults.node.d.ts +3 -2
  24. package/dist/types/runtime/utils.d.ts +2 -2
  25. package/dist/types/shared/localisedUrls.d.ts +15 -0
  26. package/dist/types/shared/type.d.ts +7 -5
  27. package/package.json +16 -12
  28. package/rstest.config.mts +6 -1
  29. package/src/runtime/Link.tsx +28 -12
  30. package/src/runtime/i18n/backend/defaults.node.ts +40 -2
  31. package/src/runtime/localizedPaths.ts +6 -17
  32. package/src/runtime/routerAdapter.tsx +4 -5
  33. package/src/runtime/utils.ts +11 -23
  34. package/src/server/index.ts +7 -17
  35. package/src/shared/localisedUrls.ts +212 -42
  36. package/src/shared/type.ts +7 -5
  37. package/tests/backendDefaults.test.ts +51 -0
  38. package/tests/i18nUtils.test.ts +10 -3
  39. package/tests/link.test.tsx +51 -1
  40. package/tests/localisedUrls.test.ts +224 -0
  41. 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
- return {
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 () => {