@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.
- package/README.md +221 -11
- package/dist/cjs/runtime/I18nLink.js +7 -17
- package/dist/cjs/runtime/Link.js +252 -0
- package/dist/cjs/runtime/canonicalRoutes.js +18 -0
- package/dist/cjs/runtime/index.js +23 -0
- package/dist/cjs/runtime/localizedPaths.js +105 -0
- package/dist/cjs/runtime/utils.js +22 -5
- package/dist/cjs/shared/localisedUrls.js +32 -2
- package/dist/esm/runtime/I18nLink.mjs +6 -16
- package/dist/esm/runtime/Link.mjs +209 -0
- package/dist/esm/runtime/canonicalRoutes.mjs +0 -0
- package/dist/esm/runtime/index.mjs +4 -2
- package/dist/esm/runtime/localizedPaths.mjs +58 -0
- package/dist/esm/runtime/utils.mjs +18 -4
- package/dist/esm/shared/localisedUrls.mjs +24 -3
- package/dist/esm-node/runtime/I18nLink.mjs +6 -16
- package/dist/esm-node/runtime/Link.mjs +210 -0
- package/dist/esm-node/runtime/canonicalRoutes.mjs +1 -0
- package/dist/esm-node/runtime/index.mjs +4 -2
- package/dist/esm-node/runtime/localizedPaths.mjs +59 -0
- package/dist/esm-node/runtime/utils.mjs +18 -4
- package/dist/esm-node/shared/localisedUrls.mjs +24 -3
- package/dist/types/runtime/I18nLink.d.ts +4 -13
- package/dist/types/runtime/Link.d.ts +56 -0
- package/dist/types/runtime/canonicalRoutes.d.ts +60 -0
- package/dist/types/runtime/index.d.ts +5 -1
- package/dist/types/runtime/localizedPaths.d.ts +39 -0
- package/dist/types/runtime/utils.d.ts +12 -3
- package/dist/types/shared/localisedUrls.d.ts +8 -0
- package/package.json +13 -13
- package/rstest.config.mts +2 -2
- package/src/runtime/I18nLink.tsx +13 -46
- package/src/runtime/Link.tsx +414 -0
- package/src/runtime/canonicalRoutes.ts +93 -0
- package/src/runtime/index.tsx +24 -2
- package/src/runtime/localizedPaths.ts +118 -0
- package/src/runtime/utils.ts +24 -5
- package/src/shared/localisedUrls.ts +63 -3
- package/tests/link.test.tsx +475 -0
- package/tests/linkTypes.test.ts +28 -0
- package/tests/type-fixture/linkTypes.fixture.tsx +51 -0
- 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
|
+
};
|
package/src/runtime/utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
};
|