@bleedingdev/modern-js-plugin-i18n 3.2.0-ultramodern.119 → 3.2.0-ultramodern.120

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 (42) hide show
  1. package/README.md +221 -11
  2. package/dist/cjs/runtime/I18nLink.js +7 -17
  3. package/dist/cjs/runtime/Link.js +252 -0
  4. package/dist/cjs/runtime/canonicalRoutes.js +18 -0
  5. package/dist/cjs/runtime/index.js +23 -0
  6. package/dist/cjs/runtime/localizedPaths.js +105 -0
  7. package/dist/cjs/runtime/utils.js +22 -5
  8. package/dist/cjs/shared/localisedUrls.js +32 -2
  9. package/dist/esm/runtime/I18nLink.mjs +6 -16
  10. package/dist/esm/runtime/Link.mjs +209 -0
  11. package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
  12. package/dist/esm/runtime/index.mjs +4 -2
  13. package/dist/esm/runtime/localizedPaths.mjs +58 -0
  14. package/dist/esm/runtime/utils.mjs +18 -4
  15. package/dist/esm/shared/localisedUrls.mjs +24 -3
  16. package/dist/esm-node/runtime/I18nLink.mjs +6 -16
  17. package/dist/esm-node/runtime/Link.mjs +210 -0
  18. package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
  19. package/dist/esm-node/runtime/index.mjs +4 -2
  20. package/dist/esm-node/runtime/localizedPaths.mjs +59 -0
  21. package/dist/esm-node/runtime/utils.mjs +18 -4
  22. package/dist/esm-node/shared/localisedUrls.mjs +24 -3
  23. package/dist/types/runtime/I18nLink.d.ts +4 -13
  24. package/dist/types/runtime/Link.d.ts +56 -0
  25. package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
  26. package/dist/types/runtime/index.d.ts +5 -1
  27. package/dist/types/runtime/localizedPaths.d.ts +39 -0
  28. package/dist/types/runtime/utils.d.ts +12 -3
  29. package/dist/types/shared/localisedUrls.d.ts +8 -0
  30. package/package.json +13 -13
  31. package/rstest.config.mts +2 -2
  32. package/src/runtime/I18nLink.tsx +13 -46
  33. package/src/runtime/Link.tsx +414 -0
  34. package/src/runtime/canonicalRoutes.ts +93 -0
  35. package/src/runtime/index.tsx +24 -2
  36. package/src/runtime/localizedPaths.ts +118 -0
  37. package/src/runtime/utils.ts +24 -5
  38. package/src/shared/localisedUrls.ts +63 -3
  39. package/tests/link.test.tsx +475 -0
  40. package/tests/linkTypes.test.ts +28 -0
  41. package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
  42. package/tests/type-fixture/tsconfig.json +15 -0
@@ -0,0 +1,118 @@
1
+ import { useMemo } from 'react';
2
+ import type { LocalisedUrlsOption } from '../shared/localisedUrls';
3
+ import {
4
+ resolveCanonicalLocalisedPath,
5
+ resolveLocalisedUrlsConfig,
6
+ } from '../shared/localisedUrls';
7
+ import { useModernI18n } from './context';
8
+ import { useI18nRouterAdapter } from './routerAdapter';
9
+ import { buildLocalizedUrl, splitUrlTarget } from './utils';
10
+
11
+ export interface LocalizedPathsConfig {
12
+ languages: string[];
13
+ localisedUrls?: LocalisedUrlsOption;
14
+ }
15
+
16
+ /**
17
+ * Localize a canonical, language-agnostic target for the given language:
18
+ * adds the language prefix and applies `localisedUrls` pattern mapping.
19
+ * `?search`/`#hash` suffixes are preserved verbatim.
20
+ */
21
+ export const localizePath = (
22
+ pathname: string,
23
+ language: string,
24
+ config: LocalizedPathsConfig,
25
+ ): string =>
26
+ buildLocalizedUrl(pathname, language, config.languages, config.localisedUrls);
27
+
28
+ /**
29
+ * Reverse of {@link localizePath}: strip the language prefix and map localized
30
+ * slugs back to the canonical pattern's path. `?search`/`#hash` suffixes are
31
+ * preserved verbatim.
32
+ */
33
+ export const canonicalPath = (
34
+ target: string,
35
+ config: LocalizedPathsConfig,
36
+ ): string => {
37
+ const { pathname, search, hash } = splitUrlTarget(target);
38
+ const segments = pathname.split('/').filter(Boolean);
39
+ const pathWithoutLanguage =
40
+ segments.length > 0 && config.languages.includes(segments[0])
41
+ ? `/${segments.slice(1).join('/')}`
42
+ : pathname || '/';
43
+ const localisedUrlsConfig = resolveLocalisedUrlsConfig(config.localisedUrls);
44
+ const resolvedPath = localisedUrlsConfig.enabled
45
+ ? resolveCanonicalLocalisedPath(
46
+ pathWithoutLanguage,
47
+ config.languages,
48
+ localisedUrlsConfig.map,
49
+ )
50
+ : pathWithoutLanguage;
51
+
52
+ return `${resolvedPath}${search}${hash}`;
53
+ };
54
+
55
+ export interface UseLocalizedPathsReturn {
56
+ localizePath: (pathname: string, language: string) => string;
57
+ canonicalPath: (pathname: string) => string;
58
+ }
59
+
60
+ /**
61
+ * Context-bound versions of {@link localizePath} and {@link canonicalPath} —
62
+ * the plugin configuration (languages, localisedUrls) is read from the i18n
63
+ * provider, so apps never copy pattern-matching helpers again.
64
+ */
65
+ export const useLocalizedPaths = (): UseLocalizedPathsReturn => {
66
+ const { supportedLanguages, localisedUrls } = useModernI18n();
67
+
68
+ return useMemo(() => {
69
+ const config: LocalizedPathsConfig = {
70
+ languages: supportedLanguages,
71
+ localisedUrls,
72
+ };
73
+
74
+ return {
75
+ localizePath: (pathname: string, language: string) =>
76
+ localizePath(pathname, language, config),
77
+ canonicalPath: (pathname: string) => canonicalPath(pathname, config),
78
+ };
79
+ }, [supportedLanguages, localisedUrls]);
80
+ };
81
+
82
+ export interface UseLocalizedLocationReturn {
83
+ language: string;
84
+ /** Canonical (language-agnostic) path of the current location. */
85
+ canonical: string;
86
+ /** Per-language hrefs for the current location, search+hash preserved. */
87
+ alternates: Record<string, string>;
88
+ }
89
+
90
+ /**
91
+ * Per-language hrefs for the current location — for hreflang `<link>` tags and
92
+ * language switchers. SSR-safe: the location comes from the router adapter.
93
+ */
94
+ export const useLocalizedLocation = (): UseLocalizedLocationReturn => {
95
+ const { language, supportedLanguages, localisedUrls } = useModernI18n();
96
+ const { location } = useI18nRouterAdapter();
97
+ const pathname = location?.pathname ?? '/';
98
+ const search = location?.search ?? '';
99
+ const hash = location?.hash ?? '';
100
+
101
+ return useMemo(() => {
102
+ const config: LocalizedPathsConfig = {
103
+ languages: supportedLanguages,
104
+ localisedUrls,
105
+ };
106
+ const alternates: Record<string, string> = {};
107
+ for (const supportedLanguage of supportedLanguages) {
108
+ alternates[supportedLanguage] =
109
+ `${localizePath(pathname, supportedLanguage, config)}${search}${hash}`;
110
+ }
111
+
112
+ return {
113
+ language,
114
+ canonical: canonicalPath(pathname, config),
115
+ alternates,
116
+ };
117
+ }, [language, supportedLanguages, localisedUrls, pathname, search, hash]);
118
+ };
@@ -45,25 +45,44 @@ export const getLanguageFromPath = (
45
45
  return fallbackLanguage;
46
46
  };
47
47
 
48
+ /**
49
+ * Split a link target into its pathname, search and hash parts without
50
+ * relying on `new URL` (SSR-hot path; targets are relative).
51
+ */
52
+ export const splitUrlTarget = (
53
+ target: string,
54
+ ): { pathname: string; search: string; hash: string } => {
55
+ const hashIndex = target.indexOf('#');
56
+ const hash = hashIndex >= 0 ? target.slice(hashIndex) : '';
57
+ const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
58
+ const searchIndex = beforeHash.indexOf('?');
59
+ const search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : '';
60
+ const pathname =
61
+ searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash;
62
+
63
+ return { pathname, search, hash };
64
+ };
65
+
48
66
  /**
49
67
  * Helper function to build localized URL
50
- * @param pathname - The current pathname
68
+ * @param target - The language-agnostic target; may include `?search` and `#hash`
51
69
  * @param language - The target language
52
70
  * @param languages - Array of supported languages
53
- * @returns The localized URL path
71
+ * @returns The localized URL path with search and hash re-appended verbatim
54
72
  */
55
73
  export const buildLocalizedUrl = (
56
- pathname: string,
74
+ target: string,
57
75
  language: string,
58
76
  languages: string[],
59
77
  localisedUrls?: boolean | LocalisedUrlsMap,
60
78
  ): string => {
79
+ const { pathname, search, hash } = splitUrlTarget(target);
61
80
  const segments = pathname.split('/').filter(Boolean);
62
81
  const localisedUrlsConfig = resolveLocalisedUrlsConfig(localisedUrls);
63
82
  const pathWithoutLanguage =
64
83
  segments.length > 0 && languages.includes(segments[0])
65
84
  ? `/${segments.slice(1).join('/')}`
66
- : pathname;
85
+ : pathname || '/';
67
86
  const resolvedPath = localisedUrlsConfig.enabled
68
87
  ? resolveLocalisedPath(
69
88
  pathWithoutLanguage,
@@ -74,7 +93,7 @@ export const buildLocalizedUrl = (
74
93
  : pathWithoutLanguage;
75
94
  const resolvedSegments = resolvedPath.split('/').filter(Boolean);
76
95
 
77
- return `/${[language, ...resolvedSegments].join('/')}`;
96
+ return `/${[language, ...resolvedSegments].join('/')}${search}${hash}`;
78
97
  };
79
98
 
80
99
  export const detectLanguageFromPath = (
@@ -224,7 +224,7 @@ const transformLocalisedRoute = (
224
224
  languages,
225
225
  localisedUrlEntry,
226
226
  ).map((localisedPath, index) =>
227
- cloneRouteWithLocalisedPath(baseRoute, localisedPath, index),
227
+ cloneRouteWithLocalisedPath(baseRoute, localisedPath, index, canonicalPath),
228
228
  );
229
229
  };
230
230
 
@@ -251,6 +251,7 @@ const cloneRouteWithLocalisedPath = (
251
251
  route: NestedRouteForCli | PageRoute,
252
252
  path: string,
253
253
  index: number,
254
+ canonicalPath: string,
254
255
  ): NestedRouteForCli | PageRoute => {
255
256
  const leadingLocaleParam = getLeadingLocaleParam(route.path);
256
257
  const localisedPath = leadingLocaleParam
@@ -260,6 +261,10 @@ const cloneRouteWithLocalisedPath = (
260
261
  ...route,
261
262
  path: localisedPath,
262
263
  } as NestedRouteForCli | PageRoute;
264
+ // Language-agnostic source pattern; lets downstream codegen collapse the
265
+ // localized physical variants back to one canonical route.
266
+ (routeWithPath as { modernCanonicalPath?: string }).modernCanonicalPath =
267
+ canonicalPath;
263
268
 
264
269
  return index === 0
265
270
  ? routeWithPath
@@ -324,7 +329,7 @@ const compilePathPattern = (pattern: string) => {
324
329
  };
325
330
  };
326
331
 
327
- const matchPathPattern = (
332
+ export const matchPathPattern = (
328
333
  pathname: string,
329
334
  pattern: string,
330
335
  ): Record<string, string> | null => {
@@ -340,7 +345,7 @@ const matchPathPattern = (
340
345
  }, {});
341
346
  };
342
347
 
343
- const buildPathFromPattern = (
348
+ export const buildPathFromPattern = (
344
349
  pattern: string,
345
350
  params: Record<string, string>,
346
351
  ): string => {
@@ -370,6 +375,22 @@ export const resolveLocalisedPath = (
370
375
  ): string => {
371
376
  const normalizedPathname = normalisePathPattern(pathname);
372
377
 
378
+ // Canonical keys take precedence: authors write language-agnostic paths,
379
+ // which are the map keys, even when no language pattern equals the key.
380
+ for (const [canonicalPattern, localisedUrlEntry] of Object.entries(
381
+ localisedUrls,
382
+ )) {
383
+ const targetPattern = localisedUrlEntry[targetLanguage];
384
+ if (!targetPattern) {
385
+ continue;
386
+ }
387
+
388
+ const params = matchPathPattern(normalizedPathname, canonicalPattern);
389
+ if (params) {
390
+ return buildPathFromPattern(targetPattern, params);
391
+ }
392
+ }
393
+
373
394
  for (const localisedUrlEntry of Object.values(localisedUrls)) {
374
395
  const targetPattern = localisedUrlEntry[targetLanguage];
375
396
  if (!targetPattern) {
@@ -391,3 +412,42 @@ export const resolveLocalisedPath = (
391
412
 
392
413
  return normalizedPathname;
393
414
  };
415
+
416
+ /**
417
+ * Reverse-map a language-specific pathname (without language prefix) back to
418
+ * the canonical, language-agnostic path: localized slug patterns are matched
419
+ * against every language variant and rebuilt from the canonical map key.
420
+ */
421
+ export const resolveCanonicalLocalisedPath = (
422
+ pathname: string,
423
+ languages: string[],
424
+ localisedUrls: LocalisedUrlsMap,
425
+ ): string => {
426
+ const normalizedPathname = normalisePathPattern(pathname);
427
+
428
+ for (const [canonicalPattern, localisedUrlEntry] of Object.entries(
429
+ localisedUrls,
430
+ )) {
431
+ const canonicalParams = matchPathPattern(
432
+ normalizedPathname,
433
+ canonicalPattern,
434
+ );
435
+ if (canonicalParams) {
436
+ return buildPathFromPattern(canonicalPattern, canonicalParams);
437
+ }
438
+
439
+ for (const language of languages) {
440
+ const sourcePattern = localisedUrlEntry[language];
441
+ if (!sourcePattern) {
442
+ continue;
443
+ }
444
+
445
+ const params = matchPathPattern(normalizedPathname, sourcePattern);
446
+ if (params) {
447
+ return buildPathFromPattern(canonicalPattern, params);
448
+ }
449
+ }
450
+ }
451
+
452
+ return normalizedPathname;
453
+ };