@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.
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
@@ -11,19 +11,28 @@ export interface ResolvedLocalisedUrlsConfig {
11
11
 
12
12
  const LOCALE_PARAM_NAMES = new Set(['lang', 'locale', 'language']);
13
13
 
14
- export const normalisePathPattern = (path: string): string => {
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 withoutTrailingSlash.replace(/\[(.+?)\]/g, ':$1');
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 === false) {
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: true, map: {} };
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
- const compilePathPattern = (pattern: string) => {
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 = normalisePathPattern(pattern).split('/').filter(Boolean);
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
- return {
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(normalisePathPattern(pathname));
428
+ const match = regexp.exec(normalisePathname(pathname));
338
429
  if (!match) {
339
430
  return null;
340
431
  }
341
432
 
342
- return names.reduce<Record<string, string>>((params, name, index) => {
343
- params[name] = decodeURIComponent(match[index + 1] || '');
344
- return params;
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 = normalisePathPattern(pathname);
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
- for (const [canonicalPattern, localisedUrlEntry] of Object.entries(
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
- for (const localisedUrlEntry of Object.values(localisedUrls)) {
395
- const targetPattern = localisedUrlEntry[targetLanguage];
396
- if (!targetPattern) {
397
- continue;
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
- const params = matchPathPattern(normalizedPathname, sourcePattern);
407
- if (params) {
408
- return buildPathFromPattern(targetPattern, params);
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 = normalisePathPattern(pathname);
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 [canonicalPattern, localisedUrlEntry] of Object.entries(
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
+ };
@@ -14,12 +14,14 @@ export interface BaseLocaleDetectionOptions {
14
14
  /**
15
15
  * Enables localised pathnames in addition to the locale prefix.
16
16
  *
17
- * - `false`: keep only locale-prefix behavior (`/en/about`).
18
- * - object: map canonical route paths to every configured language.
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
- * Defaults to `true` when `localePathRedirect` is enabled, so route
21
- * generation validates that every localisable route path has entries for all
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
+ });
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from '@rstest/core';
2
2
  import type { I18nInstance } from '../src/runtime/i18n';
3
- import { DEFAULT_I18NEXT_BACKEND_OPTIONS as NODE_DEFAULT_I18NEXT_BACKEND_OPTIONS } from '../src/runtime/i18n/backend/defaults.node';
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('uses the generated public locale directory for node fs backend defaults', () => {
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
- './config/public/locales/{{lng}}/{{ns}}.json',
29
+ `${resolveDefaultLocalesDir()}/{{lng}}/{{ns}}.json`,
23
30
  );
24
31
  });
25
32
 
@@ -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(