@bleedingdev/modern-js-i18n-runtime-extensions 3.8.3-ultramodern.2

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/index.js +76 -0
  3. package/dist/cjs/language-sync/controller.js +241 -0
  4. package/dist/cjs/language-sync/react.js +169 -0
  5. package/dist/cjs/localisedUrls/config.js +49 -0
  6. package/dist/cjs/localisedUrls/index.js +91 -0
  7. package/dist/cjs/localisedUrls/normalise.js +87 -0
  8. package/dist/cjs/localisedUrls/pathname.js +68 -0
  9. package/dist/cjs/localisedUrls/patterns.js +214 -0
  10. package/dist/cjs/localisedUrls/redirect.js +103 -0
  11. package/dist/cjs/localisedUrls/resolve.js +78 -0
  12. package/dist/cjs/localisedUrls/routes.js +202 -0
  13. package/dist/cjs/localisedUrls/types.js +18 -0
  14. package/dist/esm/index.mjs +3 -0
  15. package/dist/esm/language-sync/controller.mjs +203 -0
  16. package/dist/esm/language-sync/react.mjs +118 -0
  17. package/dist/esm/localisedUrls/config.mjs +11 -0
  18. package/dist/esm/localisedUrls/index.mjs +7 -0
  19. package/dist/esm/localisedUrls/normalise.mjs +37 -0
  20. package/dist/esm/localisedUrls/pathname.mjs +24 -0
  21. package/dist/esm/localisedUrls/patterns.mjs +152 -0
  22. package/dist/esm/localisedUrls/redirect.mjs +56 -0
  23. package/dist/esm/localisedUrls/resolve.mjs +37 -0
  24. package/dist/esm/localisedUrls/routes.mjs +161 -0
  25. package/dist/esm/localisedUrls/types.mjs +0 -0
  26. package/dist/esm-node/index.mjs +4 -0
  27. package/dist/esm-node/language-sync/controller.mjs +204 -0
  28. package/dist/esm-node/language-sync/react.mjs +119 -0
  29. package/dist/esm-node/localisedUrls/config.mjs +12 -0
  30. package/dist/esm-node/localisedUrls/index.mjs +8 -0
  31. package/dist/esm-node/localisedUrls/normalise.mjs +38 -0
  32. package/dist/esm-node/localisedUrls/pathname.mjs +25 -0
  33. package/dist/esm-node/localisedUrls/patterns.mjs +153 -0
  34. package/dist/esm-node/localisedUrls/redirect.mjs +57 -0
  35. package/dist/esm-node/localisedUrls/resolve.mjs +38 -0
  36. package/dist/esm-node/localisedUrls/routes.mjs +162 -0
  37. package/dist/esm-node/localisedUrls/types.mjs +1 -0
  38. package/dist/types/index.d.ts +3 -0
  39. package/dist/types/language-sync/controller.d.ts +26 -0
  40. package/dist/types/language-sync/react.d.ts +38 -0
  41. package/dist/types/localisedUrls/config.d.ts +9 -0
  42. package/dist/types/localisedUrls/index.d.ts +9 -0
  43. package/dist/types/localisedUrls/normalise.d.ts +10 -0
  44. package/dist/types/localisedUrls/pathname.d.ts +4 -0
  45. package/dist/types/localisedUrls/patterns.d.ts +11 -0
  46. package/dist/types/localisedUrls/redirect.d.ts +11 -0
  47. package/dist/types/localisedUrls/resolve.d.ts +7 -0
  48. package/dist/types/localisedUrls/routes.d.ts +3 -0
  49. package/dist/types/localisedUrls/types.d.ts +14 -0
  50. package/package.json +71 -0
  51. package/rslib.config.mts +4 -0
  52. package/rstest.config.mts +36 -0
  53. package/src/index.ts +3 -0
  54. package/src/language-sync/controller.ts +386 -0
  55. package/src/language-sync/react.ts +227 -0
  56. package/src/localisedUrls/config.ts +18 -0
  57. package/src/localisedUrls/index.ts +24 -0
  58. package/src/localisedUrls/normalise.ts +62 -0
  59. package/src/localisedUrls/pathname.ts +60 -0
  60. package/src/localisedUrls/patterns.ts +283 -0
  61. package/src/localisedUrls/redirect.ts +93 -0
  62. package/src/localisedUrls/resolve.ts +106 -0
  63. package/src/localisedUrls/routes.ts +422 -0
  64. package/src/localisedUrls/types.ts +18 -0
  65. package/tests/clientRedirectIntegration.test.tsx +74 -0
  66. package/tests/languageSyncController.test.ts +213 -0
  67. package/tests/localisedUrls.test.ts +673 -0
  68. package/tests/reactLanguageSync.test.tsx +117 -0
  69. package/tests/tsconfig.json +11 -0
  70. package/tsconfig.json +11 -0
@@ -0,0 +1,673 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import type { LocalisedRoute } from '../src/localisedUrls/index';
3
+ import {
4
+ applyLocalisedUrlsToRoutes,
5
+ canonicalTargetPathname,
6
+ localiseTargetPathname,
7
+ matchPathPattern,
8
+ resolveCanonicalLocalisedPath,
9
+ resolveLocalisedPath,
10
+ resolveLocalisedUrlsConfig,
11
+ validateLocalisedUrls,
12
+ } from '../src/localisedUrls/index';
13
+
14
+ const createRoute = (
15
+ path: string,
16
+ children?: LocalisedRoute[],
17
+ ): LocalisedRoute => ({
18
+ id: path,
19
+ path,
20
+ type: 'nested',
21
+ origin: 'file-system',
22
+ routeType: children ? 'layout' : 'page',
23
+ _component: `${path}.tsx`,
24
+ children,
25
+ });
26
+
27
+ describe('resolveLocalisedUrlsConfig', () => {
28
+ test('is opt-in: only a non-empty map enables the feature', () => {
29
+ const map = { '/about': { en: '/about', cs: '/o-nas' } };
30
+ expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
31
+ });
32
+
33
+ test('absent, boolean and empty-map options resolve to disabled', () => {
34
+ const disabled = { enabled: false, map: {} };
35
+ expect(resolveLocalisedUrlsConfig(undefined)).toEqual(disabled);
36
+ expect(resolveLocalisedUrlsConfig(false)).toEqual(disabled);
37
+ expect(resolveLocalisedUrlsConfig(true)).toEqual(disabled);
38
+ expect(resolveLocalisedUrlsConfig({})).toEqual(disabled);
39
+ });
40
+ });
41
+
42
+ describe('localisedUrls', () => {
43
+ test('leaves framework-internal Module Federation routes unlocalised', () => {
44
+ const internalRoute = createRoute('_mf', [
45
+ createRoute('fragment', [createRoute('product-card')]),
46
+ ]);
47
+ const publicRoute = createRoute(':lang', [createRoute('products')]);
48
+ const routes = [internalRoute, publicRoute];
49
+
50
+ expect(() =>
51
+ validateLocalisedUrls(routes, ['en', 'cs'], {
52
+ '/products': { en: '/products', cs: '/produkty' },
53
+ }),
54
+ ).not.toThrow();
55
+ expect(
56
+ applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
57
+ '/products': { en: '/products', cs: '/produkty' },
58
+ })[0],
59
+ ).toEqual(internalRoute);
60
+ });
61
+
62
+ test('requires every localisable route path to define every language', () => {
63
+ const routes = [createRoute(':lang', [createRoute('terms-of-service')])];
64
+
65
+ expect(() =>
66
+ validateLocalisedUrls(routes, ['en', 'cs'], {
67
+ '/terms-of-service': {
68
+ en: '/terms-of-service',
69
+ },
70
+ }),
71
+ ).toThrow('missing languages: cs');
72
+ });
73
+
74
+ test('requires canonical and localised patterns to keep the same parameter signature', () => {
75
+ const routes = [createRoute('/:lang/products/:slug')];
76
+
77
+ expect(() =>
78
+ validateLocalisedUrls(routes, ['en', 'cs'], {
79
+ '/products/:slug': {
80
+ en: '/products/:slug',
81
+ cs: '/produkty/:produkt',
82
+ },
83
+ }),
84
+ ).toThrow(
85
+ 'localisedUrls["/products/:slug"].cs must use the same parameter signature',
86
+ );
87
+ });
88
+
89
+ test('allows locales to reorder the same named parameters', () => {
90
+ const routes = [createRoute('/:lang/catalog/:category/:slug')];
91
+ const localisedUrls = {
92
+ '/catalog/:category/:slug': {
93
+ en: '/catalog/:category/:slug',
94
+ cs: '/katalog/:slug/kategorie/:category',
95
+ },
96
+ };
97
+
98
+ expect(() =>
99
+ validateLocalisedUrls(routes, ['en', 'cs'], localisedUrls),
100
+ ).not.toThrow();
101
+ expect(
102
+ resolveLocalisedPath(
103
+ '/catalog/shoes/red',
104
+ 'cs',
105
+ ['en', 'cs'],
106
+ localisedUrls,
107
+ ),
108
+ ).toBe('/katalog/red/kategorie/shoes');
109
+ });
110
+
111
+ test('rejects reordering optional parameters whose position affects matching', () => {
112
+ const routes = [createRoute('/:lang/catalog/:category?/:slug?')];
113
+
114
+ expect(() =>
115
+ validateLocalisedUrls(routes, ['en', 'cs'], {
116
+ '/catalog/:category?/:slug?': {
117
+ en: '/catalog/:category?/:slug?',
118
+ cs: '/katalog/:slug?/:category?',
119
+ },
120
+ }),
121
+ ).toThrow('must keep optional and splat parameters in canonical order');
122
+ });
123
+
124
+ test('allows languages of one canonical route to share a physical alias', () => {
125
+ const routes = [createRoute('/:lang/about')];
126
+
127
+ expect(
128
+ applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
129
+ '/about': {
130
+ en: '/about',
131
+ cs: '/about',
132
+ },
133
+ }).map(route => route.path),
134
+ ).toEqual([':lang/about']);
135
+ });
136
+
137
+ test('rejects distinct canonical routes that generate the same physical path', () => {
138
+ const routes = [
139
+ createRoute('/:lang/products/:slug'),
140
+ createRoute('/:lang/articles/:article'),
141
+ ];
142
+
143
+ expect(() =>
144
+ validateLocalisedUrls(routes, ['en', 'cs'], {
145
+ '/products/:slug': {
146
+ en: '/catalog/:slug',
147
+ cs: '/produkty/:slug',
148
+ },
149
+ '/articles/:article': {
150
+ en: '/catalog/:article',
151
+ cs: '/clanky/:article',
152
+ },
153
+ }),
154
+ ).toThrow('generate the same physical route pattern');
155
+ });
156
+
157
+ test('rejects an alias owned by another route canonical pattern', () => {
158
+ const routes = [createRoute('/:lang/about'), createRoute('/:lang/company')];
159
+
160
+ expect(() =>
161
+ validateLocalisedUrls(routes, ['en', 'cs'], {
162
+ '/about': {
163
+ en: '/about-en',
164
+ cs: '/about-cs',
165
+ },
166
+ '/company': {
167
+ en: '/company-en',
168
+ cs: '/about',
169
+ },
170
+ }),
171
+ ).toThrow('generate the same physical route pattern "/about"');
172
+ });
173
+
174
+ test('rejects equal-specificity aliases that can match the same path', () => {
175
+ const routes = [
176
+ createRoute('/:lang/alpha/:slug'),
177
+ createRoute('/:lang/beta/:section'),
178
+ ];
179
+
180
+ expect(() =>
181
+ validateLocalisedUrls(routes, ['en', 'cs'], {
182
+ '/alpha/:slug': {
183
+ en: '/x/:slug',
184
+ cs: '/alfa/:slug',
185
+ },
186
+ '/beta/:section': {
187
+ en: '/:section/y',
188
+ cs: '/beta/:section',
189
+ },
190
+ }),
191
+ ).toThrow('overlapping route patterns');
192
+ });
193
+
194
+ test('disambiguates lossy route ID suffixes without rejecting valid URLs', () => {
195
+ const routes = [createRoute('/:lang/about')];
196
+ const localisedUrls = {
197
+ '/about': {
198
+ en: '/about',
199
+ cs: '/o.nas',
200
+ de: '/o_nas',
201
+ },
202
+ };
203
+
204
+ const localisedRoutes = applyLocalisedUrlsToRoutes(
205
+ routes,
206
+ ['en', 'cs', 'de'],
207
+ localisedUrls,
208
+ );
209
+
210
+ expect(
211
+ applyLocalisedUrlsToRoutes(routes, ['en', 'cs', 'de'], localisedUrls),
212
+ ).toEqual(localisedRoutes);
213
+ expect(localisedRoutes.map(route => route.path)).toEqual([
214
+ ':lang/about',
215
+ ':lang/o.nas',
216
+ ':lang/o_nas',
217
+ ]);
218
+ expect(new Set(localisedRoutes.map(route => route.id)).size).toBe(3);
219
+ });
220
+
221
+ test('rejects a generated route ID that collides with an existing route', () => {
222
+ const collidingRoute = {
223
+ ...createRoute('_mf/health'),
224
+ id: '/:lang/about__localised_lang_o_nas',
225
+ };
226
+
227
+ expect(() =>
228
+ applyLocalisedUrlsToRoutes(
229
+ [createRoute('/:lang/about'), collidingRoute],
230
+ ['en', 'cs'],
231
+ {
232
+ '/about': {
233
+ en: '/about',
234
+ cs: '/o.nas',
235
+ },
236
+ },
237
+ ),
238
+ ).toThrow('generated duplicate route ID');
239
+ });
240
+
241
+ test('rejects duplicate params and non-terminal splats', () => {
242
+ expect(() =>
243
+ validateLocalisedUrls(
244
+ [createRoute('/:lang/pairs/:id/:id')],
245
+ ['en', 'cs'],
246
+ {
247
+ '/pairs/:id/:id': {
248
+ en: '/pairs/:id/:id',
249
+ cs: '/dvojice/:id/:id',
250
+ },
251
+ },
252
+ ),
253
+ ).toThrow('duplicate path parameter ":id"');
254
+
255
+ expect(() =>
256
+ validateLocalisedUrls([createRoute('/:lang/docs/*/edit')], ['en', 'cs'], {
257
+ '/docs/*/edit': {
258
+ en: '/docs/*/edit',
259
+ cs: '/dokumenty/*/upravit',
260
+ },
261
+ }),
262
+ ).toThrow('splat parameter "*" must be the final segment');
263
+ });
264
+
265
+ test('expands route paths to localised aliases', () => {
266
+ const routes = [
267
+ createRoute(':lang', [
268
+ createRoute('terms-of-service'),
269
+ createRoute('products', [createRoute(':slug')]),
270
+ ]),
271
+ ];
272
+
273
+ const localisedRoutes = applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
274
+ '/terms-of-service': {
275
+ en: '/terms-of-service',
276
+ cs: '/podminky-pouzivani',
277
+ },
278
+ '/products': {
279
+ en: '/products',
280
+ cs: '/produkty',
281
+ },
282
+ '/products/:slug': {
283
+ en: '/products/:slug',
284
+ cs: '/produkty/:slug',
285
+ },
286
+ });
287
+
288
+ const localeRoute = localisedRoutes[0] as LocalisedRoute;
289
+ expect(localeRoute.children?.map(route => route.path)).toEqual([
290
+ 'terms-of-service',
291
+ 'podminky-pouzivani',
292
+ 'products',
293
+ 'produkty',
294
+ ]);
295
+
296
+ const productRoutes = localeRoute.children?.filter(
297
+ route => route.path === 'products' || route.path === 'produkty',
298
+ );
299
+ expect(productRoutes?.[0].children?.map(route => route.path)).toEqual([
300
+ ':slug',
301
+ ]);
302
+ expect(productRoutes?.[1].children?.map(route => route.path)).toEqual([
303
+ ':slug',
304
+ ]);
305
+ });
306
+
307
+ test('expands flat locale-prefixed route paths with canonical keys', () => {
308
+ const routes = [
309
+ createRoute('/:lang/about'),
310
+ createRoute('/:lang/products/:slug'),
311
+ ];
312
+
313
+ const localisedRoutes = applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
314
+ '/about': {
315
+ en: '/about',
316
+ cs: '/o-nas',
317
+ },
318
+ '/products/:slug': {
319
+ en: '/products/:slug',
320
+ cs: '/produkty/:slug',
321
+ },
322
+ });
323
+
324
+ expect(localisedRoutes.map(route => route.path)).toEqual([
325
+ ':lang/about',
326
+ ':lang/o-nas',
327
+ ':lang/products/:slug',
328
+ ':lang/produkty/:slug',
329
+ ]);
330
+ });
331
+
332
+ test('resolves current localized path to the target language', () => {
333
+ const localisedUrls = {
334
+ '/terms-of-service': {
335
+ en: '/terms-of-service',
336
+ cs: '/podminky-pouzivani',
337
+ },
338
+ '/products/:slug': {
339
+ en: '/products/:slug',
340
+ cs: '/produkty/:slug',
341
+ },
342
+ };
343
+
344
+ expect(
345
+ resolveLocalisedPath(
346
+ '/terms-of-service',
347
+ 'cs',
348
+ ['en', 'cs'],
349
+ localisedUrls,
350
+ ),
351
+ ).toBe('/podminky-pouzivani');
352
+ expect(
353
+ resolveLocalisedPath('/produkty/cervena-bota', 'en', ['en', 'cs'], {
354
+ ...localisedUrls,
355
+ '/products/:slug': {
356
+ en: '/products/:slug',
357
+ cs: '/produkty/:slug',
358
+ },
359
+ }),
360
+ ).toBe('/products/cervena-bota');
361
+ });
362
+
363
+ test('resolves optional route params', () => {
364
+ const localisedUrls = {
365
+ '/products/:slug?': {
366
+ en: '/products/:slug?',
367
+ cs: '/produkty/:slug?',
368
+ },
369
+ };
370
+
371
+ expect(
372
+ resolveLocalisedPath('/products', 'cs', ['en', 'cs'], localisedUrls),
373
+ ).toBe('/produkty');
374
+ expect(
375
+ resolveLocalisedPath(
376
+ '/produkty/cervena-bota',
377
+ 'en',
378
+ ['en', 'cs'],
379
+ localisedUrls,
380
+ ),
381
+ ).toBe('/products/cervena-bota');
382
+ });
383
+
384
+ test('empty splats round-trip and exact static routes keep precedence', () => {
385
+ expect(matchPathPattern('/docs', '/docs/*')).toEqual({ '*': '' });
386
+ expect(
387
+ resolveLocalisedPath('/docs', 'cs', ['en', 'cs'], {
388
+ '/docs/*': {
389
+ en: '/docs/*',
390
+ cs: '/dokumenty/*',
391
+ },
392
+ }),
393
+ ).toBe('/dokumenty');
394
+
395
+ expect(
396
+ resolveLocalisedPath('/docs', 'cs', ['en', 'cs'], {
397
+ '/docs/*': {
398
+ en: '/docs/*',
399
+ cs: '/dokumenty/*',
400
+ },
401
+ '/docs': {
402
+ en: '/docs',
403
+ cs: '/dokumenty-domu',
404
+ },
405
+ }),
406
+ ).toBe('/dokumenty-domu');
407
+ });
408
+
409
+ test('encodes wildcard params while preserving path separators', () => {
410
+ const localisedUrls = {
411
+ '/docs/*': {
412
+ en: '/docs/*',
413
+ cs: '/dokumenty/*',
414
+ },
415
+ };
416
+
417
+ expect(
418
+ resolveLocalisedPath(
419
+ '/docs/a%20b/c%23d',
420
+ 'cs',
421
+ ['en', 'cs'],
422
+ localisedUrls,
423
+ ),
424
+ ).toBe('/dokumenty/a%20b/c%23d');
425
+ expect(
426
+ resolveCanonicalLocalisedPath(
427
+ '/dokumenty/a%20b/c%23d',
428
+ ['en', 'cs'],
429
+ localisedUrls,
430
+ ),
431
+ ).toBe('/docs/a%20b/c%23d');
432
+ });
433
+
434
+ test('resolves static patterns before param patterns', () => {
435
+ const localisedUrls = {
436
+ '/products/:slug': {
437
+ en: '/products/:slug',
438
+ cs: '/produkty/:slug',
439
+ },
440
+ '/products/new': {
441
+ en: '/products/new',
442
+ cs: '/produkty/novinka',
443
+ },
444
+ };
445
+
446
+ expect(
447
+ resolveLocalisedPath('/products/new', 'cs', ['en', 'cs'], localisedUrls),
448
+ ).toBe('/produkty/novinka');
449
+ expect(
450
+ resolveCanonicalLocalisedPath(
451
+ '/produkty/novinka',
452
+ ['en', 'cs'],
453
+ localisedUrls,
454
+ ),
455
+ ).toBe('/products/new');
456
+ });
457
+
458
+ test('reverse resolution is deterministic for equally specific matches', () => {
459
+ const firstOrder = {
460
+ '/z/:slug': {
461
+ en: '/x/:slug',
462
+ cs: '/z/:slug',
463
+ },
464
+ '/a/:section': {
465
+ en: '/:section/y',
466
+ cs: '/a/:section',
467
+ },
468
+ };
469
+ const reverseOrder = {
470
+ '/a/:section': firstOrder['/a/:section'],
471
+ '/z/:slug': firstOrder['/z/:slug'],
472
+ };
473
+
474
+ expect(
475
+ resolveCanonicalLocalisedPath('/x/y', ['en', 'cs'], firstOrder),
476
+ ).toBe('/a/x');
477
+ expect(
478
+ resolveCanonicalLocalisedPath('/x/y', ['en', 'cs'], reverseOrder),
479
+ ).toBe('/a/x');
480
+ });
481
+
482
+ test('reverse resolution prefers the most specific localised source pattern', () => {
483
+ const localisedUrls = {
484
+ '/catalog/:section/:item/:detail': {
485
+ en: '/:section/:item/:detail',
486
+ cs: '/katalog/:section/:item/:detail',
487
+ },
488
+ '/:category/:slug': {
489
+ en: '/store/:category/:slug',
490
+ cs: '/obchod/:category/:slug',
491
+ },
492
+ };
493
+
494
+ expect(
495
+ resolveCanonicalLocalisedPath(
496
+ '/store/shoes/red',
497
+ ['en', 'cs'],
498
+ localisedUrls,
499
+ ),
500
+ ).toBe('/shoes/red');
501
+ });
502
+
503
+ test('a specific localised source outranks a broad canonical source', () => {
504
+ const localisedUrls = {
505
+ '/:section/:slug': {
506
+ en: '/generic/:section/:slug',
507
+ cs: '/obecne/:section/:slug',
508
+ },
509
+ '/products/:id': {
510
+ en: '/fixed/:id',
511
+ cs: '/produkty/:id',
512
+ },
513
+ };
514
+
515
+ expect(
516
+ resolveCanonicalLocalisedPath('/fixed/red', ['en', 'cs'], localisedUrls),
517
+ ).toBe('/products/red');
518
+ });
519
+
520
+ test('missing required target params never collapse into empty segments', () => {
521
+ expect(() =>
522
+ resolveLocalisedPath('/products/red-shoe', 'cs', ['en', 'cs'], {
523
+ '/products/:slug': {
524
+ en: '/products/:slug',
525
+ cs: '/produkty/:product',
526
+ },
527
+ }),
528
+ ).toThrow('Missing required path parameter "product"');
529
+
530
+ expect(() =>
531
+ resolveLocalisedPath('/products/red-shoe', 'cs', ['en', 'cs'], {
532
+ '/products/:slug': {
533
+ en: '/products/:slug',
534
+ cs: '/produkty/:toString',
535
+ },
536
+ }),
537
+ ).toThrow('Missing required path parameter "toString"');
538
+ });
539
+
540
+ test('localises and canonicalises full target pathnames through one helper', () => {
541
+ const localisedUrls = {
542
+ '/products/:slug': {
543
+ en: '/products/:slug',
544
+ cs: '/produkty/:slug',
545
+ },
546
+ };
547
+
548
+ expect(
549
+ localiseTargetPathname(
550
+ '/en/products/cervena-bota',
551
+ 'cs',
552
+ ['en', 'cs'],
553
+ localisedUrls,
554
+ ),
555
+ ).toBe('/cs/produkty/cervena-bota');
556
+ expect(
557
+ canonicalTargetPathname(
558
+ '/cs/produkty/cervena-bota',
559
+ ['en', 'cs'],
560
+ localisedUrls,
561
+ ),
562
+ ).toBe('/products/cervena-bota');
563
+ });
564
+
565
+ test('strips case-insensitive locale prefixes before relocalising full pathnames', () => {
566
+ const localisedUrls = {
567
+ '/products/:slug': {
568
+ en: '/products/:slug',
569
+ cs: '/produkty/:slug',
570
+ },
571
+ };
572
+
573
+ expect(
574
+ localiseTargetPathname(
575
+ '/CS/produkty/cervena-bota',
576
+ 'en',
577
+ ['en', 'cs'],
578
+ localisedUrls,
579
+ ),
580
+ ).toBe('/en/products/cervena-bota');
581
+ expect(
582
+ canonicalTargetPathname(
583
+ '/CS/produkty/cervena-bota',
584
+ ['en', 'cs'],
585
+ localisedUrls,
586
+ ),
587
+ ).toBe('/products/cervena-bota');
588
+ });
589
+
590
+ test('resolves nested optional route params with translated ancestors', () => {
591
+ const localisedUrls = {
592
+ '/checkout': {
593
+ en: '/checkout',
594
+ cs: '/pokladna',
595
+ },
596
+ '/checkout/thank-you': {
597
+ en: '/checkout/thank-you',
598
+ cs: '/pokladna/dekujeme',
599
+ },
600
+ '/checkout/thank-you/:orderId?': {
601
+ en: '/checkout/thank-you/:orderId?',
602
+ cs: '/pokladna/dekujeme/:orderId?',
603
+ },
604
+ };
605
+
606
+ expect(
607
+ resolveLocalisedPath(
608
+ '/checkout/thank-you',
609
+ 'cs',
610
+ ['en', 'cs'],
611
+ localisedUrls,
612
+ ),
613
+ ).toBe('/pokladna/dekujeme');
614
+ expect(
615
+ resolveLocalisedPath(
616
+ '/pokladna/dekujeme/ABC-123',
617
+ 'en',
618
+ ['en', 'cs'],
619
+ localisedUrls,
620
+ ),
621
+ ).toBe('/checkout/thank-you/ABC-123');
622
+ });
623
+ });
624
+
625
+ describe('matchPathPattern decoding', () => {
626
+ test('keeps prototype-named params as ordinary own values', () => {
627
+ const params = matchPathPattern('/products/red', '/products/:__proto__');
628
+
629
+ expect(params && Object.hasOwn(params, '__proto__')).toBe(true);
630
+ expect(
631
+ params && Object.getOwnPropertyDescriptor(params, '__proto__')?.value,
632
+ ).toBe('red');
633
+ });
634
+
635
+ test('decodes valid percent-encoded params', () => {
636
+ expect(
637
+ matchPathPattern('/produkty/%C4%8Derven%C3%A1', '/produkty/:slug'),
638
+ ).toEqual({ slug: 'červená' });
639
+ });
640
+
641
+ test('treats malformed percent-encoding as no match instead of throwing', () => {
642
+ expect(() =>
643
+ matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
644
+ ).not.toThrow();
645
+ expect(
646
+ matchPathPattern('/produkty/%E0%A4%A', '/produkty/:slug'),
647
+ ).toBeNull();
648
+ });
649
+
650
+ test('keeps literal bracket segments in pathnames (no pattern rewrite)', () => {
651
+ expect(matchPathPattern('/produkty/[x]', '/produkty/:slug')).toEqual({
652
+ slug: '[x]',
653
+ });
654
+ });
655
+
656
+ test('resolveLocalisedPath returns malformed paths unchanged', () => {
657
+ const localisedUrls = {
658
+ '/products/:slug': {
659
+ en: '/products/:slug',
660
+ cs: '/produkty/:slug',
661
+ },
662
+ };
663
+
664
+ expect(
665
+ resolveLocalisedPath(
666
+ '/produkty/%E0%A4%A',
667
+ 'en',
668
+ ['en', 'cs'],
669
+ localisedUrls,
670
+ ),
671
+ ).toBe('/produkty/%E0%A4%A');
672
+ });
673
+ });